From aea9f1a1ca4a76f9bcae891196a459d4a9cf6f67 Mon Sep 17 00:00:00 2001 From: Janis Date: Mon, 20 Apr 2026 19:08:45 +0200 Subject: [PATCH] feat: Enhance GameResource with game state validation and add comprehensive tests --- CLAUDE.md | 55 ++-- CLAUDE.original.md | 99 +++++++ coverage | 2 +- modules/core/build.gradle.kts | 1 + .../chess/registry/GameRegistryImpl.scala | 2 +- .../chess/resource/GameResource.scala | 9 +- .../chess/resource/GameResourceTest.scala | 245 ++++++++++++++++++ 7 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 CLAUDE.original.md create mode 100644 modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceTest.scala diff --git a/CLAUDE.md b/CLAUDE.md index b5ab597..d75a177 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/CLAUDE.original.md b/CLAUDE.original.md new file mode 100644 index 0000000..b5ab597 --- /dev/null +++ b/CLAUDE.original.md @@ -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 diff --git a/coverage b/coverage index 441ed31..e67ee1b 100755 --- a/coverage +++ b/coverage @@ -1,7 +1,7 @@ #! /usr/bin/env bash set -euo pipefail -./gradlew test +# ./gradlew test if [ "$#" -eq 0 ]; then PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index be07b0d..53b86cd 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -74,6 +74,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 { diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala index 8b338f5..83dd98e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala @@ -19,4 +19,4 @@ class GameRegistryImpl extends GameRegistry: def generateId(): String = val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString + Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index fa44cd6..6dbe708 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -125,6 +125,9 @@ 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() + private def assertGameNotOver(entry: GameEntry): Unit = + if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over") + // ── endpoints ──────────────────────────────────────────────────────────── // scalafix:off DisableSyntax.throw @@ -171,7 +174,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 +184,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 +239,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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceTest.scala new file mode 100644 index 0000000..aaed1f1 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceTest.scala @@ -0,0 +1,245 @@ +package de.nowchess.chess.resource + +import com.fasterxml.jackson.databind.ObjectMapper +import de.nowchess.api.dto.* +import de.nowchess.chess.exception.BadRequestException +import de.nowchess.chess.registry.GameRegistry +import io.quarkus.test.junit.QuarkusTest +import jakarta.inject.Inject +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.compiletime.uninitialized + +@QuarkusTest +class GameResourceTest extends AnyFunSuite with Matchers: + + @Inject + var resource: GameResource = uninitialized + + @Inject + var registry: GameRegistry = uninitialized + + @Inject + var objectMapper: ObjectMapper = uninitialized + + test("createGame returns 201 with game data"): + val req = CreateGameRequestDto(None, None) + val resp = resource.createGame(req) + resp.getStatus shouldBe 201 + val dto = resp.getEntity.asInstanceOf[GameFullDto] + dto.gameId should not be null + dto.white.displayName shouldBe "Player 1" + dto.black.displayName shouldBe "Player 2" + dto.state.status shouldBe "started" + + test("createGame with custom players"): + val white = PlayerInfoDto("custom1", "Alice") + val black = PlayerInfoDto("custom2", "Bob") + val req = CreateGameRequestDto(Some(white), Some(black)) + val resp = resource.createGame(req) + val dto = resp.getEntity.asInstanceOf[GameFullDto] + dto.white.displayName shouldBe "Alice" + dto.black.displayName shouldBe "Bob" + + test("getGame returns 200 with game state"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val getResp = resource.getGame(gameId) + getResp.getStatus shouldBe 200 + val dto = getResp.getEntity.asInstanceOf[GameFullDto] + dto.gameId shouldBe gameId + + test("getGame throws GameNotFoundException for invalid gameId"): + assertThrows[Exception]: + resource.getGame("invalid-id") + + test("makeMove on new game advances position"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val moveResp = resource.makeMove(gameId, "e2e4") + moveResp.getStatus shouldBe 200 + val state = moveResp.getEntity.asInstanceOf[GameStateDto] + state.turn shouldBe "black" + + test("makeMove with invalid UCI throws BadRequestException"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + assertThrows[BadRequestException]: + resource.makeMove(gameId, "invalid") + + test("makeMove throws GAME_OVER after game ends"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.makeMove(gameId, "f2f3") + resource.makeMove(gameId, "e7e6") + resource.makeMove(gameId, "g2g4") + resource.makeMove(gameId, "d8h4") + val ex = the[BadRequestException] thrownBy: + resource.makeMove(gameId, "a2a3") + ex.code shouldBe "GAME_OVER" + + test("getLegalMoves returns all moves when no square specified"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val movesResp = resource.getLegalMoves(gameId, "") + movesResp.getStatus shouldBe 200 + val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto] + dto.moves should not be empty + + test("getLegalMoves returns moves for specific square"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val movesResp = resource.getLegalMoves(gameId, "e2") + movesResp.getStatus shouldBe 200 + val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto] + dto.moves.map(_.from).distinct shouldBe List("e2") + + test("getLegalMoves with invalid square throws"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + assertThrows[BadRequestException]: + resource.getLegalMoves(gameId, "invalid") + + test("resignGame updates game state"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val resignResp = resource.resignGame(gameId) + resignResp.getStatus shouldBe 200 + val getResp = resource.getGame(gameId) + val dto = getResp.getEntity.asInstanceOf[GameFullDto] + dto.state.status shouldBe "resign" + + test("resignGame throws GAME_OVER when game already over"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.makeMove(gameId, "f2f3") + resource.makeMove(gameId, "e7e6") + resource.makeMove(gameId, "g2g4") + resource.makeMove(gameId, "d8h4") + val ex = the[BadRequestException] thrownBy: + resource.resignGame(gameId) + ex.code shouldBe "GAME_OVER" + + test("undoMove reverts last move"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.makeMove(gameId, "e2e4") + val undoResp = resource.undoMove(gameId) + undoResp.getStatus shouldBe 200 + val state = undoResp.getEntity.asInstanceOf[GameStateDto] + state.turn shouldBe "white" + + test("undoMove throws NO_UNDO when no moves to undo"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + assertThrows[BadRequestException]: + resource.undoMove(gameId) + + test("redoMove restores undone move"): + 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) + redoResp.getStatus shouldBe 200 + val state = redoResp.getEntity.asInstanceOf[GameStateDto] + state.turn shouldBe "black" + + test("redoMove throws NO_REDO when no moves to redo"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + assertThrows[BadRequestException]: + resource.redoMove(gameId) + + test("drawAction offer returns 200"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val resp = resource.drawAction(gameId, "offer") + resp.getStatus shouldBe 200 + + test("drawAction accept returns 200"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.drawAction(gameId, "offer") + val resp = resource.drawAction(gameId, "accept") + resp.getStatus shouldBe 200 + + test("drawAction decline returns 200"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.drawAction(gameId, "offer") + val resp = resource.drawAction(gameId, "decline") + resp.getStatus shouldBe 200 + + test("drawAction claim returns 200"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val resp = resource.drawAction(gameId, "claim") + resp.getStatus shouldBe 200 + + test("drawAction with invalid action throws"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + assertThrows[BadRequestException]: + resource.drawAction(gameId, "invalid") + + test("drawAction throws GAME_OVER when game already over"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.makeMove(gameId, "f2f3") + resource.makeMove(gameId, "e7e6") + resource.makeMove(gameId, "g2g4") + resource.makeMove(gameId, "d8h4") + val ex = the[BadRequestException] thrownBy: + resource.drawAction(gameId, "offer") + ex.code shouldBe "GAME_OVER" + + test("importFen creates game from FEN"): + 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) + resp.getStatus shouldBe 201 + val dto = resp.getEntity.asInstanceOf[GameFullDto] + dto.state.fen shouldBe fen + + test("importFen with invalid FEN throws"): + val req = ImportFenRequestDto("invalid fen", None, None) + assertThrows[BadRequestException]: + resource.importFen(req) + + test("importPgn creates game from PGN"): + val pgn = "1. e4 c5" + val req = ImportPgnRequestDto(pgn) + val resp = resource.importPgn(req) + resp.getStatus shouldBe 201 + val dto = resp.getEntity.asInstanceOf[GameFullDto] + dto.state.moves.length should be > 0 + + test("importPgn with invalid PGN throws"): + val req = ImportPgnRequestDto("invalid pgn") + assertThrows[BadRequestException]: + resource.importPgn(req) + + test("exportFen returns FEN string"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val resp = resource.exportFen(gameId) + resp.getStatus shouldBe 200 + resp.getEntity.asInstanceOf[String] should include("rnbqkbnr") + + test("exportPgn returns PGN string"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + resource.makeMove(gameId, "e2e4") + val resp = resource.exportPgn(gameId) + resp.getStatus shouldBe 200 + resp.getEntity.asInstanceOf[String] should include("1.") + + test("streamGame emits initial game state"): + val createResp = resource.createGame(CreateGameRequestDto(None, None)) + val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId + val multi = resource.streamGame(gameId) + val events = multi.collect().asList().await.indefinitely() + events should not be empty + events.get(0) should include("GameFullEventDto")