Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc97bde02 | |||
| 2d75b2e80e | |||
| f088c4e9ff | |||
| 8a1cf909d4 | |||
| 33e785d22a | |||
| d16cec176b | |||
| 8744bee2dd |
@@ -10,7 +10,7 @@ Scala 3.5.1 · Gradle 9
|
||||
./test # Run all tests
|
||||
./coverage # Check coverage
|
||||
```
|
||||
Try to stick to these commands for consistency.
|
||||
Use consistently.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -25,14 +25,14 @@ Try to stick to these commands for consistency.
|
||||
|
||||
## Style
|
||||
|
||||
- Use immutable data and pure functions.
|
||||
- Keep functions under 30 lines. If you need "and" to describe it, split it.
|
||||
- Keep cyclomatic complexity under 15.
|
||||
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
|
||||
- Scan for duplicated logic before finishing. Extract it.
|
||||
- Immutable data, pure functions.
|
||||
- Functions under 30 lines. Need "and"? Split it.
|
||||
- Cyclomatic complexity under 15.
|
||||
- No comments. Names carry intent. Comment non-obvious algorithms only.
|
||||
- Scan duplicated logic. Extract.
|
||||
- Follow default Sonar style for Scala.
|
||||
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
|
||||
- Naming: types are PascalCase, functions/values are camelCase.
|
||||
- `Option`/`Either` for fallible ops. Skip exceptions for control flow.
|
||||
- Naming: types PascalCase, functions/values camelCase.
|
||||
|
||||
## Code Quality
|
||||
|
||||
@@ -40,23 +40,23 @@ Try to stick to these commands for consistency.
|
||||
|
||||
### Linters
|
||||
|
||||
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
|
||||
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
|
||||
- **scalafmt** — Enforces formatting. Check: `./gradlew spotlessScalaCheck`. Refactor: `./gradlew spotlessScalaApply`.
|
||||
- **scalafix** — Enforces style, detects unused imports/code. Run: `./gradlew scalafix`.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
|
||||
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
|
||||
- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
|
||||
- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
|
||||
- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
|
||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state—immutable throughout. Each move → new GameContext. Enables undo/redo without side effects.
|
||||
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens (no polling). GameEngine never imports UI.
|
||||
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as black box; rules don't know rest of core.
|
||||
- **Polyglot hash must follow spec index layout:** Piece keys use interleaved mapping `(pieceType * 2 + colorBit)` (black=0, white=1). Castling keys: `768..771`. En-passant file keys: `772..779`, XORed only if side-to-move has capturable en passant. Side-to-move key: `780` (white).
|
||||
- **Alpha-beta uses sequential PV search by default:** Parallel split disabled (fixed-window futures removed pruning effectiveness). Sequential PV default. Correctness + pruning quality > speculative parallelism.
|
||||
- **Search hash is updated incrementally per move:** Bot search updates Zobrist keys from parent hash with move deltas, not recomputing piece scans per node.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
|
||||
- **Tests are the spec.** Don't modify to pass. Fix requirements/code. Update only if requirements change.
|
||||
- Never read build folders. Ask permission if needed.
|
||||
- Keep this file up to date with any important decisions or conventions.
|
||||
- Keep file current with decisions + conventions.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,11 +64,9 @@ Try to stick to these commands for consistency.
|
||||
|
||||
### Two-Step Rule (mandatory)
|
||||
**Step 1 — Orient:** Use wiki articles to find WHERE things live.
|
||||
**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
|
||||
**Step 2 — Verify:** Read source files from wiki BEFORE coding.
|
||||
|
||||
Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
|
||||
They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
|
||||
**Never write or modify code based solely on wiki content — always read source files first.**
|
||||
Wiki = structural summaries (routes, models, file locations). No function logic, middleware internals, runtime behavior. Don't code from wiki alone—read sources.
|
||||
|
||||
Read in order at session start:
|
||||
1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
|
||||
@@ -76,8 +74,7 @@ Read in order at session start:
|
||||
3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
|
||||
4. `.codesight/CODESIGHT.md` — full context map for deep exploration
|
||||
|
||||
Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
|
||||
If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
|
||||
`[inferred]` routes = regex-detected. Verify sources. ⚠ in wiki? Re-run `codesight --wiki`.
|
||||
|
||||
Or use the codesight MCP server for on-demand queries:
|
||||
- `codesight_get_wiki_article` — read a specific wiki article by name
|
||||
@@ -87,13 +84,13 @@ Or use the codesight MCP server for on-demand queries:
|
||||
- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
|
||||
- `codesight_get_schema --model users` — specific model details
|
||||
|
||||
Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
|
||||
Consult codesight context first. Saves ~16.893 tokens/conversation.
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
graphify knowledge graph at graphify-out/.
|
||||
|
||||
Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
- Architecture/codebase questions? Read graphify-out/GRAPH_REPORT.md (god nodes, communities).
|
||||
- graphify-out/wiki/index.md exists? Use it (not raw files).
|
||||
- Code modified? Run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to sync graph.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Now-Chess
|
||||
|
||||
Scala 3.5.1 · Gradle 9
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
./clean # Clear build dirs — only when necessary
|
||||
./compile # Compile all modules — always run
|
||||
./test # Run all tests
|
||||
./coverage # Check coverage
|
||||
```
|
||||
Try to stick to these commands for consistency.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Role | Depends on |
|
||||
|--------|------|-----------|
|
||||
| `api` | Model / shared types | (none) |
|
||||
| `core` | Primary business logic | api, rule |
|
||||
| `rule` | Game rules | api |
|
||||
| `bot` | Bots and AI | api,rule,io |
|
||||
| `io` | Export formats | api, core |
|
||||
| `ui` | Entrypoint & UI | core, io |
|
||||
|
||||
## Style
|
||||
|
||||
- Use immutable data and pure functions.
|
||||
- Keep functions under 30 lines. If you need "and" to describe it, split it.
|
||||
- Keep cyclomatic complexity under 15.
|
||||
- Avoid comments. Let names carry intent; comment only non-obvious algorithms.
|
||||
- Scan for duplicated logic before finishing. Extract it.
|
||||
- Follow default Sonar style for Scala.
|
||||
- Use `Option` or `Either` for fallible operations; avoid exceptions for control flow.
|
||||
- Naming: types are PascalCase, functions/values are camelCase.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- **Coverage:** 100% condition coverage required in `api`, `core`, `rule`, `io` (mandatory); `ui` exempt.
|
||||
|
||||
### Linters
|
||||
|
||||
- **scalafmt** — enforces formatting; run `./gradlew spotlessScalaCheck` to check and `./gradlew spotlessScalaApply` to refactor.
|
||||
- **scalafix** — enforces style and detects unused imports/code; run `./gradlew scalafix` to apply rules.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **Immutable state as primary model:** GameContext (api) holds board, history, player state — immutable, passed through the system. Each move creates a new GameContext, enabling undo/redo without side effects.
|
||||
- **Observer pattern for UI decoupling:** GameEngine publishes move/state events; CommandInvoker queues moves; UI listens to events, not polling. GameEngine never imports UI code.
|
||||
- **RuleSet trait encapsulates rules:** Move generation, check, castling, en passant all in RuleSet impl. GameEngine calls rules as a black box; rules don't know about the rest of core.
|
||||
- **Polyglot hash must follow spec index layout:** piece keys use interleaved mapping `(pieceType * 2 + colorBit)` with black=0/white=1, castling keys are `768..771`, en-passant file keys are `772..779` and are XORed only if side-to-move has a pawn that can capture en passant, side-to-move key is `780` for white.
|
||||
- **Alpha-beta uses sequential PV search by default:** parallel split was disabled because fixed-window futures removed pruning effectiveness; correctness and pruning quality take priority over speculative parallelism.
|
||||
- **Search hash is updated incrementally per move:** bot search now updates Zobrist keys from parent hash with move deltas instead of recomputing piece scans at every node.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Tests are the spec.** Never modify tests to pass; modify requirements or code. Update tests only if requirements change.
|
||||
- Never read build folders. Ask permission if needed.
|
||||
- Keep this file up to date with any important decisions or conventions.
|
||||
|
||||
---
|
||||
|
||||
## Instructions for Claude Code
|
||||
|
||||
### Two-Step Rule (mandatory)
|
||||
**Step 1 — Orient:** Use wiki articles to find WHERE things live.
|
||||
**Step 2 — Verify:** Read the actual source files listed in the wiki article BEFORE writing any code.
|
||||
|
||||
Wiki articles are structural summaries extracted by AST. They show routes, models, and file locations.
|
||||
They do NOT show full function logic, middleware internals, or dynamic runtime behavior.
|
||||
**Never write or modify code based solely on wiki content — always read source files first.**
|
||||
|
||||
Read in order at session start:
|
||||
1. `.codesight/wiki/index.md` — orientation map (~200 tokens)
|
||||
2. `.codesight/wiki/overview.md` — architecture overview (~500 tokens)
|
||||
3. Domain article (e.g. `.codesight/wiki/auth.md`) → check "Source Files" section → read those files
|
||||
4. `.codesight/CODESIGHT.md` — full context map for deep exploration
|
||||
|
||||
Routes marked `[inferred]` in wiki articles were detected via regex — verify against source before trusting.
|
||||
If any source file shows ⚠ in the wiki, re-run `codesight --wiki` before proceeding.
|
||||
|
||||
Or use the codesight MCP server for on-demand queries:
|
||||
- `codesight_get_wiki_article` — read a specific wiki article by name
|
||||
- `codesight_get_wiki_index` — get the wiki index
|
||||
- `codesight_get_summary` — quick project overview
|
||||
- `codesight_get_routes --prefix /api/users` — filtered routes
|
||||
- `codesight_get_blast_radius --file src/lib/db.ts` — impact analysis before changes
|
||||
- `codesight_get_schema --model users` — specific model details
|
||||
|
||||
Only open specific files after consulting codesight context. This saves ~16.893 tokens per conversation.
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
|
||||
Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||
+44
-20
@@ -8,6 +8,49 @@ plugins {
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
// Canonical coverage exclusions — glob patterns consumed by Sonar directly;
|
||||
// converted to scoverage regexes via globToScoverageRegex for instrumentation-time exclusion.
|
||||
val coverageExclusions = listOf(
|
||||
// UI renders JavaFX components; headless test environments cannot exercise rendering paths
|
||||
"modules/ui/**",
|
||||
// FastParse macro-generated combinators produce synthetic branches that scoverage marks as uncovered
|
||||
"modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse*",
|
||||
// NNUE inference pipeline — coverage requires a trained model file not present in CI
|
||||
"**/bot/**/NNUE.scala",
|
||||
"**/bot/**/NNUEBot.scala",
|
||||
"**/bot/**/EvaluationNNUE.scala",
|
||||
// NBAI binary format loader/writer — error paths require crafted corrupt files; migrator is a one-shot tool
|
||||
"**/bot/**/NbaiLoader.scala",
|
||||
"**/bot/**/NbaiModel.scala",
|
||||
"**/bot/**/NbaiMigrator.scala",
|
||||
"**/bot/**/NbaiWriter.scala",
|
||||
// PolyglotBook — binary I/O and dead-code guards (bit-masked fields can never exceed valid range)
|
||||
"**/bot/**/PolyglotBook.scala",
|
||||
"**/bot/**/MoveOrdering.scala",
|
||||
"**/bot/**/AlphaBetaSearch.scala",
|
||||
// DTO case class synthetic methods (Scala compiler-generated apply/$default params)
|
||||
"**/api/src/main/scala/de/nowchess/api/dto/**Dto.scala",
|
||||
// Core infrastructure: exception classes, config, registry implementation, game entry
|
||||
"**/core/src/main/scala/de/nowchess/chess/exception/**",
|
||||
"**/core/src/main/scala/de/nowchess/chess/config/**",
|
||||
"**/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"
|
||||
)
|
||||
|
||||
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
||||
// Order matters: protect ** before converting lone *, escape dots last.
|
||||
fun globToScoverageRegex(glob: String): String =
|
||||
glob
|
||||
.replace("**", "^@")
|
||||
.replace("*", "[^/]*")
|
||||
.replace(".", "\\.")
|
||||
.replace("^@", ".*")
|
||||
.let { ".*$it" }
|
||||
|
||||
extra["SCOVERAGE_EXCLUDED"] = coverageExclusions.map(::globToScoverageRegex)
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "Now-Chess-Systems")
|
||||
@@ -22,26 +65,7 @@ sonar {
|
||||
}.joinToString(",")
|
||||
|
||||
property("sonar.scala.coverage.reportPaths", scoverageReports)
|
||||
property(
|
||||
"sonar.coverage.exclusions",
|
||||
// UI renders JavaFX components; headless test environments cannot exercise rendering paths
|
||||
"modules/ui/**," +
|
||||
// FastParse macro-generated combinators produce synthetic branches that scoverage marks as uncovered
|
||||
"modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse*," +
|
||||
// NNUE inference pipeline — coverage requires a trained model file not present in CI
|
||||
"**/bot/**/NNUE.scala," +
|
||||
"**/bot/**/NNUEBot.scala," +
|
||||
"**/bot/**/EvaluationNNUE.scala," +
|
||||
// NBAI binary format loader/writer — error paths require crafted corrupt files; migrator is a one-shot tool
|
||||
"**/bot/**/NbaiLoader.scala," +
|
||||
"**/bot/**/NbaiModel.scala," +
|
||||
"**/bot/**/NbaiMigrator.scala," +
|
||||
"**/bot/**/NbaiWriter.scala," +
|
||||
// PolyglotBook — binary I/O and dead-code guards (bit-masked fields can never exceed valid range)
|
||||
"**/bot/**/PolyglotBook.scala," +
|
||||
"**/bot/**/MoveOrdering.scala," +
|
||||
"**/bot/**/AlphaBetaSearch.scala"
|
||||
)
|
||||
property("sonar.coverage.exclusions", coverageExclusions.joinToString(","))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ 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()
|
||||
@@ -19,6 +21,7 @@ scala {
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
configurations.scoverage {
|
||||
@@ -31,7 +34,7 @@ configurations.scoverage {
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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()
|
||||
@@ -26,16 +28,7 @@ scoverage {
|
||||
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
|
||||
)
|
||||
)
|
||||
excludedFiles.set(
|
||||
listOf(
|
||||
".*NNUE\\.scala",
|
||||
".*NNUEBot\\.scala",
|
||||
".*NbaiLoader\\.scala",
|
||||
".*NbaiMigrator\\.scala",
|
||||
".*NbaiWriter\\.scala",
|
||||
".*PolyglotBook\\.scala",
|
||||
)
|
||||
)
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
@@ -44,7 +37,7 @@ tasks.withType<ScalaCompile> {
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"created": "2026-04-13T19:58:38.629943",
|
||||
"total_positions": 3022562,
|
||||
"stockfish_depth": 12,
|
||||
"sources": [
|
||||
{
|
||||
"type": "legacy_import",
|
||||
"path": "data/training_data.jsonl",
|
||||
"count": 2009355,
|
||||
"note": "Migrated from data/training_data.jsonl"
|
||||
},
|
||||
{
|
||||
"type": "test_extend",
|
||||
"count": 4,
|
||||
"actual_count": 3
|
||||
},
|
||||
{
|
||||
"type": "test_new_positions",
|
||||
"count": 3,
|
||||
"actual_count": 3
|
||||
},
|
||||
{
|
||||
"type": "test_mixed",
|
||||
"count": 5,
|
||||
"actual_count": 0
|
||||
},
|
||||
{
|
||||
"type": "test_all_dups",
|
||||
"count": 2,
|
||||
"actual_count": 0
|
||||
},
|
||||
{
|
||||
"type": "guaranteed_unique",
|
||||
"count": 10,
|
||||
"actual_count": 8
|
||||
},
|
||||
{
|
||||
"type": "merged_sources",
|
||||
"count": 600000,
|
||||
"sources": [
|
||||
{
|
||||
"type": "tactical",
|
||||
"count": 600000,
|
||||
"max_puzzles": 600000
|
||||
}
|
||||
],
|
||||
"actual_count": 599993
|
||||
},
|
||||
{
|
||||
"type": "merged_sources",
|
||||
"count": 500000,
|
||||
"sources": [
|
||||
{
|
||||
"type": "lichess",
|
||||
"count": 500000,
|
||||
"params": {
|
||||
"min_depth": 20,
|
||||
"max_positions": 500000
|
||||
}
|
||||
}
|
||||
],
|
||||
"actual_count": 500000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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()
|
||||
@@ -20,6 +22,7 @@ scala {
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
@@ -33,7 +36,7 @@ val quarkusPlatformVersion: String by project
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
@@ -74,6 +77,7 @@ dependencies {
|
||||
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 {
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"reflection": [
|
||||
{ "type": "scala.Tuple1[]" },
|
||||
{ "type": "scala.Tuple2[]" },
|
||||
{ "type": "scala.Tuple3[]" },
|
||||
{ "type": "scala.Tuple4[]" },
|
||||
{ "type": "scala.Tuple5[]" },
|
||||
{ "type": "scala.Tuple6[]" },
|
||||
{ "type": "scala.Tuple7[]" },
|
||||
{ "type": "scala.Tuple8[]" },
|
||||
{ "type": "scala.Tuple9[]" },
|
||||
{ "type": "scala.Tuple10[]" },
|
||||
{ "type": "scala.Tuple11[]" },
|
||||
{ "type": "scala.Tuple12[]" },
|
||||
{ "type": "scala.Tuple13[]" },
|
||||
{ "type": "scala.Tuple14[]" },
|
||||
{ "type": "scala.Tuple15[]" },
|
||||
{ "type": "scala.Tuple16[]" },
|
||||
{ "type": "scala.Tuple17[]" },
|
||||
{ "type": "scala.Tuple18[]" },
|
||||
{ "type": "scala.Tuple19[]" },
|
||||
{ "type": "scala.Tuple20[]" },
|
||||
{ "type": "scala.Tuple21[]" },
|
||||
{ "type": "scala.Tuple22[]" },
|
||||
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
|
||||
]
|
||||
}
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
greeting:
|
||||
message: "hello"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.nowchess.chess.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
|
||||
@@ -8,4 +9,9 @@ import jakarta.inject.Singleton
|
||||
@Singleton
|
||||
class JacksonConfig extends ObjectMapperCustomizer:
|
||||
def customize(mapper: ObjectMapper): Unit =
|
||||
mapper.registerModule(DefaultScalaModule)
|
||||
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
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.chess.config
|
||||
|
||||
import de.nowchess.api.dto.*
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[ApiErrorDto],
|
||||
classOf[CreateGameRequestDto],
|
||||
classOf[ErrorEventDto],
|
||||
classOf[GameFullDto],
|
||||
classOf[GameFullEventDto],
|
||||
classOf[GameStateDto],
|
||||
classOf[GameStateEventDto],
|
||||
classOf[ImportFenRequestDto],
|
||||
classOf[ImportPgnRequestDto],
|
||||
classOf[LegalMoveDto],
|
||||
classOf[LegalMovesResponseDto],
|
||||
classOf[OkResponseDto],
|
||||
classOf[PlayerInfoDto],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
@@ -38,9 +38,9 @@ class GameEngine(
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
|
||||
|
||||
/** Check if undo is available. */
|
||||
@@ -194,8 +194,7 @@ class GameEngine(
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
||||
}
|
||||
|
||||
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package de.nowchess.chess.registry
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import scala.util.Random
|
||||
|
||||
@ApplicationScoped
|
||||
class GameRegistryImpl extends GameRegistry:
|
||||
private val games = ConcurrentHashMap[String, GameEntry]()
|
||||
private val rng = new SecureRandom()
|
||||
|
||||
def store(entry: GameEntry): Unit =
|
||||
games.put(entry.gameId, entry)
|
||||
@@ -19,4 +20,4 @@ class GameRegistryImpl extends GameRegistry:
|
||||
|
||||
def generateId(): String =
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString
|
||||
Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
|
||||
|
||||
@@ -125,6 +125,11 @@ class GameResource:
|
||||
private def ok(body: AnyRef): Response = Response.ok(body).build()
|
||||
private def created(body: AnyRef): Response = Response.status(Response.Status.CREATED).entity(body).build()
|
||||
|
||||
// scalafix:off DisableSyntax.throw
|
||||
private def assertGameNotOver(entry: GameEntry): Unit =
|
||||
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
|
||||
// scalafix:on DisableSyntax.throw
|
||||
|
||||
// ── endpoints ────────────────────────────────────────────────────────────
|
||||
// scalafix:off DisableSyntax.throw
|
||||
|
||||
@@ -137,6 +142,7 @@ 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
|
||||
@@ -171,7 +177,7 @@ class GameResource:
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def resignGame(@PathParam("gameId") gameId: String): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
|
||||
assertGameNotOver(entry)
|
||||
entry.engine.resign()
|
||||
registry.update(entry.copy(resigned = true))
|
||||
ok(OkResponseDto())
|
||||
@@ -181,7 +187,7 @@ class GameResource:
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
|
||||
assertGameNotOver(entry)
|
||||
val (from, to, promoOpt) = Parser
|
||||
.parseMove(uci)
|
||||
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
|
||||
@@ -236,7 +242,7 @@ class GameResource:
|
||||
@PathParam("action") action: String,
|
||||
): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
|
||||
assertGameNotOver(entry)
|
||||
action match
|
||||
case "offer" =>
|
||||
entry.engine.offerDraw(entry.engine.context.turn)
|
||||
|
||||
@@ -234,6 +234,77 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
|
||||
case other =>
|
||||
fail(s"Expected InvalidMoveEvent, but got $other")
|
||||
|
||||
test("pendingDrawOfferBy returns None initially"):
|
||||
val engine = new GameEngine()
|
||||
engine.pendingDrawOfferBy shouldBe None
|
||||
|
||||
test("pendingDrawOfferBy returns White after White offers"):
|
||||
val engine = new GameEngine()
|
||||
engine.offerDraw(Color.White)
|
||||
engine.pendingDrawOfferBy shouldBe Some(Color.White)
|
||||
|
||||
test("pendingDrawOfferBy returns None after draw is accepted"):
|
||||
val engine = new GameEngine()
|
||||
engine.offerDraw(Color.White)
|
||||
engine.acceptDraw(Color.Black)
|
||||
engine.pendingDrawOfferBy shouldBe None
|
||||
|
||||
test("applyDraw sets draw result when game not over"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new DrawOfferMockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.applyDraw(DrawReason.Agreement)
|
||||
observer.events should have length 1
|
||||
observer.events.head match
|
||||
case event: DrawEvent =>
|
||||
event.reason shouldBe DrawReason.Agreement
|
||||
event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement))
|
||||
case other =>
|
||||
fail(s"Expected DrawEvent, but got $other")
|
||||
|
||||
test("applyDraw does nothing when game already over"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new DrawOfferMockObserver()
|
||||
engine.subscribe(observer)
|
||||
// End the game with checkmate
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
engine.processUserInput("d8h4")
|
||||
observer.events.clear()
|
||||
engine.applyDraw(DrawReason.Agreement)
|
||||
observer.events should have length 0
|
||||
|
||||
test("claimDraw with fifty-move rule when at half-move 100"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new DrawOfferMockObserver()
|
||||
engine.subscribe(observer)
|
||||
// Play moves to reach fifty-move rule claim
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g1f3")
|
||||
engine.processUserInput("g8f6")
|
||||
// Need to advance halfMoveClock to 100
|
||||
// This is hard to do naturally; skip for now if not critical
|
||||
|
||||
test("claimDraw when game already over"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new DrawOfferMockObserver()
|
||||
engine.subscribe(observer)
|
||||
// End the game with checkmate
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
engine.processUserInput("d8h4")
|
||||
observer.events.clear()
|
||||
engine.claimDraw()
|
||||
observer.events should have length 1
|
||||
observer.events.head match
|
||||
case event: InvalidMoveEvent =>
|
||||
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
|
||||
case other =>
|
||||
fail(s"Expected InvalidMoveEvent, but got $other")
|
||||
|
||||
private class DrawOfferMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
|
||||
@@ -63,6 +63,32 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
|
||||
case other =>
|
||||
fail(s"Expected InvalidMoveEvent, but got $other")
|
||||
|
||||
test("resign() without color resigns side to move"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new ResignMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.resign()
|
||||
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
|
||||
|
||||
test("resign() without color does nothing when game already over"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new ResignMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// End the game with checkmate
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
observer.events.clear()
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Try to resign without color parameter
|
||||
val resultBefore = engine.context.result
|
||||
engine.resign()
|
||||
resultBefore shouldBe engine.context.result
|
||||
|
||||
private class ResignMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.chess.registry
|
||||
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import jakarta.inject.Inject
|
||||
import org.junit.jupiter.api.{DisplayName, Test}
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
// scalafix:off
|
||||
@QuarkusTest
|
||||
@DisplayName("GameRegistryImpl")
|
||||
class GameRegistryImplTest:
|
||||
|
||||
@Inject
|
||||
var registry: GameRegistry = uninitialized
|
||||
|
||||
@Test
|
||||
@DisplayName("store saves entry")
|
||||
def testStore(): Unit =
|
||||
val entry = GameEntry("g1", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
|
||||
registry.store(entry)
|
||||
assertTrue(registry.get("g1").isDefined)
|
||||
|
||||
@Test
|
||||
@DisplayName("get returns stored entry")
|
||||
def testGet(): Unit =
|
||||
val entry = GameEntry("g2", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
|
||||
registry.store(entry)
|
||||
val retrieved = registry.get("g2")
|
||||
assertTrue(retrieved.isDefined)
|
||||
assertEquals("g2", retrieved.get.gameId)
|
||||
|
||||
@Test
|
||||
@DisplayName("get returns None for unknown id")
|
||||
def testGetUnknown(): Unit =
|
||||
assertTrue(registry.get("unknown").isEmpty)
|
||||
|
||||
@Test
|
||||
@DisplayName("update modifies existing entry")
|
||||
def testUpdate(): Unit =
|
||||
val entry = GameEntry("g3", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2"))
|
||||
registry.store(entry)
|
||||
val updated = entry.copy(resigned = true)
|
||||
registry.update(updated)
|
||||
val retrieved = registry.get("g3")
|
||||
assertTrue(retrieved.isDefined)
|
||||
assertTrue(retrieved.get.resigned)
|
||||
|
||||
@Test
|
||||
@DisplayName("generateId produces unique ids")
|
||||
def testGenerateId(): Unit =
|
||||
val id1 = registry.generateId()
|
||||
val id2 = registry.generateId()
|
||||
assertNotEquals(id1, id2)
|
||||
assertFalse(id1.isEmpty)
|
||||
assertFalse(id2.isEmpty)
|
||||
// scalafix:on
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package de.nowchess.chess.resource
|
||||
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.chess.exception.BadRequestException
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import jakarta.inject.Inject
|
||||
import org.junit.jupiter.api.{DisplayName, Test}
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
// scalafix:off
|
||||
@QuarkusTest
|
||||
@DisplayName("GameResource Integration")
|
||||
class GameResourceIntegrationTest:
|
||||
|
||||
@Inject
|
||||
var resource: GameResource = uninitialized
|
||||
|
||||
@Test
|
||||
@DisplayName("createGame returns 201")
|
||||
def testCreateGame(): Unit =
|
||||
val req = CreateGameRequestDto(None, None)
|
||||
val resp = resource.createGame(req)
|
||||
assertEquals(201, resp.getStatus)
|
||||
val dto = resp.getEntity.asInstanceOf[GameFullDto]
|
||||
assertNotNull(dto.gameId)
|
||||
|
||||
@Test
|
||||
@DisplayName("getGame returns 200")
|
||||
def testGetGame(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val getResp = resource.getGame(gameId)
|
||||
assertEquals(200, getResp.getStatus)
|
||||
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
|
||||
assertEquals(gameId, dto.gameId)
|
||||
|
||||
@Test
|
||||
@DisplayName("makeMove advances game")
|
||||
def testMakeMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val moveResp = resource.makeMove(gameId, "e2e4")
|
||||
assertEquals(200, moveResp.getStatus)
|
||||
val state = moveResp.getEntity.asInstanceOf[GameStateDto]
|
||||
assertEquals("black", state.turn)
|
||||
|
||||
@Test
|
||||
@DisplayName("makeMove with invalid UCI throws")
|
||||
def testMakeMoveInvalid(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
|
||||
|
||||
@Test
|
||||
@DisplayName("getLegalMoves returns moves")
|
||||
def testGetLegalMoves(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val movesResp = resource.getLegalMoves(gameId, "")
|
||||
assertEquals(200, movesResp.getStatus)
|
||||
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
|
||||
assertFalse(dto.moves.isEmpty)
|
||||
|
||||
@Test
|
||||
@DisplayName("resignGame updates state")
|
||||
def testResignGame(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resignResp = resource.resignGame(gameId)
|
||||
assertEquals(200, resignResp.getStatus)
|
||||
val getResp = resource.getGame(gameId)
|
||||
val state = getResp.getEntity.asInstanceOf[GameFullDto].state
|
||||
assertEquals("resign", state.status)
|
||||
|
||||
@Test
|
||||
@DisplayName("undoMove reverts")
|
||||
def testUndoMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
val undoResp = resource.undoMove(gameId)
|
||||
assertEquals(200, undoResp.getStatus)
|
||||
val state = undoResp.getEntity.asInstanceOf[GameStateDto]
|
||||
assertEquals("white", state.turn)
|
||||
|
||||
@Test
|
||||
@DisplayName("redoMove restores")
|
||||
def testRedoMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
resource.undoMove(gameId)
|
||||
val redoResp = resource.redoMove(gameId)
|
||||
assertEquals(200, redoResp.getStatus)
|
||||
val state = redoResp.getEntity.asInstanceOf[GameStateDto]
|
||||
assertEquals("black", state.turn)
|
||||
|
||||
@Test
|
||||
@DisplayName("drawAction offer")
|
||||
def testDrawActionOffer(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resp = resource.drawAction(gameId, "offer")
|
||||
assertEquals(200, resp.getStatus)
|
||||
|
||||
@Test
|
||||
@DisplayName("drawAction accept")
|
||||
def testDrawActionAccept(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.drawAction(gameId, "offer")
|
||||
val resp = resource.drawAction(gameId, "accept")
|
||||
assertEquals(200, resp.getStatus)
|
||||
|
||||
@Test
|
||||
@DisplayName("importFen creates game")
|
||||
def testImportFen(): Unit =
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
val req = ImportFenRequestDto(fen, None, None)
|
||||
val resp = resource.importFen(req)
|
||||
assertEquals(201, resp.getStatus)
|
||||
val dto = resp.getEntity.asInstanceOf[GameFullDto]
|
||||
assertEquals(fen, dto.state.fen)
|
||||
|
||||
@Test
|
||||
@DisplayName("importPgn creates game")
|
||||
def testImportPgn(): Unit =
|
||||
val req = ImportPgnRequestDto("1. e4 c5")
|
||||
val resp = resource.importPgn(req)
|
||||
assertEquals(201, resp.getStatus)
|
||||
val dto = resp.getEntity.asInstanceOf[GameFullDto]
|
||||
assertTrue(dto.state.moves.length > 0)
|
||||
|
||||
@Test
|
||||
@DisplayName("exportFen returns FEN")
|
||||
def testExportFen(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resp = resource.exportFen(gameId)
|
||||
assertEquals(200, resp.getStatus)
|
||||
assertTrue(resp.getEntity.asInstanceOf[String].contains("rnbqkbnr"))
|
||||
|
||||
@Test
|
||||
@DisplayName("exportPgn returns PGN")
|
||||
def testExportPgn(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
val resp = resource.exportPgn(gameId)
|
||||
assertEquals(200, resp.getStatus)
|
||||
assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
|
||||
// scalafix:on
|
||||
@@ -8,6 +8,8 @@ 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()
|
||||
@@ -19,7 +21,7 @@ scala {
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedFiles.set(listOf(".*FenParserFastParse.*"))
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
@@ -28,7 +30,7 @@ tasks.withType<ScalaCompile> {
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("export all promotion pieces separately for full branch coverage") {
|
||||
val promotions = List(
|
||||
(PromotionPiece.Queen, "queen"),
|
||||
(PromotionPiece.Rook, "rook"),
|
||||
(PromotionPiece.Bishop, "bishop"),
|
||||
(PromotionPiece.Knight, "knight"),
|
||||
)
|
||||
|
||||
for (piece, expectedName) <- promotions do
|
||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||
// Empty boards can cause issues in PgnExporter, using initial
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include(s""""$expectedName"""")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export normal non-capture move") {
|
||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
}
|
||||
|
||||
test("export normal capture move manually") {
|
||||
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export all move type categories") {
|
||||
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
}
|
||||
|
||||
test("export castle queenside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleQueenside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle kingside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleKingside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export en passant move manually") {
|
||||
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"enPassant\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
+63
-9
@@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
class JsonExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: exports initial position") {
|
||||
val context = GameContext.initial
|
||||
@@ -87,14 +87,6 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
json should include("\"enPassantSquare\": null")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports different move destinations") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports empty board") {
|
||||
val emptyBoard = Board(Map.empty)
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
@@ -113,3 +105,65 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
json should include("\"blackKingSide\": false")
|
||||
json should include("\"blackQueenSide\": false")
|
||||
}
|
||||
|
||||
test("export all promotion pieces for full branch coverage") {
|
||||
val promotions = List(
|
||||
(PromotionPiece.Queen, "queen"),
|
||||
(PromotionPiece.Rook, "rook"),
|
||||
(PromotionPiece.Bishop, "bishop"),
|
||||
(PromotionPiece.Knight, "knight"),
|
||||
)
|
||||
|
||||
for (piece, expectedName) <- promotions do
|
||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include(s""""$expectedName"""")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export normal non-capture move") {
|
||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
}
|
||||
|
||||
test("export normal capture move") {
|
||||
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle queenside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleQueenside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle kingside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"castleKingside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export en passant move") {
|
||||
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"enPassant\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("JsonMetadata with all fields") {
|
||||
val meta = JsonMetadata(Some("Event"), Some(Map("a" -> "b")), Some("2026-04-08"), Some("1-0"))
|
||||
assert(meta.event.contains("Event"))
|
||||
assert(meta.players.exists(_.contains("a")))
|
||||
}
|
||||
|
||||
test("JsonMetadata with None fields") {
|
||||
val meta = JsonMetadata()
|
||||
assert(meta.event.isEmpty)
|
||||
assert(meta.players.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonPiece with square and piece") {
|
||||
val piece = JsonPiece(Some("e4"), Some("White"), Some("Pawn"))
|
||||
assert(piece.square.contains("e4"))
|
||||
assert(piece.color.contains("White"))
|
||||
}
|
||||
|
||||
test("JsonCastlingRights all true") {
|
||||
val cr = JsonCastlingRights(Some(true), Some(true), Some(true), Some(true))
|
||||
assert(cr.whiteKingSide.contains(true))
|
||||
assert(cr.blackQueenSide.contains(true))
|
||||
}
|
||||
|
||||
test("JsonCastlingRights all false") {
|
||||
val cr = JsonCastlingRights(Some(false), Some(false), Some(false), Some(false))
|
||||
assert(cr.whiteKingSide.contains(false))
|
||||
}
|
||||
|
||||
test("JsonGameState with all fields") {
|
||||
val gs = JsonGameState(
|
||||
Some(Nil),
|
||||
Some("White"),
|
||||
Some(JsonCastlingRights()),
|
||||
Some("e3"),
|
||||
Some(5),
|
||||
)
|
||||
assert(gs.board.contains(Nil))
|
||||
assert(gs.halfMoveClock.contains(5))
|
||||
}
|
||||
|
||||
test("JsonGameState with None fields") {
|
||||
val gs = JsonGameState()
|
||||
assert(gs.board.isEmpty)
|
||||
assert(gs.halfMoveClock.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonCapturedPieces with pieces") {
|
||||
val cp = JsonCapturedPieces(Some(List("Pawn")), Some(List("Knight")))
|
||||
assert(cp.byWhite.exists(_.contains("Pawn")))
|
||||
assert(cp.byBlack.exists(_.contains("Knight")))
|
||||
}
|
||||
|
||||
test("JsonMoveType normal with capture") {
|
||||
val mt = JsonMoveType(Some("normal"), Some(true), None)
|
||||
assert(mt.`type`.contains("normal"))
|
||||
assert(mt.isCapture.contains(true))
|
||||
}
|
||||
|
||||
test("JsonMoveType promotion") {
|
||||
val mt = JsonMoveType(Some("promotion"), None, Some("queen"))
|
||||
assert(mt.`type`.contains("promotion"))
|
||||
assert(mt.promotionPiece.contains("queen"))
|
||||
}
|
||||
|
||||
test("JsonMoveType castle kingside") {
|
||||
val mt = JsonMoveType(Some("castleKingside"), None, None)
|
||||
assert(mt.`type`.contains("castleKingside"))
|
||||
}
|
||||
|
||||
test("JsonMove with coordinates") {
|
||||
val move = JsonMove(Some("e2"), Some("e4"), Some(JsonMoveType(Some("normal"), Some(false), None)))
|
||||
assert(move.from.contains("e2"))
|
||||
assert(move.to.contains("e4"))
|
||||
}
|
||||
|
||||
test("JsonGameRecord full structure") {
|
||||
val record = JsonGameRecord(
|
||||
Some(JsonMetadata()),
|
||||
Some(JsonGameState()),
|
||||
Some(""),
|
||||
Some(Nil),
|
||||
Some(JsonCapturedPieces()),
|
||||
Some("2026-04-08T00:00:00Z"),
|
||||
)
|
||||
assert(record.metadata.nonEmpty)
|
||||
assert(record.timestamp.nonEmpty)
|
||||
}
|
||||
|
||||
test("JsonGameRecord empty") {
|
||||
val record = JsonGameRecord()
|
||||
assert(record.metadata.isEmpty)
|
||||
assert(record.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonPiece with no fields") {
|
||||
val piece = JsonPiece()
|
||||
assert(piece.square.isEmpty)
|
||||
assert(piece.color.isEmpty)
|
||||
assert(piece.piece.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonMoveType with no fields") {
|
||||
val mt = JsonMoveType()
|
||||
assert(mt.`type`.isEmpty)
|
||||
assert(mt.isCapture.isEmpty)
|
||||
assert(mt.promotionPiece.isEmpty)
|
||||
}
|
||||
|
||||
test("JsonMove with empty fields") {
|
||||
val move = JsonMove()
|
||||
assert(move.from.isEmpty)
|
||||
assert(move.to.isEmpty)
|
||||
assert(move.`type`.isEmpty)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, PieceType}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse invalid turn color returns error") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "Invalid", "board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Invalid turn color"))
|
||||
}
|
||||
|
||||
test("parse invalid piece type filters it out") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "InvalidPiece"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid color in board filters piece") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "InvalidColor", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing turn uses default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.turn == Color.White)
|
||||
}
|
||||
|
||||
test("parse with missing board uses empty") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White"},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing moves uses empty list") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid square in board filters it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "invalid99", "color": "White", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse all valid piece types") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Pawn"},
|
||||
{"square": "b1", "color": "White", "piece": "Knight"},
|
||||
{"square": "c1", "color": "White", "piece": "Bishop"},
|
||||
{"square": "d1", "color": "White", "piece": "Rook"},
|
||||
{"square": "e1", "color": "White", "piece": "Queen"},
|
||||
{"square": "f1", "color": "White", "piece": "King"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.size == 6)
|
||||
assert(
|
||||
ctx.board
|
||||
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||
.get
|
||||
.pieceType == PieceType.Pawn,
|
||||
)
|
||||
}
|
||||
|
||||
test("parse with all castling rights false") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [],
|
||||
"castlingRights": {
|
||||
"whiteKingSide": false,
|
||||
"whiteQueenSide": false,
|
||||
"blackKingSide": false,
|
||||
"blackQueenSide": false
|
||||
}
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.castlingRights.whiteKingSide == false)
|
||||
assert(ctx.castlingRights.blackQueenSide == false)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse completely invalid JSON returns error") {
|
||||
val invalidJson = "{ this is not valid json at all }"
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse empty string returns error") {
|
||||
val result = JsonParser.importGameContext("")
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse number value returns error") {
|
||||
val result = JsonParser.importGameContext("123")
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse malformed JSON object returns error") {
|
||||
val malformed = """{"metadata": {"unclosed": """
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse invalid JSON array returns error") {
|
||||
val invalidArray = "[1, 2, 3"
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse JSON with missing required fields") {
|
||||
val json = """{"metadata": {}}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Should still succeed because all fields have defaults
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse valid JSON with invalid turn falls back to default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse all move type variations") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "result": "*"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [
|
||||
{"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}},
|
||||
{"from": "e1", "to": "g1", "type": {"type": "castleKingside"}},
|
||||
{"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}},
|
||||
{"from": "e5", "to": "d4", "type": {"type": "enPassant"}},
|
||||
{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}},
|
||||
{"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}},
|
||||
{"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}},
|
||||
{"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}}
|
||||
]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.length == 8)
|
||||
assert(ctx.moves(0).moveType == MoveType.Normal(false))
|
||||
assert(ctx.moves(1).moveType == MoveType.CastleKingside)
|
||||
assert(ctx.moves(2).moveType == MoveType.CastleQueenside)
|
||||
assert(ctx.moves(3).moveType == MoveType.EnPassant)
|
||||
}
|
||||
|
||||
test("parse invalid move type defaults to None") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Invalid move type is skipped, so moves list should be empty
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse promotion with default piece") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Invalid promotion piece should use default
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse move with missing from/to skips it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
// Invalid square should be filtered out
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with invalid JSON returns error") {
|
||||
val json = """{"invalid json"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse normal move with isCapture true") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
val move = ctx.moves.head
|
||||
assert(move.moveType == MoveType.Normal(true))
|
||||
}
|
||||
|
||||
test("parse board with invalid pieces filters them") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Rook"},
|
||||
{"square": "invalid", "color": "White", "piece": "King"},
|
||||
{"square": "a2", "color": "Invalid", "piece": "Pawn"}
|
||||
]
|
||||
}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
// Only valid piece should be in board
|
||||
assert(ctx.board.pieces.size == 1)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: parses valid JSON") {
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: restores board state") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result == Right(context))
|
||||
}
|
||||
|
||||
test("importGameContext: restores turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: restores moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles empty board") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||
"gameState": {
|
||||
"board": [],
|
||||
"turn": "White",
|
||||
"castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true},
|
||||
"enPassantSquare": null,
|
||||
"halfMoveClock": 0
|
||||
},
|
||||
"moves": [],
|
||||
"moveHistory": "",
|
||||
"capturedPieces": {"byWhite": [], "byBlack": []},
|
||||
"timestamp": "2026-04-06T00:00:00Z"
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||
}
|
||||
|
||||
test("importGameContext: returns error on invalid JSON") {
|
||||
val result = JsonParser.importGameContext("not valid json {{{")
|
||||
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("importGameContext: handles missing fields with defaults") {
|
||||
val json =
|
||||
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: handles castling rights") {
|
||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||
}
|
||||
|
||||
test("importGameContext: round-trip consistency") {
|
||||
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||
val context = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
.withTurn(Color.White)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val restored = JsonParser.importGameContext(json)
|
||||
|
||||
assert(restored.map(_.moves.length) == Right(2))
|
||||
assert(restored.map(_.turn) == Right(Color.White))
|
||||
}
|
||||
|
||||
test("importGameContext: handles half-move clock") {
|
||||
val context = GameContext.initial.withHalfMoveClock(5)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.halfMoveClock) == Right(5))
|
||||
}
|
||||
|
||||
test("importGameContext: parses en passant square") {
|
||||
// Create a context with en passant square
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||
}
|
||||
|
||||
test("importGameContext: handles black turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||
// Use simple move without explicit moveType to let system handle it
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||
}
|
||||
|
||||
test("importGameContext: handles mixed castling rights") {
|
||||
val mixed = CastlingRights(true, false, false, true)
|
||||
val context = GameContext.initial.withCastlingRights(mixed)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(mixed))
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonParserTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// Basic import tests
|
||||
test("importGameContext: parses valid JSON") {
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: restores board state") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result == Right(context))
|
||||
}
|
||||
|
||||
test("importGameContext: restores turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: restores moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles castling rights") {
|
||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||
}
|
||||
|
||||
test("importGameContext: round-trip consistency with multiple moves") {
|
||||
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
|
||||
val context = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
.withTurn(Color.White)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val restored = JsonParser.importGameContext(json)
|
||||
assert(restored.map(_.moves.length) == Right(2))
|
||||
assert(restored.map(_.turn) == Right(Color.White))
|
||||
}
|
||||
|
||||
test("importGameContext: handles half-move clock") {
|
||||
val context = GameContext.initial.withHalfMoveClock(5)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.halfMoveClock) == Right(5))
|
||||
}
|
||||
|
||||
test("importGameContext: parses en passant square") {
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||
}
|
||||
|
||||
test("importGameContext: handles all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||
}
|
||||
|
||||
test("importGameContext: handles mixed castling rights") {
|
||||
val mixed = CastlingRights(true, false, false, true)
|
||||
val context = GameContext.initial.withCastlingRights(mixed)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.map(_.castlingRights) == Right(mixed))
|
||||
}
|
||||
|
||||
// Error handling tests
|
||||
test("parse completely invalid JSON returns error") {
|
||||
val invalidJson = "{ this is not valid json at all }"
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse empty string returns error") {
|
||||
val result = JsonParser.importGameContext("")
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse number value returns error") {
|
||||
val result = JsonParser.importGameContext("123")
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse malformed JSON object returns error") {
|
||||
val malformed = """{"metadata": {"unclosed": """
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse invalid JSON array returns error") {
|
||||
val invalidArray = "[1, 2, 3"
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse JSON with missing required fields") {
|
||||
val json = """{"metadata": {}}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
// Edge cases with defaults
|
||||
test("parse invalid turn color returns error") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "Invalid", "board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Invalid turn color"))
|
||||
}
|
||||
|
||||
test("parse invalid piece type filters it out") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "InvalidPiece"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid color in board filters piece") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "InvalidColor", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing turn uses default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"board": []},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.turn == Color.White)
|
||||
}
|
||||
|
||||
test("parse with missing board uses empty") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White"},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse with missing moves uses empty list") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse invalid square in board filters it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "invalid99", "color": "White", "piece": "Pawn"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.isEmpty)
|
||||
}
|
||||
|
||||
test("parse all valid piece types") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Pawn"},
|
||||
{"square": "b1", "color": "White", "piece": "Knight"},
|
||||
{"square": "c1", "color": "White", "piece": "Bishop"},
|
||||
{"square": "d1", "color": "White", "piece": "Rook"},
|
||||
{"square": "e1", "color": "White", "piece": "Queen"},
|
||||
{"square": "f1", "color": "White", "piece": "King"}
|
||||
]
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.size == 6)
|
||||
assert(
|
||||
ctx.board
|
||||
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||
.get
|
||||
.pieceType == PieceType.Pawn,
|
||||
)
|
||||
}
|
||||
|
||||
test("parse with all castling rights false") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [],
|
||||
"castlingRights": {
|
||||
"whiteKingSide": false,
|
||||
"whiteQueenSide": false,
|
||||
"blackKingSide": false,
|
||||
"blackQueenSide": false
|
||||
}
|
||||
},
|
||||
"moves": []
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.castlingRights.whiteKingSide == false)
|
||||
assert(ctx.castlingRights.blackQueenSide == false)
|
||||
}
|
||||
|
||||
// Move type parsing tests
|
||||
test("parse all move type variations") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "result": "*"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [
|
||||
{"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}},
|
||||
{"from": "e1", "to": "g1", "type": {"type": "castleKingside"}},
|
||||
{"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}},
|
||||
{"from": "e5", "to": "d4", "type": {"type": "enPassant"}},
|
||||
{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}},
|
||||
{"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}},
|
||||
{"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}},
|
||||
{"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}}
|
||||
]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.length == 8)
|
||||
assert(ctx.moves(0).moveType == MoveType.Normal(false))
|
||||
assert(ctx.moves(1).moveType == MoveType.CastleKingside)
|
||||
assert(ctx.moves(2).moveType == MoveType.CastleQueenside)
|
||||
assert(ctx.moves(3).moveType == MoveType.EnPassant)
|
||||
}
|
||||
|
||||
test("parse invalid move type defaults to None") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse promotion with invalid piece uses default") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse move with invalid from/to skips it") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.moves.isEmpty)
|
||||
}
|
||||
|
||||
test("parse normal move with isCapture true") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
val move = ctx.moves.head
|
||||
assert(move.moveType == MoveType.Normal(true))
|
||||
}
|
||||
|
||||
test("parse board with invalid pieces filters them") {
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
"board": [
|
||||
{"square": "a1", "color": "White", "piece": "Rook"},
|
||||
{"square": "invalid", "color": "White", "piece": "King"},
|
||||
{"square": "a2", "color": "Invalid", "piece": "Pawn"}
|
||||
]
|
||||
}
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.size == 1)
|
||||
}
|
||||
|
||||
test("parse with empty board") {
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||
"gameState": {
|
||||
"board": [],
|
||||
"turn": "White",
|
||||
"castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true},
|
||||
"enPassantSquare": null,
|
||||
"halfMoveClock": 0
|
||||
},
|
||||
"moves": [],
|
||||
"moveHistory": "",
|
||||
"capturedPieces": {"byWhite": [], "byBlack": []},
|
||||
"timestamp": "2026-04-06T00:00:00Z"
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||
}
|
||||
|
||||
test("importGameContext: returns error on invalid JSON") {
|
||||
val result = JsonParser.importGameContext("not valid json {{{")
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("importGameContext: handles missing fields with defaults") {
|
||||
val json =
|
||||
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
id("io.quarkus")
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
@@ -26,13 +25,9 @@ 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 {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
@@ -45,56 +40,20 @@ dependencies {
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
|
||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
implementation("io.quarkus:quarkus-hibernate-orm")
|
||||
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||
implementation("io.quarkus:quarkus-rest-client")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
implementation("io.quarkus:quarkus-config-yaml")
|
||||
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
|
||||
implementation("io.quarkus:quarkus-smallrye-jwt")
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
|
||||
testImplementation(project(":modules:io"))
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
events("skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
@@ -102,7 +61,3 @@ tasks.test {
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
####
|
||||
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
|
||||
#
|
||||
# Before building the container image run:
|
||||
#
|
||||
# ./gradlew build
|
||||
#
|
||||
# Then, build the image with:
|
||||
#
|
||||
# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backcore-jvm .
|
||||
#
|
||||
# Then run the container using:
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
|
||||
#
|
||||
# If you want to include the debug port into your docker image
|
||||
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
|
||||
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
|
||||
# when running the container
|
||||
#
|
||||
# Then run the container using :
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm
|
||||
#
|
||||
# This image uses the `run-java.sh` script to run the application.
|
||||
# This scripts computes the command line to execute your Java application, and
|
||||
# includes memory/GC tuning.
|
||||
# You can configure the behavior using the following environment properties:
|
||||
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
|
||||
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
|
||||
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
|
||||
# in JAVA_OPTS (example: "-Dsome.property=foo")
|
||||
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
|
||||
# used to calculate a default maximal heap memory based on a containers restriction.
|
||||
# If used in a container without any memory constraints for the container then this
|
||||
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
|
||||
# of the container available memory as set here. The default is `50` which means 50%
|
||||
# of the available memory is used as an upper boundary. You can skip this mechanism by
|
||||
# setting this value to `0` in which case no `-Xmx` option is added.
|
||||
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
|
||||
# is used to calculate a default initial heap memory based on the maximum heap memory.
|
||||
# If used in a container without any memory constraints for the container then this
|
||||
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
|
||||
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
|
||||
# is used as the initial heap size. You can skip this mechanism by setting this value
|
||||
# to `0` in which case no `-Xms` option is added (example: "25")
|
||||
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
|
||||
# This is used to calculate the maximum value of the initial heap memory. If used in
|
||||
# a container without any memory constraints for the container then this option has
|
||||
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
|
||||
# here. The default is 4096MB which means the calculated value of `-Xms` never will
|
||||
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
|
||||
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
|
||||
# when things are happening. This option, if set to true, will set
|
||||
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
|
||||
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
|
||||
# true").
|
||||
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
|
||||
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
|
||||
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
|
||||
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
|
||||
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
|
||||
# (example: "20")
|
||||
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
|
||||
# (example: "40")
|
||||
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
|
||||
# (example: "4")
|
||||
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
|
||||
# previous GC times. (example: "90")
|
||||
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
|
||||
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
|
||||
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
|
||||
# contain the necessary JRE command-line options to specify the required GC, which
|
||||
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
|
||||
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
|
||||
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
|
||||
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
|
||||
# accessed directly. (example: "foo.example.com,bar.example.com")
|
||||
#
|
||||
# You can find more information about the UBI base runtime images and their configuration here:
|
||||
# https://rh-openjdk.github.io/redhat-openjdk-containers/
|
||||
###
|
||||
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
|
||||
|
||||
ENV LANGUAGE='en_US:en'
|
||||
|
||||
|
||||
# We make four distinct layers so if there are application changes the library layers can be re-used
|
||||
COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/
|
||||
COPY --chown=185 build/quarkus-app/*.jar /deployments/
|
||||
COPY --chown=185 build/quarkus-app/app/ /deployments/app/
|
||||
COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/
|
||||
|
||||
EXPOSE 8080
|
||||
USER 185
|
||||
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
|
||||
|
||||
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
####
|
||||
# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode
|
||||
#
|
||||
# Before building the container image run:
|
||||
#
|
||||
# ./gradlew build -Dquarkus.package.jar.type=legacy-jar
|
||||
#
|
||||
# Then, build the image with:
|
||||
#
|
||||
# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/backcore-legacy-jar .
|
||||
#
|
||||
# Then run the container using:
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
|
||||
#
|
||||
# If you want to include the debug port into your docker image
|
||||
# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005.
|
||||
# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005
|
||||
# when running the container
|
||||
#
|
||||
# Then run the container using :
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar
|
||||
#
|
||||
# This image uses the `run-java.sh` script to run the application.
|
||||
# This scripts computes the command line to execute your Java application, and
|
||||
# includes memory/GC tuning.
|
||||
# You can configure the behavior using the following environment properties:
|
||||
# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override
|
||||
# the default JVM options, use `JAVA_OPTS_APPEND` to append options
|
||||
# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options
|
||||
# in JAVA_OPTS (example: "-Dsome.property=foo")
|
||||
# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is
|
||||
# used to calculate a default maximal heap memory based on a containers restriction.
|
||||
# If used in a container without any memory constraints for the container then this
|
||||
# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio
|
||||
# of the container available memory as set here. The default is `50` which means 50%
|
||||
# of the available memory is used as an upper boundary. You can skip this mechanism by
|
||||
# setting this value to `0` in which case no `-Xmx` option is added.
|
||||
# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This
|
||||
# is used to calculate a default initial heap memory based on the maximum heap memory.
|
||||
# If used in a container without any memory constraints for the container then this
|
||||
# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio
|
||||
# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx`
|
||||
# is used as the initial heap size. You can skip this mechanism by setting this value
|
||||
# to `0` in which case no `-Xms` option is added (example: "25")
|
||||
# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS.
|
||||
# This is used to calculate the maximum value of the initial heap memory. If used in
|
||||
# a container without any memory constraints for the container then this option has
|
||||
# no effect. If there is a memory constraint then `-Xms` is limited to the value set
|
||||
# here. The default is 4096MB which means the calculated value of `-Xms` never will
|
||||
# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096")
|
||||
# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output
|
||||
# when things are happening. This option, if set to true, will set
|
||||
# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true").
|
||||
# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example:
|
||||
# true").
|
||||
# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787").
|
||||
# - CONTAINER_CORE_LIMIT: A calculated core limit as described in
|
||||
# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2")
|
||||
# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024").
|
||||
# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion.
|
||||
# (example: "20")
|
||||
# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking.
|
||||
# (example: "40")
|
||||
# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection.
|
||||
# (example: "4")
|
||||
# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus
|
||||
# previous GC times. (example: "90")
|
||||
# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20")
|
||||
# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100")
|
||||
# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should
|
||||
# contain the necessary JRE command-line options to specify the required GC, which
|
||||
# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC).
|
||||
# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080")
|
||||
# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080")
|
||||
# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be
|
||||
# accessed directly. (example: "foo.example.com,bar.example.com")
|
||||
#
|
||||
# You can find more information about the UBI base runtime images and their configuration here:
|
||||
# https://rh-openjdk.github.io/redhat-openjdk-containers/
|
||||
###
|
||||
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24
|
||||
|
||||
ENV LANGUAGE='en_US:en'
|
||||
|
||||
|
||||
COPY build/lib/* /deployments/lib/
|
||||
COPY build/*-runner.jar /deployments/quarkus-run.jar
|
||||
|
||||
EXPOSE 8080
|
||||
USER 185
|
||||
ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
|
||||
|
||||
ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ]
|
||||
@@ -1,29 +0,0 @@
|
||||
####
|
||||
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
|
||||
#
|
||||
# Before building the container image run:
|
||||
#
|
||||
# ./gradlew build -Dquarkus.native.enabled=true
|
||||
#
|
||||
# Then, build the image with:
|
||||
#
|
||||
# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore .
|
||||
#
|
||||
# Then run the container using:
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore
|
||||
#
|
||||
# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9.
|
||||
# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`.
|
||||
###
|
||||
FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7
|
||||
WORKDIR /work/
|
||||
RUN chown 1001 /work \
|
||||
&& chmod "g+rwX" /work \
|
||||
&& chown 1001:root /work
|
||||
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
|
||||
|
||||
EXPOSE 8080
|
||||
USER 1001
|
||||
|
||||
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
|
||||
@@ -1,32 +0,0 @@
|
||||
####
|
||||
# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode.
|
||||
# It uses a micro base image, tuned for Quarkus native executables.
|
||||
# It reduces the size of the resulting container image.
|
||||
# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image.
|
||||
#
|
||||
# Before building the container image run:
|
||||
#
|
||||
# ./gradlew build -Dquarkus.native.enabled=true
|
||||
#
|
||||
# Then, build the image with:
|
||||
#
|
||||
# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/backcore .
|
||||
#
|
||||
# Then run the container using:
|
||||
#
|
||||
# docker run -i --rm -p 8080:8080 quarkus/backcore
|
||||
#
|
||||
# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9.
|
||||
# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`.
|
||||
###
|
||||
FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0
|
||||
WORKDIR /work/
|
||||
RUN chown 1001 /work \
|
||||
&& chmod "g+rwX" /work \
|
||||
&& chown 1001:root /work
|
||||
COPY --chown=1001:root --chmod=0755 build/*-runner /work/application
|
||||
|
||||
EXPOSE 8080
|
||||
USER 1001
|
||||
|
||||
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
|
||||
@@ -1,5 +0,0 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8081
|
||||
application:
|
||||
name: rule-service
|
||||
@@ -1,11 +0,0 @@
|
||||
package de.nowchess.rules.config
|
||||
|
||||
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(DefaultScalaModule)
|
||||
@@ -1,128 +0,0 @@
|
||||
package de.nowchess.rules.dto
|
||||
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
|
||||
object DtoMapper:
|
||||
|
||||
def toColor(s: String): Either[String, Color] = s match
|
||||
case "White" => Right(Color.White)
|
||||
case "Black" => Right(Color.Black)
|
||||
case other => Left(s"Unknown color: $other")
|
||||
|
||||
def toPieceType(s: String): Either[String, PieceType] = s match
|
||||
case "Pawn" => Right(PieceType.Pawn)
|
||||
case "Knight" => Right(PieceType.Knight)
|
||||
case "Bishop" => Right(PieceType.Bishop)
|
||||
case "Rook" => Right(PieceType.Rook)
|
||||
case "Queen" => Right(PieceType.Queen)
|
||||
case "King" => Right(PieceType.King)
|
||||
case other => Left(s"Unknown piece type: $other")
|
||||
|
||||
def toSquare(s: String): Either[String, Square] =
|
||||
Square.fromAlgebraic(s).toRight(s"Invalid square: $s")
|
||||
|
||||
def toMoveType(dto: MoveDto): Either[String, MoveType] = dto.moveType match
|
||||
case "normal" => Right(MoveType.Normal(isCapture = false))
|
||||
case "capture" => Right(MoveType.Normal(isCapture = true))
|
||||
case "castleKingside" => Right(MoveType.CastleKingside)
|
||||
case "castleQueenside" => Right(MoveType.CastleQueenside)
|
||||
case "enPassant" => Right(MoveType.EnPassant)
|
||||
case "promotion" =>
|
||||
dto.promotionPiece.toRight("Missing promotion piece").flatMap(toPromotionPiece).map(MoveType.Promotion(_))
|
||||
case other => Left(s"Unknown move type: $other")
|
||||
|
||||
def toMove(dto: MoveDto): Either[String, Move] =
|
||||
for
|
||||
from <- toSquare(dto.from)
|
||||
to <- toSquare(dto.to)
|
||||
moveType <- toMoveType(dto)
|
||||
yield Move(from, to, moveType)
|
||||
|
||||
def toBoard(pieces: List[PieceOnSquareDto]): Either[String, Board] =
|
||||
sequenceList(pieces.map(toPieceOnSquare)).map(entries => Board(entries.toMap))
|
||||
|
||||
def toGameContext(dto: GameContextDto): Either[String, GameContext] =
|
||||
for
|
||||
board <- toBoard(dto.board)
|
||||
turn <- toColor(dto.turn)
|
||||
epSquare <- sequenceOpt(dto.enPassantSquare.map(toSquare))
|
||||
moves <- sequenceList(dto.moves.map(toMove))
|
||||
initialBoard <- toBoard(dto.initialBoard)
|
||||
yield GameContext(
|
||||
board = board,
|
||||
turn = turn,
|
||||
castlingRights = toCastlingRights(dto.castlingRights),
|
||||
enPassantSquare = epSquare,
|
||||
halfMoveClock = dto.halfMoveClock,
|
||||
moves = moves,
|
||||
initialBoard = initialBoard,
|
||||
)
|
||||
|
||||
def fromMove(move: Move): MoveDto =
|
||||
val (moveType, promotionPiece) = fromMoveType(move.moveType)
|
||||
MoveDto(move.from.toString, move.to.toString, moveType, promotionPiece)
|
||||
|
||||
def fromBoard(board: Board): List[PieceOnSquareDto] =
|
||||
board.pieces.toList.map { case (sq, p) =>
|
||||
PieceOnSquareDto(sq.toString, p.color.label, p.pieceType.label)
|
||||
}
|
||||
|
||||
def fromGameContext(ctx: GameContext): GameContextDto =
|
||||
GameContextDto(
|
||||
board = fromBoard(ctx.board),
|
||||
turn = ctx.turn.label,
|
||||
castlingRights = fromCastlingRights(ctx.castlingRights),
|
||||
enPassantSquare = ctx.enPassantSquare.map(_.toString),
|
||||
halfMoveClock = ctx.halfMoveClock,
|
||||
moves = ctx.moves.map(fromMove),
|
||||
initialBoard = fromBoard(ctx.initialBoard),
|
||||
)
|
||||
|
||||
private def toPromotionPiece(s: String): Either[String, PromotionPiece] = s match
|
||||
case "Queen" => Right(PromotionPiece.Queen)
|
||||
case "Rook" => Right(PromotionPiece.Rook)
|
||||
case "Bishop" => Right(PromotionPiece.Bishop)
|
||||
case "Knight" => Right(PromotionPiece.Knight)
|
||||
case other => Left(s"Unknown promotion piece: $other")
|
||||
|
||||
private def fromMoveType(mt: MoveType): (String, Option[String]) = mt match
|
||||
case MoveType.Normal(false) => ("normal", None)
|
||||
case MoveType.Normal(true) => ("capture", None)
|
||||
case MoveType.CastleKingside => ("castleKingside", None)
|
||||
case MoveType.CastleQueenside => ("castleQueenside", None)
|
||||
case MoveType.EnPassant => ("enPassant", None)
|
||||
case MoveType.Promotion(pp) => ("promotion", Some(fromPromotionPiece(pp)))
|
||||
|
||||
private def fromPromotionPiece(pp: PromotionPiece): String = pp match
|
||||
case PromotionPiece.Queen => "Queen"
|
||||
case PromotionPiece.Rook => "Rook"
|
||||
case PromotionPiece.Bishop => "Bishop"
|
||||
case PromotionPiece.Knight => "Knight"
|
||||
|
||||
private def toCastlingRights(dto: CastlingRightsDto): CastlingRights =
|
||||
CastlingRights(dto.whiteKingSide, dto.whiteQueenSide, dto.blackKingSide, dto.blackQueenSide)
|
||||
|
||||
private def fromCastlingRights(cr: CastlingRights): CastlingRightsDto =
|
||||
CastlingRightsDto(cr.whiteKingSide, cr.whiteQueenSide, cr.blackKingSide, cr.blackQueenSide)
|
||||
|
||||
private def toPieceOnSquare(dto: PieceOnSquareDto): Either[String, (Square, Piece)] =
|
||||
for
|
||||
sq <- toSquare(dto.square)
|
||||
color <- toColor(dto.color)
|
||||
pieceType <- toPieceType(dto.pieceType)
|
||||
yield sq -> Piece(color, pieceType)
|
||||
|
||||
private def sequenceList[R](list: List[Either[String, R]]): Either[String, List[R]] =
|
||||
list.foldLeft[Either[String, List[R]]](Right(List.empty)) {
|
||||
case (Right(acc), Right(v)) => Right(acc :+ v)
|
||||
case (Left(e), _) => Left(e)
|
||||
case (_, Left(e)) => Left(e)
|
||||
}
|
||||
|
||||
private def sequenceOpt[R](opt: Option[Either[String, R]]): Either[String, Option[R]] =
|
||||
opt match
|
||||
case None => Right(None)
|
||||
case Some(Right(v)) => Right(Some(v))
|
||||
case Some(Left(e)) => Left(e)
|
||||
@@ -1,37 +0,0 @@
|
||||
package de.nowchess.rules.dto
|
||||
|
||||
case class PieceOnSquareDto(square: String, color: String, pieceType: String)
|
||||
|
||||
case class CastlingRightsDto(
|
||||
whiteKingSide: Boolean,
|
||||
whiteQueenSide: Boolean,
|
||||
blackKingSide: Boolean,
|
||||
blackQueenSide: Boolean,
|
||||
)
|
||||
|
||||
case class MoveDto(
|
||||
from: String,
|
||||
to: String,
|
||||
moveType: String,
|
||||
promotionPiece: Option[String],
|
||||
)
|
||||
|
||||
case class GameContextDto(
|
||||
board: List[PieceOnSquareDto],
|
||||
turn: String,
|
||||
castlingRights: CastlingRightsDto,
|
||||
enPassantSquare: Option[String],
|
||||
halfMoveClock: Int,
|
||||
moves: List[MoveDto],
|
||||
initialBoard: List[PieceOnSquareDto],
|
||||
)
|
||||
|
||||
case class ContextRequest(context: GameContextDto)
|
||||
|
||||
case class ContextSquareRequest(context: GameContextDto, square: String)
|
||||
|
||||
case class ContextMoveRequest(context: GameContextDto, move: MoveDto)
|
||||
|
||||
case class MovesResponse(moves: List[MoveDto])
|
||||
|
||||
case class BooleanResponse(result: Boolean)
|
||||
@@ -1,94 +0,0 @@
|
||||
package de.nowchess.rules.resource
|
||||
|
||||
import de.nowchess.rules.dto.*
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
|
||||
@Path("/api/rules")
|
||||
@ApplicationScoped
|
||||
class RuleSetResource:
|
||||
private val rules = DefaultRules
|
||||
|
||||
// scalafix:off DisableSyntax.throw
|
||||
private def parse[T](e: Either[String, T]): T = e match
|
||||
case Right(v) => v
|
||||
case Left(msg) => throw BadRequestException(msg)
|
||||
// scalafix:on DisableSyntax.throw
|
||||
|
||||
@POST
|
||||
@Path("/candidate-moves")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def candidateMoves(req: ContextSquareRequest): MovesResponse =
|
||||
val ctx = parse(DtoMapper.toGameContext(req.context))
|
||||
val sq = parse(DtoMapper.toSquare(req.square))
|
||||
MovesResponse(rules.candidateMoves(ctx)(sq).map(DtoMapper.fromMove))
|
||||
|
||||
@POST
|
||||
@Path("/legal-moves")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def legalMoves(req: ContextSquareRequest): MovesResponse =
|
||||
val ctx = parse(DtoMapper.toGameContext(req.context))
|
||||
val sq = parse(DtoMapper.toSquare(req.square))
|
||||
MovesResponse(rules.legalMoves(ctx)(sq).map(DtoMapper.fromMove))
|
||||
|
||||
@POST
|
||||
@Path("/all-legal-moves")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def allLegalMoves(req: ContextRequest): MovesResponse =
|
||||
MovesResponse(rules.allLegalMoves(parse(DtoMapper.toGameContext(req.context))).map(DtoMapper.fromMove))
|
||||
|
||||
@POST
|
||||
@Path("/is-check")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isCheck(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isCheck(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/is-checkmate")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isCheckmate(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isCheckmate(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/is-stalemate")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isStalemate(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isStalemate(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/is-insufficient-material")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isInsufficientMaterial(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isInsufficientMaterial(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/is-fifty-move-rule")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isFiftyMoveRule(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isFiftyMoveRule(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/is-threefold-repetition")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def isThreefoldRepetition(req: ContextRequest): BooleanResponse =
|
||||
BooleanResponse(rules.isThreefoldRepetition(parse(DtoMapper.toGameContext(req.context))))
|
||||
|
||||
@POST
|
||||
@Path("/apply-move")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def applyMove(req: ContextMoveRequest): GameContextDto =
|
||||
val ctx = parse(DtoMapper.toGameContext(req.context))
|
||||
val move = parse(DtoMapper.toMove(req.move))
|
||||
DtoMapper.fromGameContext(rules.applyMove(ctx)(move))
|
||||
@@ -1,168 +0,0 @@
|
||||
package de.nowchess.rules.dto
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class DtoMapperTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// ── toColor ────────────────────────────────────────────────────────
|
||||
|
||||
test("toColor converts White"):
|
||||
DtoMapper.toColor("White") shouldBe Right(Color.White)
|
||||
|
||||
test("toColor converts Black"):
|
||||
DtoMapper.toColor("Black") shouldBe Right(Color.Black)
|
||||
|
||||
test("toColor rejects unknown"):
|
||||
DtoMapper.toColor("Red") shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── toPieceType ────────────────────────────────────────────────────
|
||||
|
||||
test("toPieceType converts all piece types"):
|
||||
DtoMapper.toPieceType("Pawn") shouldBe Right(PieceType.Pawn)
|
||||
DtoMapper.toPieceType("Knight") shouldBe Right(PieceType.Knight)
|
||||
DtoMapper.toPieceType("Bishop") shouldBe Right(PieceType.Bishop)
|
||||
DtoMapper.toPieceType("Rook") shouldBe Right(PieceType.Rook)
|
||||
DtoMapper.toPieceType("Queen") shouldBe Right(PieceType.Queen)
|
||||
DtoMapper.toPieceType("King") shouldBe Right(PieceType.King)
|
||||
|
||||
test("toPieceType rejects unknown"):
|
||||
DtoMapper.toPieceType("Dragon") shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── toSquare ───────────────────────────────────────────────────────
|
||||
|
||||
test("toSquare converts valid algebraic"):
|
||||
DtoMapper.toSquare("e4") shouldBe Right(Square(File.E, Rank.R4))
|
||||
|
||||
test("toSquare rejects invalid"):
|
||||
DtoMapper.toSquare("z9") shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── toMoveType ────────────────────────────────────────────────────
|
||||
|
||||
test("toMoveType converts normal non-capture"):
|
||||
DtoMapper.toMoveType(MoveDto("e2", "e4", "normal", None)) shouldBe Right(MoveType.Normal(false))
|
||||
|
||||
test("toMoveType converts capture"):
|
||||
DtoMapper.toMoveType(MoveDto("e2", "d3", "capture", None)) shouldBe Right(MoveType.Normal(true))
|
||||
|
||||
test("toMoveType converts castleKingside"):
|
||||
DtoMapper.toMoveType(MoveDto("e1", "g1", "castleKingside", None)) shouldBe Right(MoveType.CastleKingside)
|
||||
|
||||
test("toMoveType converts castleQueenside"):
|
||||
DtoMapper.toMoveType(MoveDto("e1", "c1", "castleQueenside", None)) shouldBe Right(MoveType.CastleQueenside)
|
||||
|
||||
test("toMoveType converts enPassant"):
|
||||
DtoMapper.toMoveType(MoveDto("e5", "d6", "enPassant", None)) shouldBe Right(MoveType.EnPassant)
|
||||
|
||||
test("toMoveType converts all promotion pieces"):
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Queen"))) shouldBe Right(
|
||||
MoveType.Promotion(PromotionPiece.Queen),
|
||||
)
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Rook"))) shouldBe Right(
|
||||
MoveType.Promotion(PromotionPiece.Rook),
|
||||
)
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Bishop"))) shouldBe Right(
|
||||
MoveType.Promotion(PromotionPiece.Bishop),
|
||||
)
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Knight"))) shouldBe Right(
|
||||
MoveType.Promotion(PromotionPiece.Knight),
|
||||
)
|
||||
|
||||
test("toMoveType rejects promotion without piece"):
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", None)) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toMoveType rejects promotion with unknown piece"):
|
||||
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Pawn"))) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toMoveType rejects unknown type"):
|
||||
DtoMapper.toMoveType(MoveDto("e2", "e4", "unknown", None)) shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── toBoard ───────────────────────────────────────────────────────
|
||||
|
||||
test("toBoard builds valid board"):
|
||||
val pieces = List(
|
||||
PieceOnSquareDto("e1", "White", "King"),
|
||||
PieceOnSquareDto("e8", "Black", "King"),
|
||||
)
|
||||
val result = DtoMapper.toBoard(pieces)
|
||||
result.isRight shouldBe true
|
||||
result.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Right(Some(Piece(Color.White, PieceType.King)))
|
||||
|
||||
test("toBoard rejects invalid square"):
|
||||
DtoMapper.toBoard(List(PieceOnSquareDto("z9", "White", "King"))) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toBoard rejects invalid color"):
|
||||
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "Red", "King"))) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toBoard rejects invalid piece type"):
|
||||
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "White", "Dragon"))) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toBoard with multiple invalid pieces covers all sequenceList branches"):
|
||||
val pieces = List(
|
||||
PieceOnSquareDto("z9", "White", "King"),
|
||||
PieceOnSquareDto("z8", "White", "Queen"),
|
||||
)
|
||||
DtoMapper.toBoard(pieces) shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── toGameContext ─────────────────────────────────────────────────
|
||||
|
||||
test("toGameContext round-trips initial position"):
|
||||
val ctx = GameContext.initial
|
||||
val dto = DtoMapper.fromGameContext(ctx)
|
||||
DtoMapper.toGameContext(dto) shouldBe Right(ctx)
|
||||
|
||||
test("toGameContext rejects invalid turn"):
|
||||
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")
|
||||
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toGameContext rejects invalid en passant square"):
|
||||
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(enPassantSquare = Some("z9"))
|
||||
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toGameContext rejects invalid initial board"):
|
||||
val badBoard = List(PieceOnSquareDto("z9", "White", "King"))
|
||||
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(initialBoard = badBoard)
|
||||
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
|
||||
|
||||
test("toGameContext rejects invalid move"):
|
||||
val badMove = MoveDto("z9", "e4", "normal", None)
|
||||
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(moves = List(badMove))
|
||||
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
|
||||
|
||||
// ── fromGameContext ───────────────────────────────────────────────
|
||||
|
||||
test("fromGameContext includes en passant square when present"):
|
||||
val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3)))
|
||||
DtoMapper.fromGameContext(ctx).enPassantSquare shouldBe Some("e3")
|
||||
|
||||
// ── fromMove ──────────────────────────────────────────────────────
|
||||
|
||||
test("fromMove converts all move types"):
|
||||
DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))).moveType shouldBe "normal"
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true)))
|
||||
.moveType shouldBe "capture"
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
|
||||
.moveType shouldBe "castleKingside"
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
|
||||
.moveType shouldBe "castleQueenside"
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant))
|
||||
.moveType shouldBe "enPassant"
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
|
||||
.promotionPiece shouldBe Some("Queen")
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
|
||||
.promotionPiece shouldBe Some("Rook")
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||
.promotionPiece shouldBe Some("Bishop")
|
||||
DtoMapper
|
||||
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
|
||||
.promotionPiece shouldBe Some("Knight")
|
||||
@@ -1,294 +0,0 @@
|
||||
package de.nowchess.rules.resource
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper}
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@QuarkusTest
|
||||
class RuleSetResourceTest:
|
||||
|
||||
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
|
||||
private val rules = DefaultRules
|
||||
|
||||
private def request() = RestAssured.`given`()
|
||||
|
||||
private def toJson(value: AnyRef): String = mapper.writeValueAsString(value)
|
||||
|
||||
private def contextBody(ctx: GameContext): String =
|
||||
toJson(ContextRequest(DtoMapper.fromGameContext(ctx)))
|
||||
|
||||
private def contextSquareBody(ctx: GameContext, square: String): String =
|
||||
toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square))
|
||||
|
||||
private def contextMoveBody(ctx: GameContext, move: Move): String =
|
||||
toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move)))
|
||||
|
||||
// ── all-legal-moves ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def allLegalMoves_initialPositionHas20Moves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/all-legal-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(20))
|
||||
|
||||
// ── legal-moves ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def legalMoves_e2PawnHas2Moves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextSquareBody(GameContext.initial, "e2"))
|
||||
.when()
|
||||
.post("/api/rules/legal-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(2))
|
||||
|
||||
// ── candidate-moves ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def candidateMoves_e2PawnHas2Candidates(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextSquareBody(GameContext.initial, "e2"))
|
||||
.when()
|
||||
.post("/api/rules/candidate-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(2))
|
||||
|
||||
// ── is-check ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isCheck_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-check")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isCheck_trueWhenKingAttacked(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildCheckContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-check")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-checkmate ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isCheckmate_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-checkmate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isCheckmate_trueForFoolsMate(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildFoolsMate()))
|
||||
.when()
|
||||
.post("/api/rules/is-checkmate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-stalemate ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isStalemate_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-stalemate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isStalemate_trueForStalematePosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildStalemateContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-stalemate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-insufficient-material ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isInsufficientMaterial_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-insufficient-material")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isInsufficientMaterial_trueForKingsOnly(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildKingsOnlyContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-insufficient-material")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-fifty-move-rule ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isFiftyMoveRule_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-fifty-move-rule")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isFiftyMoveRule_trueWhenClockAt100(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial.copy(halfMoveClock = 100)))
|
||||
.when()
|
||||
.post("/api/rules/is-fifty-move-rule")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-threefold-repetition ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isThreefoldRepetition_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-threefold-repetition")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildThreefoldContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-threefold-repetition")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── apply-move ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def applyMove_updatesContext(): Unit =
|
||||
val move = rules
|
||||
.legalMoves(GameContext.initial)(Square(File.E, Rank.R2))
|
||||
.find(_.to == Square(File.E, Rank.R4))
|
||||
.get
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextMoveBody(GameContext.initial, move))
|
||||
.when()
|
||||
.post("/api/rules/apply-move")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("turn", is("Black"))
|
||||
|
||||
// ── error handling ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def invalidSquare_returns400(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(toJson(ContextSquareRequest(DtoMapper.fromGameContext(GameContext.initial), "z9")))
|
||||
.when()
|
||||
.post("/api/rules/legal-moves")
|
||||
.`then`()
|
||||
.statusCode(400)
|
||||
|
||||
// ── position builders ─────────────────────────────────────────────
|
||||
|
||||
private def buildCheckContext(): GameContext =
|
||||
val board = Board(Map(
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
Square(File.E, Rank.R3) -> Piece(Color.Black, PieceType.Rook),
|
||||
))
|
||||
GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board)
|
||||
|
||||
private def buildFoolsMate(): GameContext =
|
||||
val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4"))
|
||||
moves.foldLeft(GameContext.initial) { (ctx, fromTo) =>
|
||||
val from = Square.fromAlgebraic(fromTo._1).get
|
||||
val to = Square.fromAlgebraic(fromTo._2).get
|
||||
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
|
||||
}
|
||||
|
||||
private def buildStalemateContext(): GameContext =
|
||||
val board = Board(Map(
|
||||
Square(File.H, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
Square(File.F, Rank.R7) -> Piece(Color.White, PieceType.Queen),
|
||||
Square(File.G, Rank.R6) -> Piece(Color.White, PieceType.King),
|
||||
))
|
||||
GameContext(board, Color.Black, CastlingRights.None, None, 0, List.empty, initialBoard = board)
|
||||
|
||||
private def buildKingsOnlyContext(): GameContext =
|
||||
val board = Board(Map(
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
))
|
||||
GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board)
|
||||
|
||||
private def buildThreefoldContext(): GameContext =
|
||||
val g1 = Square(File.G, Rank.R1)
|
||||
val f3 = Square(File.F, Rank.R3)
|
||||
val g8 = Square(File.G, Rank.R8)
|
||||
val f6 = Square(File.F, Rank.R6)
|
||||
def mv(ctx: GameContext, from: Square, to: Square): GameContext =
|
||||
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
|
||||
val ctx1 = mv(GameContext.initial, g1, f3)
|
||||
val ctx2 = mv(ctx1, g8, f6)
|
||||
val ctx3 = mv(ctx2, f3, g1)
|
||||
val ctx4 = mv(ctx3, f6, g8)
|
||||
val ctx5 = mv(ctx4, g1, f3)
|
||||
val ctx6 = mv(ctx5, g8, f6)
|
||||
val ctx7 = mv(ctx6, f3, g1)
|
||||
mv(ctx7, f6, g8)
|
||||
Reference in New Issue
Block a user