Compare commits
21 Commits
b8545748b3
...
core-0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 80518719d5 | |||
| 2d6ead7e47 | |||
| 3ff80318b4 | |||
| 9fb743d135 | |||
| 412ed986a9 | |||
| 8bbeead702 | |||
| e5e20c566e | |||
| 13bfc16cfe | |||
| 85cbf95c18 | |||
| 1361dfc895 | |||
| 707c4826a4 | |||
| 919beb3b4b | |||
| ee79dc5b98 | |||
| f28e69dc18 | |||
| 5f485fed9b | |||
| f4c18d22d7 | |||
| 4d800e88eb | |||
| 2df2fdeeb9 | |||
| 9190d1e5a0 | |||
| d675966436 | |||
| b4116e9a82 |
@@ -1,120 +0,0 @@
|
||||
# Claude Code – Working Agreement
|
||||
|
||||
## Workflow: Plan → Write Tests → Implement → Verify
|
||||
|
||||
### 1. Plan First
|
||||
Before writing any code, produce an explicit plan:
|
||||
- Restate the requirement in your own words to confirm understanding.
|
||||
- List every file you intend to create or modify.
|
||||
- Identify risks or unknowns upfront.
|
||||
- Wait for confirmation **only** when the plan reveals an ambiguity that cannot be resolved from context. Otherwise proceed immediately.
|
||||
|
||||
### 2. Write Tests
|
||||
Before implementing, write tests that should cover the new behaviour.
|
||||
Only write tests for the new behaviour.
|
||||
|
||||
### 3. Implement
|
||||
Follow the plan. Do not add scope beyond what was agreed.
|
||||
|
||||
### 4. Verify Every Requirement
|
||||
After implementation, go through each requirement one-by-one and confirm it is satisfied:
|
||||
- Run the relevant tests (unit, integration, or build check) for every changed module.
|
||||
- If a requirement **cannot** be fulfilled, do **not** silently skip it — document it immediately (see *Unresolved Requirements* below).
|
||||
|
||||
---
|
||||
|
||||
## No Code Without Verification (Testing)
|
||||
|
||||
- Every new behaviour must be covered by at least one automated test before the task is considered done.
|
||||
- Every bug fix must be accompanied by a regression test that fails before the fix and passes after.
|
||||
- Run `./gradlew :modules:<module>:test` (or the appropriate Gradle task) and confirm a green build before marking work complete.
|
||||
- If a test cannot be written for a legitimate reason, document it in `docs/unresolved.md` with an explanation.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Bug Fixing
|
||||
|
||||
- When a test or build step fails, attempt to fix the root cause immediately — do **not** ask for permission.
|
||||
- Apply the fix, re-run the verification, and continue until green.
|
||||
- If the same failure persists after **three** fix attempts, stop, log the issue in `docs/unresolved.md`, and surface a concise summary.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Requirements → `docs/unresolved.md`
|
||||
|
||||
When a requirement or bug cannot be resolved, append an entry to `docs/unresolved.md`:
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] <Short title>
|
||||
|
||||
**Requirement / Bug:**
|
||||
<What was requested or what failed>
|
||||
|
||||
**Root Cause (if known):**
|
||||
<Why it cannot be resolved right now>
|
||||
|
||||
**Attempted Fixes:**
|
||||
1. <What was tried>
|
||||
2. …
|
||||
|
||||
**Suggested Next Step:**
|
||||
<What a human engineer should investigate>
|
||||
```
|
||||
|
||||
Create the file if it does not exist. Never delete existing entries.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
. ← Repository root (multi-project Gradle setup)
|
||||
├── build.gradle.kts ← Root build file (shared plugins, dependency versions)
|
||||
├── settings.gradle.kts ← Gradle settings (declares all subprojects)
|
||||
├── modules/ ← One subdirectory per microservice
|
||||
│ └── <service>/
|
||||
│ ├── build.gradle.kts
|
||||
│ └── src/
|
||||
└── docs/ ← Architecture Decision Records, API docs, unresolved issues
|
||||
└── unresolved.md
|
||||
```
|
||||
|
||||
### Conventions
|
||||
- All microservices live under `modules/{service-name}`. Never place service code in the root.
|
||||
- Shared configuration (dependency versions, plugin setup) belongs in the **root** `build.gradle.kts` or in `buildSrc` / a version catalog.
|
||||
- `settings.gradle.kts` must include every module via `include(":modules:<service>")`.
|
||||
- Architecture decisions go in `docs/adr/` as numbered Markdown files (`ADR-001-<title>.md`).
|
||||
- API contracts live in `/docs/api/`.
|
||||
- Unit tests extend `AnyFunSuite with Matchers` — no `@Test` annotations, no `: Unit` requirement
|
||||
- Integration tests use `@QuarkusTest` with JUnit 5 — `@Test` methods must be explicitly typed `: Unit`
|
||||
- Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts
|
||||
|
||||
## Coverage Conventions
|
||||
- Branch coverage must be at least 90% - unless there is a good reason not to.
|
||||
- Line coverage must be at least 95% - unless there is a good reason not to.
|
||||
- Method coverage must be at least 90% - unless there is a good reason not to.
|
||||
- To check coverage use jacoco-reporter/scoverage_coverage_gaps.py modules/{service}/build/reports/scoverageTest/scoverage.xml
|
||||
- IMPORTANT: modules/{service}/build/reports/scoverage/scoverage.xml is not used for coverage TEST calculation. Do not use it.
|
||||
|
||||
## Agent Routing Rules
|
||||
|
||||
### Use agents in PARALLEL when:
|
||||
- Tasks touch different, independent microservices
|
||||
- No shared files or state between tasks
|
||||
- Example: "implement service-user AND service-orders simultaneously"
|
||||
|
||||
### Use agents SEQUENTIALLY when:
|
||||
- Tasks have dependencies (architect → implementer → test-writer)
|
||||
- Shared API contracts are involved
|
||||
- Example: design API first, then implement, then test
|
||||
|
||||
## Quick-Reference Checklist
|
||||
|
||||
Before considering any task done, confirm:
|
||||
|
||||
- [ ] Plan was written and requirements restated
|
||||
- [ ] All planned files were created / modified
|
||||
- [ ] Automated tests cover the new behaviour
|
||||
- [ ] `./gradlew build` (or scoped task) is green
|
||||
- [ ] Each requirement has been explicitly verified
|
||||
- [ ] Any unresolved items are logged in `docs/unresolved.md`
|
||||
@@ -2,9 +2,10 @@
|
||||
name: scala-implementer
|
||||
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
|
||||
tools: Read, Write, Edit, Bash, Glob
|
||||
model: sonnet
|
||||
model: inherit
|
||||
color: pink
|
||||
---
|
||||
|
||||
You do not have permissions to write tests, just source code.
|
||||
You are a Scala 3 expert specialising in Quarkus microservices.
|
||||
Always read the relevant /docs/api/ file before implementing.
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
name: test-writer
|
||||
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
||||
model: sonnet
|
||||
model: haiku
|
||||
color: purple
|
||||
---
|
||||
|
||||
You do not have permissions to modify the source code, just write tests.
|
||||
You write tests for Scala 3 + Quarkus services.
|
||||
|
||||
@@ -12,12 +13,11 @@ You write tests for Scala 3 + Quarkus services.
|
||||
- Unit tests: `extends AnyFunSuite with Matchers` — use `test("description") { ... }` DSL, no `@Test` annotation, no `: Unit` return type needed.
|
||||
- Integration tests: `@QuarkusTest` with JUnit 5 — `@Test` methods MUST be explicitly typed `: Unit`.
|
||||
|
||||
Target 95%+ conditional coverage.
|
||||
Target 100% conditional coverage if possible.
|
||||
|
||||
When invoked BEFORE scala-implementer (no implementation exists yet):
|
||||
Use the contract-first-test-writing skill — write failing tests from docs/api/{service}.yaml.
|
||||
|
||||
When invoked AFTER scala-implementer (implementation exists):
|
||||
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
|
||||
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
|
||||
To regenerate the report run the tests first.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Memory Index
|
||||
|
||||
## Feedback
|
||||
- [feedback_keep_structure_updated.md](feedback_keep_structure_updated.md) — Update structure memory files whenever source files are added, removed, or changed
|
||||
|
||||
## Project Structure
|
||||
- [project_structure_root.md](project_structure_root.md) — Top-level layout, modules list, VERSIONS map, navigation rules (skip `build/`, `.gradle/`, `.idea/`)
|
||||
- [project_structure_api.md](project_structure_api.md) — `modules/api`: all files and types (Board, Piece, Square, GameState, Move, ApiResponse, PlayerInfo)
|
||||
- [project_structure_core.md](project_structure_core.md) — `modules/core`: all files and types (GameContext, GameRules, MoveValidator, GameController, Parser, Renderer)
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: keep-structure-memory-updated
|
||||
description: Always update the project structure memory files when adding, removing, or changing source files
|
||||
type: feedback
|
||||
---
|
||||
|
||||
After any change that adds, removes, renames, or significantly alters a source file, update the relevant structure memory file:
|
||||
|
||||
- New/renamed/deleted file in `modules/api` → update `project_structure_api.md`
|
||||
- New/renamed/deleted file in `modules/core` → update `project_structure_core.md`
|
||||
- New module, dependency version change, or new top-level directory → update `project_structure_root.md`
|
||||
- New module added → create a new `project_structure_<module>.md` and add it to `MEMORY.md`
|
||||
|
||||
**Why:** Structure memories are the primary navigation aid. Stale entries cause wasted exploration.
|
||||
|
||||
**How to apply:** Treat the structure memory update as part of completing any implementation task — do it in the same session, not as a follow-up.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: module-api-structure
|
||||
description: File and type overview for the modules/api module (shared domain types)
|
||||
type: project
|
||||
---
|
||||
|
||||
# Module: `modules/api`
|
||||
|
||||
**Purpose:** Shared domain model — pure data types with no game logic. Depended on by `modules/core`.
|
||||
|
||||
**Gradle:** `id("scala")`, no `application` plugin. No Quarkus. Uses scoverage plugin.
|
||||
|
||||
**Package root:** `de.nowchess.api`
|
||||
|
||||
## Source files (`src/main/scala/de/nowchess/api/`)
|
||||
|
||||
### `board/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `Board.scala` | `opaque type Board = Map[Square, Piece]` — extensions: `pieceAt`, `withMove`, `pieces`; `Board.initial` sets up start position |
|
||||
| `Color.scala` | `enum Color { White, Black }` — `.opposite`, `.label` |
|
||||
| `Piece.scala` | `case class Piece(color, pieceType)` — convenience vals `WhitePawn`…`BlackKing` |
|
||||
| `PieceType.scala` | `enum PieceType { Pawn, Knight, Bishop, Rook, Queen, King }` — `.label` |
|
||||
| `Square.scala` | `enum File { A–H }`, `enum Rank { R1–R8 }`, `case class Square(file, rank)` — `.toString` algebraic, `Square.fromAlgebraic(s)` |
|
||||
|
||||
### `game/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `GameState.scala` | `case class CastlingRights(kingSide, queenSide)` + `.None`/`.Both`; `enum GameResult { WhiteWins, BlackWins, Draw }`; `enum GameStatus { NotStarted, InProgress, Finished(result) }`; `case class GameState(piecePlacement, activeColor, castlingWhite, castlingBlack, enPassantTarget, halfMoveClock, fullMoveNumber, status)` — FEN-compatible snapshot |
|
||||
|
||||
### `move/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `Move.scala` | `enum PromotionPiece { Knight, Bishop, Rook, Queen }`; `enum MoveType { Normal, CastleKingside, CastleQueenside, EnPassant, Promotion(piece) }`; `case class Move(from, to, moveType = Normal)` |
|
||||
|
||||
### `player/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `PlayerInfo.scala` | `opaque type PlayerId = String`; `case class PlayerInfo(id: PlayerId, displayName: String)` |
|
||||
|
||||
### `response/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `ApiResponse.scala` | `sealed trait ApiResponse[+A]` → `Success[A](data)` / `Failure(errors)`; `case class ApiError(code, message, field?)`; `case class Pagination(page, pageSize, totalItems)` + `.totalPages`; `case class PagedResponse[A](items, pagination)` |
|
||||
|
||||
## Test files (`src/test/scala/de/nowchess/api/`)
|
||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
||||
|
||||
## Notes
|
||||
- `GameState` is FEN-style but `Board` (in `core`) is a `Map[Square,Piece]` — the two are separate representations
|
||||
- `CastlingRights` is defined here in `api`; the castling logic lives in `core`
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: module-core-structure
|
||||
description: File and type overview for the modules/core module (TUI chess engine)
|
||||
type: project
|
||||
---
|
||||
|
||||
# Module: `modules/core`
|
||||
|
||||
**Purpose:** Standalone TUI chess application. All game logic, move validation, rendering. Depends on `modules/api`.
|
||||
|
||||
**Gradle:** `id("scala")` + `application` plugin. Main class: `de.nowchess.chess.Main`. Uses scoverage plugin.
|
||||
|
||||
**Package root:** `de.nowchess.chess`
|
||||
|
||||
## Source files (`src/main/scala/de/nowchess/chess/`)
|
||||
|
||||
### Root
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `Main.scala` | Entry point — prints welcome, starts `GameController.gameLoop(GameContext.initial, Color.White)` |
|
||||
|
||||
### `controller/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `GameController.scala` | `sealed trait MoveResult` ADT: `Quit`, `InvalidFormat`, `NoPiece`, `WrongColor`, `IllegalMove`, `Moved`, `MovedInCheck`, `Checkmate`, `Stalemate`; `object GameController` — `processMove(ctx, turn, raw): MoveResult` (pure), `gameLoop(ctx, turn)` (I/O loop), `applyRightsRevocation(...)` (castling rights bookkeeping) |
|
||||
| `Parser.scala` | `object Parser` — `parseMove(input): Option[(Square, Square)]` parses coordinate notation e.g. `"e2e4"` |
|
||||
|
||||
### `logic/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `GameContext.scala` | `enum CastleSide { Kingside, Queenside }`; `case class GameContext(board, whiteCastling, blackCastling)` — `.castlingFor(color)`, `.withUpdatedRights(color, rights)`; `GameContext.initial`; `extension (Board).withCastle(color, side)` moves king+rook atomically |
|
||||
| `GameRules.scala` | `enum PositionStatus { Normal, InCheck, Mated, Drawn }`; `object GameRules` — `isInCheck(board, color)`, `legalMoves(ctx, color): Set[(Square,Square)]`, `gameStatus(ctx, color): PositionStatus` |
|
||||
| `MoveValidator.scala` | `object MoveValidator` — `isLegal(board, from, to)`, `legalTargets(board, from): Set[Square]` (board-only, no castling), `legalTargets(ctx, from)` (context-aware, includes castling), `isCastle`, `castleSide`, `castlingTargets(ctx, color)` — full castling legality (empty squares, no check through transit) |
|
||||
|
||||
### `view/`
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `Renderer.scala` | `object Renderer` — `render(board): String` outputs ANSI-colored board with file/rank labels |
|
||||
| `PieceUnicode.scala` | `extension (Piece).unicode: String` maps each piece to its Unicode chess symbol |
|
||||
|
||||
## Test files (`src/test/scala/de/nowchess/chess/`)
|
||||
Mirror of main structure — one `*Test.scala` per source file using `AnyFunSuite with Matchers`.
|
||||
|
||||
## Key design notes
|
||||
- `MoveValidator` has two overloaded `legalTargets`: one takes `Board` (geometry only), one takes `GameContext` (adds castling)
|
||||
- `GameRules.legalMoves` filters by check — it calls `MoveValidator.legalTargets(ctx, from)` then simulates each move
|
||||
- Castling rights revocation is in `GameController.applyRightsRevocation`, triggered after every move
|
||||
- No `@QuarkusTest` — this module is a plain Scala application, not a Quarkus service
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: project-root-structure
|
||||
description: Top-level project structure, modules list, and navigation notes for NowChessSystems
|
||||
type: project
|
||||
---
|
||||
|
||||
# NowChessSystems — Root Structure
|
||||
|
||||
## Directory layout (skip `build/`, `.gradle/`, `.idea/`)
|
||||
|
||||
```
|
||||
NowChessSystems/
|
||||
├── build.gradle.kts # Root: sonarqube plugin, VERSIONS map
|
||||
├── settings.gradle.kts # include(":modules:core", ":modules:api")
|
||||
├── gradlew / gradlew.bat
|
||||
├── CLAUDE.md # Project instructions for Claude Code
|
||||
├── .claude/
|
||||
│ ├── CLAUDE.MD # Working agreement (plan/verify/unresolved)
|
||||
│ ├── settings.json
|
||||
│ └── agents/ # architect, code-reviewer, gradle-builder, scala-implementer, test-writer
|
||||
├── docs/
|
||||
│ ├── Claude-Skills.md
|
||||
│ ├── Security.md
|
||||
│ └── unresolved.md
|
||||
├── jacoco-reporter/ # Python scripts for coverage gap reporting
|
||||
└── modules/
|
||||
├── api/ # Shared domain types (no logic)
|
||||
└── core/ # TUI chess engine + game logic
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Gradle path | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `api` | `:modules:api` | Shared domain model: Board, Piece, Move, GameState, ApiResponse |
|
||||
| `core` | `:modules:core` | TUI chess app: game logic, move validation, rendering |
|
||||
|
||||
`core` depends on `api` via `implementation(project(":modules:api"))`.
|
||||
|
||||
## VERSIONS (root `build.gradle.kts`)
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `QUARKUS_SCALA3` | 1.0.0 |
|
||||
| `SCALA3` | 3.5.1 |
|
||||
| `SCALA_LIBRARY` | 2.13.18 |
|
||||
| `SCALATEST` | 3.2.19 |
|
||||
| `SCALATEST_JUNIT` | 0.1.11 |
|
||||
| `SCOVERAGE` | 2.1.1 |
|
||||
|
||||
## Navigation rules
|
||||
- **Always skip** `build/`, `.gradle/`, `.idea/` when exploring — they are generated artifacts
|
||||
- Tests use `AnyFunSuite with Matchers` (ScalaTest), not JUnit `@Test`
|
||||
- No Quarkus in current modules — Quarkus is planned for future services
|
||||
- Agent workflow: architect → scala-implementer → test-writer → gradle-builder → code-reviewer
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -12,6 +12,7 @@
|
||||
<option value="$PROJECT_DIR$/modules" />
|
||||
<option value="$PROJECT_DIR$/modules/api" />
|
||||
<option value="$PROJECT_DIR$/modules/core" />
|
||||
<option value="$PROJECT_DIR$/modules/ui" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</profile>
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test">
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
<component name="IssueNavigationConfiguration">
|
||||
<option name="links">
|
||||
<list>
|
||||
<IssueNavigationLink>
|
||||
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
|
||||
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
|
||||
</IssueNavigationLink>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
YOU CAN:
|
||||
- Edit and use the asset in any commercial or non commercial project
|
||||
- Use the asset in any commercial or non commercial project
|
||||
|
||||
YOU CAN'T:
|
||||
- Resell or distribute the asset to others
|
||||
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 907 B |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 818 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 237 B |
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 264 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 287 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 297 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 313 B |
|
After Width: | Height: | Size: 251 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 280 B |
@@ -1,58 +1,58 @@
|
||||
# CLAUDE.md
|
||||
# CLAUDE.md — NowChessSystems
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
## Stack
|
||||
Scala 3.5.x · Quarkus + quarkus-scala3 · Hibernate/Jakarta · Lanterna TUI · K8s + ArgoCD + Kargo · Frontend TBD (Vite/React/Angular/Vue)
|
||||
|
||||
## Build & Test Commands
|
||||
### Memory
|
||||
|
||||
Your memory is saved under .claude/memory/MEMORY.md.
|
||||
|
||||
## Structure
|
||||
```
|
||||
build.gradle.kts / settings.gradle.kts # root; include(":modules:<svc>") per service
|
||||
modules/<svc>/build.gradle.kts + src/
|
||||
docs/adr/ docs/api/ docs/unresolved.md
|
||||
```
|
||||
Versions in root `extra["VERSIONS"]`; modules read via `rootProject.extra["VERSIONS"] as Map<String,String>`.
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
# Build everything
|
||||
./gradlew build
|
||||
|
||||
# Build a single module
|
||||
./gradlew :modules:<service>:build
|
||||
|
||||
# Run tests for a single module
|
||||
./gradlew :modules:<service>:test
|
||||
|
||||
# Run a specific test class
|
||||
./gradlew :modules:<service>:test --tests "de.nowchess.<service>.<ClassName>"
|
||||
./gradlew :modules:<svc>:build|test
|
||||
./gradlew :modules:<svc>:test --tests "de.nowchess.<svc>.<Class>"
|
||||
```
|
||||
|
||||
The only current module is `core` (`modules/core`).
|
||||
## Workflow
|
||||
1. **Plan** — restate requirement, list files, flag risks. Proceed unless genuine ambiguity.
|
||||
2. **Tests first** — cover only new behaviour.
|
||||
3. **Implement** — no scope creep.
|
||||
4. **Verify** — check each requirement; confirm green build.
|
||||
|
||||
## Architecture
|
||||
## Scala/Quarkus Rules
|
||||
- `given`/`using` only (no `implicit`); `Option`/`Either`/`Try` (no `null`/`.get`)
|
||||
- `jakarta.*` only; reactive I/O (`Uni`/`Multi`), no blocking on event loop
|
||||
- Always exclude `org.scala-lang:scala-library` from Quarkus BOM
|
||||
- Unit tests: `extends AnyFunSuite with Matchers` — no `@Test`, no `: Unit`
|
||||
- Integration tests: `@QuarkusTest` + JUnit 5 — `@Test` methods need explicit `: Unit`
|
||||
|
||||
**NowChessSystems** is a chess platform built as a Scala 3 + Quarkus microservice system.
|
||||
## Coverage
|
||||
Line = 100% · Branch = 100% · Method = 100% · Regression tests · document exceptions
|
||||
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
|
||||
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
|
||||
|
||||
- Multi-module Gradle project; every service lives under `modules/{service-name}`.
|
||||
- Shared dependency versions live in the root `build.gradle.kts` under `extra["VERSIONS"]`.
|
||||
- Each module reads versions via `rootProject.extra["VERSIONS"] as Map<String, String>`.
|
||||
- `settings.gradle.kts` must `include(":modules:<service>")` for every module.
|
||||
## Bug Fixing
|
||||
Fix failures immediately without asking. After 3 failed attempts → log in `docs/unresolved.md` + surface summary.
|
||||
|
||||
### Stack (ADR-001)
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Language | Scala 3.5.x |
|
||||
| Backend framework | Quarkus + `quarkus-scala3` extension |
|
||||
| Persistence | Hibernate / Jakarta Persistence |
|
||||
| Frontend (TBD) | Vite; React/Angular/Vue under evaluation |
|
||||
| TUI | Lanterna |
|
||||
| Container orchestration | Kubernetes + ArgoCD + Kargo |
|
||||
## Agents (new service)
|
||||
Sequential: architect → scala-implementer → test-writer → gradle-builder → code-reviewer (review only, no self-fix)
|
||||
Parallel: only when services are fully independent (no shared contracts/state).
|
||||
|
||||
### Key Scala 3 / Quarkus Rules
|
||||
- Use `given`/`using`, not `implicit` (no Scala 2 idioms).
|
||||
- Use `Option`/`Either`/`Try`, never `null` or `.get`.
|
||||
- Jakarta annotations only (`jakarta.*`), never `javax.*`.
|
||||
- Use reactive types (`Uni`, `Multi`) for I/O; no blocking calls on the event loop.
|
||||
- **Always exclude `org.scala-lang:scala-library` from Quarkus BOM** to avoid Scala 2 conflicts.
|
||||
- **Unit tests use `extends AnyFunSuite with Matchers`** — ScalaTest DSL, no `@Test` annotations needed.
|
||||
- **Integration tests use `@QuarkusTest` with JUnit 5** — explicit `: Unit` return type still required on `@Test` methods.
|
||||
## Unresolved (`docs/unresolved.md`)
|
||||
Append only, never delete:
|
||||
```
|
||||
## [YYYY-MM-DD] <title>
|
||||
**Requirement/Bug:** **Root Cause:** **Attempted Fixes:** **Next Step:**
|
||||
```
|
||||
|
||||
### Agent Workflow (for new services)
|
||||
1. **architect** → writes OpenAPI contract to `docs/api/{service}.yaml` and ADR to `docs/adr/`.
|
||||
2. **scala-implementer** → reads contract, implements service under `modules/{service}/`.
|
||||
3. **test-writer** → writes `@QuarkusTest` integration tests and `AnyFunSuite with Matchers` unit tests.
|
||||
4. **gradle-builder** → resolves any build/dependency issues.
|
||||
5. **code-reviewer** → reviews; reports findings back without self-fixing.
|
||||
|
||||
Detailed working agreement (plan/verify/unresolved workflow) is in `.claude/CLAUDE.MD`.
|
||||
## Done Checklist
|
||||
- [ ] Plan written · files created/modified · tests green · requirements verified · unresolved logged
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
id("org.sonarqube") version "7.2.3.7755"
|
||||
id("org.scoverage") version "8.1" apply false
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
@@ -28,7 +29,10 @@ val versions = mapOf(
|
||||
"SCALA_LIBRARY" to "2.13.18",
|
||||
"SCALATEST" to "3.2.19",
|
||||
"SCALATEST_JUNIT" to "0.1.11",
|
||||
"SCOVERAGE" to "2.1.1"
|
||||
"SCOVERAGE" to "2.1.1",
|
||||
"SCALAFX" to "21.0.0-R32",
|
||||
"JAVAFX" to "21.0.1",
|
||||
"JUNIT_BOM" to "5.13.4"
|
||||
)
|
||||
extra["VERSIONS"] = versions
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#! /usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
./gradlew test
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||
else
|
||||
python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||
fi
|
||||
@@ -0,0 +1,20 @@
|
||||
## [2026-03-31] Unreachable code blocking 100% statement coverage
|
||||
|
||||
**Requirement/Bug:** Reach 100% statement coverage in core module.
|
||||
|
||||
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
|
||||
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
|
||||
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
|
||||
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
|
||||
|
||||
**Attempted Fixes:**
|
||||
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
|
||||
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
|
||||
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
|
||||
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
|
||||
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
|
||||
|
||||
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
|
||||
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
|
||||
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
|
||||
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JaCoCo Coverage Gap Reporter
|
||||
Parses a JaCoCo XML report and outputs missing line & branch (conditional)
|
||||
coverage in a structured format that Claude Code agents can act on directly.
|
||||
|
||||
Usage:
|
||||
python jacoco_coverage_gaps.py <jacoco-report.xml> [--min-coverage 80]
|
||||
python jacoco_coverage_gaps.py <jacoco-report.xml> --output json
|
||||
python jacoco_coverage_gaps.py <jacoco-report.xml> --output markdown
|
||||
python jacoco_coverage_gaps.py <jacoco-report.xml> --output agent (default)
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class LineCoverage:
|
||||
line_number: int
|
||||
hits: int # 0 = not executed
|
||||
branch_total: int = 0 # 0 = not a branch point
|
||||
branch_covered: int = 0
|
||||
|
||||
@property
|
||||
def is_uncovered(self) -> bool:
|
||||
return self.hits == 0
|
||||
|
||||
@property
|
||||
def is_partial_branch(self) -> bool:
|
||||
return self.branch_total > 0 and self.branch_covered < self.branch_total
|
||||
|
||||
|
||||
@dataclass
|
||||
class MethodCoverage:
|
||||
name: str
|
||||
descriptor: str
|
||||
first_line: Optional[int]
|
||||
missed_instructions: int
|
||||
covered_instructions: int
|
||||
missed_branches: int
|
||||
covered_branches: int
|
||||
uncovered_lines: list[int] = field(default_factory=list)
|
||||
partial_branch_lines: list[int] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_branches(self) -> int:
|
||||
return self.missed_branches + self.covered_branches
|
||||
|
||||
@property
|
||||
def is_fully_covered(self) -> bool:
|
||||
return self.missed_instructions == 0 and self.missed_branches == 0
|
||||
|
||||
@property
|
||||
def branch_coverage_pct(self) -> float:
|
||||
total = self.total_branches
|
||||
return 100.0 * self.covered_branches / total if total else 100.0
|
||||
|
||||
@property
|
||||
def line_coverage_pct(self) -> float:
|
||||
total = self.missed_instructions + self.covered_instructions
|
||||
return 100.0 * self.covered_instructions / total if total else 100.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassCoverage:
|
||||
class_name: str # e.g. com/example/Foo
|
||||
source_file: Optional[str]
|
||||
methods: list[MethodCoverage] = field(default_factory=list)
|
||||
all_lines: list[LineCoverage] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def java_class_name(self) -> str:
|
||||
return self.class_name.replace("/", ".")
|
||||
|
||||
@property
|
||||
def source_path(self) -> Optional[str]:
|
||||
"""Best-guess relative source path."""
|
||||
if self.source_file:
|
||||
package = "/".join(self.class_name.split("/")[:-1])
|
||||
return f"src/main/java/{package}/{self.source_file}" if package else f"src/main/java/{self.source_file}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def uncovered_lines(self) -> list[int]:
|
||||
return sorted({l.line_number for l in self.all_lines if l.is_uncovered})
|
||||
|
||||
@property
|
||||
def partial_branch_lines(self) -> list[int]:
|
||||
return sorted({l.line_number for l in self.all_lines if l.is_partial_branch})
|
||||
|
||||
@property
|
||||
def missed_branches(self) -> int:
|
||||
return sum(max(l.branch_total - l.branch_covered, 0) for l in self.all_lines)
|
||||
|
||||
@property
|
||||
def total_branches(self) -> int:
|
||||
return sum(l.branch_total for l in self.all_lines)
|
||||
|
||||
@property
|
||||
def covered_branches(self) -> int:
|
||||
return self.total_branches - self.missed_branches
|
||||
|
||||
@property
|
||||
def missed_lines(self) -> int:
|
||||
return len(self.uncovered_lines)
|
||||
|
||||
@property
|
||||
def total_lines(self) -> int:
|
||||
return len(self.all_lines)
|
||||
|
||||
@property
|
||||
def covered_lines(self) -> int:
|
||||
return self.total_lines - self.missed_lines
|
||||
|
||||
@property
|
||||
def has_gaps(self) -> bool:
|
||||
return bool(self.uncovered_lines or self.partial_branch_lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_jacoco_xml(xml_path: str) -> list[ClassCoverage]:
|
||||
"""Parse a JaCoCo XML report into ClassCoverage objects."""
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
results: list[ClassCoverage] = []
|
||||
|
||||
for package in root.iter("package"):
|
||||
for cls_elem in package.findall("class"):
|
||||
class_name = cls_elem.get("name", "")
|
||||
source_file = cls_elem.get("sourcefilename")
|
||||
|
||||
# Build method map from <method> children
|
||||
methods: list[MethodCoverage] = []
|
||||
for m in cls_elem.findall("method"):
|
||||
counters = {c.get("type"): c for c in m.findall("counter")}
|
||||
|
||||
def _missed(t): return int(counters[t].get("missed", 0)) if t in counters else 0
|
||||
def _covered(t): return int(counters[t].get("covered", 0)) if t in counters else 0
|
||||
|
||||
methods.append(MethodCoverage(
|
||||
name=m.get("name", ""),
|
||||
descriptor=m.get("desc", ""),
|
||||
first_line=int(m.get("line")) if m.get("line") else None,
|
||||
missed_instructions=_missed("INSTRUCTION"),
|
||||
covered_instructions=_covered("INSTRUCTION"),
|
||||
missed_branches=_missed("BRANCH"),
|
||||
covered_branches=_covered("BRANCH"),
|
||||
))
|
||||
|
||||
cc = ClassCoverage(
|
||||
class_name=class_name,
|
||||
source_file=source_file,
|
||||
methods=methods,
|
||||
)
|
||||
|
||||
# Per-line data lives in the matching <sourcefile> element
|
||||
source_file_elem = package.find(f"sourcefile[@name='{source_file}']") if source_file else None
|
||||
if source_file_elem is not None:
|
||||
for line_elem in source_file_elem.findall("line"):
|
||||
nr = int(line_elem.get("nr", 0))
|
||||
mi = int(line_elem.get("mi", 0)) # missed instructions
|
||||
ci = int(line_elem.get("ci", 0)) # covered instructions
|
||||
mb = int(line_elem.get("mb", 0)) # missed branches
|
||||
cb = int(line_elem.get("cb", 0)) # covered branches
|
||||
hits = ci # ci > 0 means line was executed at least once
|
||||
cc.all_lines.append(LineCoverage(
|
||||
line_number=nr,
|
||||
hits=hits,
|
||||
branch_total=mb + cb,
|
||||
branch_covered=cb,
|
||||
))
|
||||
|
||||
if cc.has_gaps:
|
||||
results.append(cc)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _compact_ranges(numbers: list[int]) -> str:
|
||||
"""Turn [1,2,3,5,7,8,9] -> '1-3, 5, 7-9'"""
|
||||
if not numbers:
|
||||
return ""
|
||||
ranges = []
|
||||
start = prev = numbers[0]
|
||||
for n in numbers[1:]:
|
||||
if n == prev + 1:
|
||||
prev = n
|
||||
else:
|
||||
ranges.append(f"{start}-{prev}" if start != prev else str(start))
|
||||
start = prev = n
|
||||
ranges.append(f"{start}-{prev}" if start != prev else str(start))
|
||||
return ", ".join(ranges)
|
||||
|
||||
|
||||
def format_agent(classes: list[ClassCoverage]) -> str:
|
||||
"""
|
||||
Output optimised for Claude Code agents:
|
||||
– structured, machine-readable yet human-legible
|
||||
– uses file paths and line numbers agents can act on
|
||||
– groups by file, sorts by severity (most gaps first)
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append("# JaCoCo Coverage Gaps — Agent Action Report")
|
||||
lines.append("")
|
||||
lines.append("## Summary")
|
||||
total_uncovered = sum(c.missed_lines for c in classes)
|
||||
total_partial = sum(len(c.partial_branch_lines) for c in classes)
|
||||
total_missed_branches = sum(c.missed_branches for c in classes)
|
||||
lines.append(f"- Files with gaps : {len(classes)}")
|
||||
lines.append(f"- Uncovered lines : {total_uncovered}")
|
||||
lines.append(f"- Partial branches: {total_partial} lines affected")
|
||||
lines.append(f"- Missed branches : {total_missed_branches} branch paths")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Files Requiring Tests")
|
||||
lines.append("")
|
||||
lines.append("> Each entry lists the SOURCE FILE PATH, the LINE NUMBERS that need")
|
||||
lines.append("> coverage, and the METHODS that contain those gaps.")
|
||||
lines.append("> Write or extend unit/integration tests to exercise these paths.")
|
||||
lines.append("")
|
||||
|
||||
# Sort: most uncovered lines first
|
||||
sorted_classes = sorted(classes, key=lambda c: -(c.missed_lines + len(c.partial_branch_lines)))
|
||||
|
||||
for cls in sorted_classes:
|
||||
source = cls.source_path or f"(source unknown) {cls.java_class_name}"
|
||||
lines.append(f"### `{source}`")
|
||||
lines.append(f"**Class**: `{cls.java_class_name}`")
|
||||
lines.append("")
|
||||
|
||||
if cls.uncovered_lines:
|
||||
lines.append(f"#### ❌ Uncovered Lines")
|
||||
lines.append(f"Lines not executed at all: `{_compact_ranges(cls.uncovered_lines)}`")
|
||||
lines.append("")
|
||||
lines.append("**Methods with uncovered lines:**")
|
||||
for method in cls.methods:
|
||||
uncov = [l for l in cls.uncovered_lines
|
||||
if method.first_line and l >= method.first_line]
|
||||
# heuristic: only attribute if there are uncovered lines near the method start
|
||||
if method.missed_instructions > 0:
|
||||
sig = f"`{method.name}{method.descriptor}`"
|
||||
pct = method.line_coverage_pct
|
||||
lines.append(f" - {sig} — {pct:.0f}% instruction coverage")
|
||||
lines.append("")
|
||||
|
||||
if cls.partial_branch_lines:
|
||||
lines.append(f"#### ⚠️ Partial Branch Coverage (Missing Conditional Paths)")
|
||||
lines.append(f"Lines where not all branches are taken: `{_compact_ranges(cls.partial_branch_lines)}`")
|
||||
lines.append("")
|
||||
lines.append("**Methods with branch gaps:**")
|
||||
for method in cls.methods:
|
||||
if method.missed_branches > 0:
|
||||
sig = f"`{method.name}{method.descriptor}`"
|
||||
pct = method.branch_coverage_pct
|
||||
missing = method.missed_branches
|
||||
lines.append(f" - {sig} — {pct:.0f}% branch coverage ({missing} branch path(s) never taken)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("**Action**: Add tests that exercise the above lines/branches.")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Quick Reference: All Uncovered Locations")
|
||||
lines.append("")
|
||||
lines.append("Copy-paste friendly list for IDE navigation or grep:")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
for cls in sorted_classes:
|
||||
src = cls.source_path or cls.java_class_name
|
||||
if cls.uncovered_lines:
|
||||
for ln in cls.uncovered_lines:
|
||||
lines.append(f"{src}:{ln} # uncovered line")
|
||||
if cls.partial_branch_lines:
|
||||
for ln in cls.partial_branch_lines:
|
||||
lines.append(f"{src}:{ln} # partial branch")
|
||||
lines.append("```")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_json(classes: list[ClassCoverage]) -> str:
|
||||
out = []
|
||||
for cls in classes:
|
||||
out.append({
|
||||
"class": cls.java_class_name,
|
||||
"source_path": cls.source_path,
|
||||
"uncovered_lines": cls.uncovered_lines,
|
||||
"partial_branch_lines": cls.partial_branch_lines,
|
||||
"missed_branches": cls.missed_branches,
|
||||
"methods": [
|
||||
{
|
||||
"name": m.name,
|
||||
"descriptor": m.descriptor,
|
||||
"first_line": m.first_line,
|
||||
"line_coverage_pct": round(m.line_coverage_pct, 1),
|
||||
"branch_coverage_pct": round(m.branch_coverage_pct, 1),
|
||||
"missed_branches": m.missed_branches,
|
||||
"missed_instructions": m.missed_instructions,
|
||||
}
|
||||
for m in cls.methods
|
||||
if not m.is_fully_covered
|
||||
],
|
||||
})
|
||||
return json.dumps(out, indent=2)
|
||||
|
||||
|
||||
def format_markdown(classes: list[ClassCoverage]) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# JaCoCo Missing Coverage Report\n")
|
||||
for cls in sorted(classes, key=lambda c: cls.java_class_name):
|
||||
lines.append(f"## {cls.java_class_name}")
|
||||
if cls.source_path:
|
||||
lines.append(f"**File**: `{cls.source_path}`\n")
|
||||
if cls.uncovered_lines:
|
||||
lines.append(f"**Uncovered lines**: {_compact_ranges(cls.uncovered_lines)}\n")
|
||||
if cls.partial_branch_lines:
|
||||
lines.append(f"**Partial branches at lines**: {_compact_ranges(cls.partial_branch_lines)}\n")
|
||||
lines.append("| Method | Line Coverage | Branch Coverage | Missed Branches |")
|
||||
lines.append("|--------|--------------|-----------------|-----------------|")
|
||||
for m in cls.methods:
|
||||
if not m.is_fully_covered:
|
||||
lines.append(
|
||||
f"| `{m.name}` | {m.line_coverage_pct:.0f}% | "
|
||||
f"{m.branch_coverage_pct:.0f}% | {m.missed_branches} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Report missing line & branch coverage from a JaCoCo XML report."
|
||||
)
|
||||
parser.add_argument("xml_file", help="Path to jacoco.xml report file")
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
choices=["agent", "json", "markdown"],
|
||||
default="json",
|
||||
help="Output format (default: agent)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-coverage",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="Only report classes below this %% line coverage (0 = report all gaps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package-filter", "-p",
|
||||
default=None,
|
||||
help="Only report classes in this package prefix (e.g. com/example/service)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
xml_path = Path(args.xml_file)
|
||||
if not xml_path.exists():
|
||||
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
classes = parse_jacoco_xml(str(xml_path))
|
||||
|
||||
# Apply package filter
|
||||
if args.package_filter:
|
||||
prefix = args.package_filter.replace(".", "/")
|
||||
classes = [c for c in classes if c.class_name.startswith(prefix)]
|
||||
|
||||
# Apply min-coverage filter
|
||||
if args.min_coverage > 0:
|
||||
def _line_pct(c: ClassCoverage) -> float:
|
||||
total = c.total_lines
|
||||
return 100.0 * c.covered_lines / total if total else 100.0
|
||||
|
||||
classes = [c for c in classes if _line_pct(c) < args.min_coverage]
|
||||
|
||||
if not classes:
|
||||
print("✅ No coverage gaps found matching the given filters.")
|
||||
return
|
||||
|
||||
if args.output == "agent":
|
||||
print(format_agent(classes))
|
||||
elif args.output == "json":
|
||||
print(format_json(classes))
|
||||
elif args.output == "markdown":
|
||||
print(format_markdown(classes))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,6 +19,9 @@ Usage:
|
||||
python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default)
|
||||
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller
|
||||
python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80
|
||||
python scoverage_coverage_gaps.py (default: scans ./modules)
|
||||
python scoverage_coverage_gaps.py --modules-dir ./services
|
||||
python scoverage_coverage_gaps.py <scoverage.xml>
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -26,7 +29,8 @@ import sys
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path, PureWindowsPath
|
||||
import glob
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@@ -112,7 +116,6 @@ class ClassGap:
|
||||
@property
|
||||
def uncovered_branch_lines(self) -> list[int]:
|
||||
"""Lines that are branch points and have at least one uncovered branch statement."""
|
||||
# Group branch statements by line; a line is "partial" if some covered, some not
|
||||
from collections import defaultdict
|
||||
by_line: dict[int, list[Statement]] = defaultdict(list)
|
||||
for s in self.statements:
|
||||
@@ -120,10 +123,7 @@ class ClassGap:
|
||||
by_line[s.line].append(s)
|
||||
partial = []
|
||||
for line, stmts in by_line.items():
|
||||
has_covered = any(s.is_covered for s in stmts)
|
||||
has_uncovered = any(s.is_uncovered for s in stmts)
|
||||
# Report line if any branch arm is uncovered
|
||||
if has_uncovered:
|
||||
if any(s.is_uncovered for s in stmts):
|
||||
partial.append(line)
|
||||
return sorted(partial)
|
||||
|
||||
@@ -169,20 +169,10 @@ class ClassGap:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalise_source(raw: str) -> str:
|
||||
"""
|
||||
Convert an absolute Windows or Unix source path from the XML into a
|
||||
relative src/main/scala/… path for agent consumption.
|
||||
|
||||
Strategy:
|
||||
1. Replace Windows backslashes.
|
||||
2. Find the 'src/' anchor and take everything from there.
|
||||
3. Fall back to the package-derived path if no anchor found.
|
||||
"""
|
||||
normalised = raw.replace("\\", "/")
|
||||
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Fallback: just the filename portion
|
||||
return normalised.split("/")[-1]
|
||||
|
||||
|
||||
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
|
||||
# Parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
def parse_scoverage_xml(xml_path: str) -> tuple[dict, list[ClassGap]]:
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# ── Authoritative project-level totals from <scoverage> root element ──────
|
||||
project_stats = {
|
||||
"total_statements": int(root.get("statement-count", 0)),
|
||||
"covered_statements": int(root.get("statements-invoked", 0)),
|
||||
@@ -202,17 +191,16 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
"branch_coverage_pct": float(root.get("branch-rate", 0.0)),
|
||||
}
|
||||
project_stats["missed_statements"] = (
|
||||
project_stats["total_statements"] - project_stats["covered_statements"]
|
||||
project_stats["total_statements"] - project_stats["covered_statements"]
|
||||
)
|
||||
|
||||
class_map: dict[str, ClassGap] = {} # full-class-name → ClassGap
|
||||
class_map: dict[str, ClassGap] = {}
|
||||
|
||||
for package in root.findall("packages/package"):
|
||||
for cls_elem in package.findall("classes/class"):
|
||||
class_name = cls_elem.get("name", "")
|
||||
filename = cls_elem.get("filename", "")
|
||||
|
||||
# Authoritative per-class totals from <class> attributes
|
||||
cls_total = int(cls_elem.get("statement-count", 0))
|
||||
cls_invoked = int(cls_elem.get("statements-invoked", 0))
|
||||
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0))
|
||||
@@ -221,11 +209,8 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
for method_elem in cls_elem.findall("methods/method"):
|
||||
method_name = method_elem.get("name", "")
|
||||
|
||||
# Authoritative per-method totals from <method> attributes
|
||||
m_total = int(method_elem.get("statement-count", 0))
|
||||
m_invoked = int(method_elem.get("statements-invoked", 0))
|
||||
m_stmt_rate = float(method_elem.get("statement-rate", 0.0))
|
||||
m_br_rate = float(method_elem.get("branch-rate", 0.0))
|
||||
m_total = int(method_elem.get("statement-count", 0))
|
||||
m_invoked = int(method_elem.get("statements-invoked", 0))
|
||||
|
||||
for stmt_elem in method_elem.findall("statements/statement"):
|
||||
raw_source = stmt_elem.get("source", filename)
|
||||
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
method=method_name,
|
||||
))
|
||||
|
||||
# Register method-level gap using authoritative XML stats
|
||||
cg = next(
|
||||
(v for v in class_map.values() if v.class_name == class_name),
|
||||
None,
|
||||
@@ -268,7 +252,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
uncov_lines = sorted({s.line for s in active if s.is_uncovered})
|
||||
uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered})
|
||||
if uncov_lines or uncov_branch_lines:
|
||||
# Count branches from statement-level data (not in method XML attrs)
|
||||
total_b = sum(1 for s in active if s.is_branch)
|
||||
cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
|
||||
mg = MethodGap(
|
||||
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
|
||||
)
|
||||
cg.method_gaps.append(mg)
|
||||
|
||||
# ── Project stats injected so formatters never recount from statements ────
|
||||
return project_stats, [cg for cg in class_map.values() if cg.has_gaps]
|
||||
|
||||
|
||||
@@ -310,103 +292,60 @@ def _compact_ranges(numbers: list[int]) -> str:
|
||||
# Formatters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pct_bar(pct: float, width: int = 20) -> str:
|
||||
"""Render a compact ASCII progress bar, e.g. [████░░░░░░░░░░░░░░░░] 23.5%"""
|
||||
filled = round(pct / 100 * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return f"[{bar}] {pct:.1f}%"
|
||||
|
||||
|
||||
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
||||
"""
|
||||
Compact agent format — optimised for low token count.
|
||||
Emits only actionable gaps: file path, uncovered lines, branch-gap lines,
|
||||
and a per-method breakdown. No ASCII bars, no redundant tables.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append("# scoverage Coverage Gaps — Agent Action Report")
|
||||
lines.append("")
|
||||
|
||||
# ---- Project-level totals (authoritative from <scoverage> root element) ----
|
||||
total_stmts = project_stats["total_statements"]
|
||||
covered_stmts = project_stats["covered_statements"]
|
||||
missed_stmts = project_stats["missed_statements"]
|
||||
total_stmts = project_stats["total_statements"]
|
||||
covered_stmts = project_stats["covered_statements"]
|
||||
missed_stmts = project_stats["missed_statements"]
|
||||
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
||||
overall_branch_pct = project_stats["branch_coverage_pct"]
|
||||
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes)
|
||||
# Branch totals: count from statement data (scoverage root has no branch count attr)
|
||||
total_branches = sum(c.total_branches for c in classes)
|
||||
covered_branches = sum(c.covered_branches for c in classes)
|
||||
missed_branches = sum(c.missed_branches for c in classes)
|
||||
total_branches = sum(c.total_branches for c in classes)
|
||||
covered_branches = sum(c.covered_branches for c in classes)
|
||||
missed_branches = total_branches - covered_branches
|
||||
|
||||
lines.append("## Project Coverage Summary")
|
||||
lines.append("")
|
||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
||||
lines.append(f"|-------------------|---------|-------|--------|----------|")
|
||||
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |")
|
||||
lines.append(f"| Branch paths | {covered_branches:>7} | {total_branches:>5} | {missed_branches:>6} | {_pct_bar(overall_branch_pct)} |")
|
||||
lines.append(f"| Files with gaps | {'—':>7} | {len(classes):>5} | {'—':>6} | {'—'} |")
|
||||
lines.append(f"| Lines w/ br. gaps | {'—':>7} | {total_branch_lines:>5} | {'—':>6} | {'—'} |")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Files Requiring Tests")
|
||||
lines.append("")
|
||||
lines.append("> Each entry lists the SOURCE FILE PATH, uncovered LINE NUMBERS,")
|
||||
lines.append("> and the METHODS that contain those gaps.")
|
||||
lines.append("> Write or extend unit/integration tests to exercise these paths.")
|
||||
lines.append("# scoverage Coverage Gaps")
|
||||
lines.append(
|
||||
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
|
||||
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
|
||||
f"files with gaps: {len(classes)}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
|
||||
|
||||
for cls in sorted_classes:
|
||||
lines.append(f"### `{cls.source_path}`")
|
||||
lines.append(f"**Class**: `{cls.class_name}`")
|
||||
lines.append("")
|
||||
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
||||
lines.append(f"|--------------|---------|-------|--------|----------|")
|
||||
lines.append(f"| Statements | {cls.covered_statements:>7} | {cls.total_statements:>5} | {cls.missed_statements:>6} | {_pct_bar(cls.stmt_coverage_pct)} |")
|
||||
if cls.total_branches:
|
||||
lines.append(f"| Branch paths | {cls.covered_branches:>7} | {cls.total_branches:>5} | {cls.missed_branches:>6} | {_pct_bar(cls.branch_coverage_pct)} |")
|
||||
lines.append("")
|
||||
|
||||
uncov = cls.all_uncovered_lines
|
||||
if uncov:
|
||||
lines.append("#### ❌ Uncovered Statements")
|
||||
lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`")
|
||||
lines.append("")
|
||||
|
||||
uncov = cls.all_uncovered_lines
|
||||
branch_lines = cls.uncovered_branch_lines
|
||||
if branch_lines:
|
||||
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)")
|
||||
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## {cls.source_path}")
|
||||
lines.append(
|
||||
f"stmt: {cls.stmt_coverage_pct:.1f}% ({cls.missed_statements} missed)"
|
||||
+ (f" | branches: {cls.branch_coverage_pct:.1f}% ({cls.missed_branches} missed)"
|
||||
if cls.total_branches else "")
|
||||
)
|
||||
if uncov:
|
||||
lines.append(f"uncovered lines: {_compact_ranges(uncov)}")
|
||||
only_branch = [l for l in branch_lines if l not in cls.all_uncovered_lines]
|
||||
if only_branch:
|
||||
lines.append(f"partial branches: {_compact_ranges(only_branch)}")
|
||||
|
||||
if cls.method_gaps:
|
||||
lines.append("#### Methods with Gaps")
|
||||
lines.append("")
|
||||
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
|
||||
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
|
||||
lines.append("methods:")
|
||||
for mg in cls.method_gaps:
|
||||
stmt_cell = f"{_pct_bar(mg.stmt_coverage_pct, 10)} ({mg.total_statements - mg.covered_statements}/{mg.total_statements} missed)"
|
||||
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a"
|
||||
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "—"
|
||||
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "—"
|
||||
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |")
|
||||
lines.append("")
|
||||
parts = [f" {mg.short_name}"]
|
||||
if mg.uncovered_lines:
|
||||
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
|
||||
if mg.uncovered_branch_lines:
|
||||
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
|
||||
lines.append(" ".join(parts))
|
||||
|
||||
lines.append("**Action**: Add tests that exercise the lines/branches listed above.")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Quick Reference: All Uncovered Locations")
|
||||
lines.append("")
|
||||
lines.append("Copy-paste friendly list for IDE navigation or grep:")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
for cls in sorted_classes:
|
||||
for ln in cls.all_uncovered_lines:
|
||||
lines.append(f"{cls.source_path}:{ln} # uncovered statement")
|
||||
for ln in cls.uncovered_branch_lines:
|
||||
if ln not in cls.all_uncovered_lines:
|
||||
lines.append(f"{cls.source_path}:{ln} # partial branch")
|
||||
lines.append("```")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scan-modules mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Candidate sub-paths within a module directory where scoverage.xml may live.
|
||||
_SCOVERAGE_SUBPATHS = [
|
||||
# Gradle / default layout
|
||||
"build/reports/scoverageTest/scoverage.xml",
|
||||
# sbt default (scala version wildcard resolved via glob)
|
||||
"target/scala-*/scoverage-report/scoverage.xml",
|
||||
# Maven / flat layout
|
||||
"target/scoverage-report/scoverage.xml",
|
||||
# Already at root of module
|
||||
"scoverage.xml",
|
||||
]
|
||||
|
||||
|
||||
def _find_scoverage_xml(module_dir: Path) -> Optional[Path]:
|
||||
"""Return the first scoverage.xml found inside *module_dir*, or None."""
|
||||
for pattern in _SCOVERAGE_SUBPATHS:
|
||||
hits = sorted(module_dir.glob(pattern))
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def format_module_gaps(module_name: str, classes: list[ClassGap], stmt_pct: float) -> str:
|
||||
"""
|
||||
One summary line per module. If coverage is not 100%, append an agent hint.
|
||||
"""
|
||||
if not classes:
|
||||
return f"[{module_name}] stmt: {stmt_pct:.1f}% ✅"
|
||||
|
||||
line = f"[{module_name}] stmt: {stmt_pct:.1f}% files_with_gaps: {len(classes)}"
|
||||
if stmt_pct < 100.0:
|
||||
line += f" # hint: run ./coverage {module_name} for details"
|
||||
return line
|
||||
|
||||
|
||||
def run_scan_modules(modules_dir: str, package_filter: Optional[str], min_coverage: float) -> None:
|
||||
base = Path(modules_dir)
|
||||
if not base.is_dir():
|
||||
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
module_dirs = sorted(p for p in base.iterdir() if p.is_dir())
|
||||
if not module_dirs:
|
||||
print(f"No sub-directories found in {base}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
results: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for mod_dir in module_dirs:
|
||||
if mod_dir.name.startswith("build"):
|
||||
continue
|
||||
xml_path = _find_scoverage_xml(mod_dir)
|
||||
if xml_path is None:
|
||||
missing.append(mod_dir.name)
|
||||
continue
|
||||
|
||||
project_stats, classes = parse_scoverage_xml(str(xml_path))
|
||||
|
||||
if package_filter:
|
||||
classes = [c for c in classes if c.class_name.startswith(package_filter)]
|
||||
if min_coverage > 0:
|
||||
classes = [c for c in classes if c.stmt_coverage_pct < min_coverage]
|
||||
|
||||
results.append(
|
||||
format_module_gaps(mod_dir.name, classes, project_stats["stmt_coverage_pct"])
|
||||
)
|
||||
|
||||
print("\n".join(results))
|
||||
|
||||
if missing:
|
||||
print(
|
||||
f"\n# Modules without scoverage.xml: {', '.join(missing)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -519,7 +539,13 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Report missing statement & branch coverage from a scoverage XML report."
|
||||
)
|
||||
parser.add_argument("xml_file", help="Path to scoverage.xml report file")
|
||||
|
||||
# Positional xml_file is optional when --scan-modules is used
|
||||
parser.add_argument(
|
||||
"xml_file",
|
||||
nargs="?",
|
||||
help="Path to scoverage.xml report file (not required with --scan-modules)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
choices=["agent", "json", "markdown"],
|
||||
@@ -537,8 +563,30 @@ def main() -> None:
|
||||
default=None,
|
||||
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
|
||||
)
|
||||
# ── Scan-modules mode ──────────────────────────────────────────────────
|
||||
parser.add_argument(
|
||||
"--scan-modules",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Scan every sub-directory of --modules-dir for a scoverage.xml "
|
||||
"and print a compact coverage-gaps summary per module."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--modules-dir",
|
||||
default="./modules",
|
||||
help="Root directory that contains one sub-directory per module (default: ./modules)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── Scan-modules path (explicit flag, or default when no xml_file given) ──
|
||||
if args.scan_modules or not args.xml_file:
|
||||
run_scan_modules(args.modules_dir, args.package_filter, args.min_coverage)
|
||||
return
|
||||
|
||||
# ── Single-file path ──────────────────────────────────────────────────
|
||||
|
||||
xml_path = Path(args.xml_file)
|
||||
if not xml_path.exists():
|
||||
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
|
||||
@@ -565,4 +613,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Gap Reporter
|
||||
Scans JUnit XML test results under modules/*/build/test-results/*.xml and
|
||||
outputs a minimal summary optimised for agent consumption.
|
||||
|
||||
Usage:
|
||||
python test_gaps.py # scan all modules (default)
|
||||
python test_gaps.py --module chess # single module
|
||||
python test_gaps.py --module all # explicit all
|
||||
python test_gaps.py --modules-dir ./modules
|
||||
python test_gaps.py --results-subdir build/test-results
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TestCase:
|
||||
classname: str
|
||||
name: str
|
||||
time: float
|
||||
failure: Optional[str] = None # message if failed
|
||||
error: Optional[str] = None # message if errored
|
||||
skipped: bool = False
|
||||
|
||||
@property
|
||||
def short_class(self) -> str:
|
||||
return self.classname.split(".")[-1]
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self.failure is not None:
|
||||
return "FAIL"
|
||||
if self.error is not None:
|
||||
return "ERROR"
|
||||
if self.skipped:
|
||||
return "SKIP"
|
||||
return "OK"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuiteResult:
|
||||
name: str
|
||||
total: int
|
||||
failures: int
|
||||
errors: int
|
||||
skipped: int
|
||||
time: float
|
||||
cases: list[TestCase] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def passed(self) -> int:
|
||||
return self.total - self.failures - self.errors - self.skipped
|
||||
|
||||
@property
|
||||
def is_clean(self) -> bool:
|
||||
return self.failures == 0 and self.errors == 0
|
||||
|
||||
@property
|
||||
def bad_cases(self) -> list[TestCase]:
|
||||
return [c for c in self.cases if c.status in ("FAIL", "ERROR")]
|
||||
|
||||
@property
|
||||
def skipped_cases(self) -> list[TestCase]:
|
||||
return [c for c in self.cases if c.skipped]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleResult:
|
||||
name: str
|
||||
suites: list[SuiteResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total(self) -> int: return sum(s.total for s in self.suites)
|
||||
@property
|
||||
def failures(self) -> int: return sum(s.failures for s in self.suites)
|
||||
@property
|
||||
def errors(self) -> int: return sum(s.errors for s in self.suites)
|
||||
@property
|
||||
def skipped(self) -> int: return sum(s.skipped for s in self.suites)
|
||||
@property
|
||||
def passed(self) -> int: return sum(s.passed for s in self.suites)
|
||||
@property
|
||||
def is_clean(self) -> bool: return self.failures == 0 and self.errors == 0
|
||||
|
||||
@property
|
||||
def bad_cases(self) -> list[TestCase]:
|
||||
return [c for s in self.suites for c in s.bad_cases]
|
||||
|
||||
@property
|
||||
def skipped_cases(self) -> list[TestCase]:
|
||||
return [c for s in self.suites for c in s.skipped_cases]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_suite_xml(xml_path: Path) -> SuiteResult:
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Handle both <testsuite> root and <testsuites> wrapper
|
||||
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
|
||||
|
||||
# Merge multiple suites from one file into a single SuiteResult
|
||||
total = failures = errors = skipped = 0
|
||||
elapsed = 0.0
|
||||
name = xml_path.stem
|
||||
cases: list[TestCase] = []
|
||||
|
||||
for suite in suites:
|
||||
total += int(suite.get("tests", 0))
|
||||
failures += int(suite.get("failures", 0))
|
||||
errors += int(suite.get("errors", 0))
|
||||
skipped += int(suite.get("skipped", 0))
|
||||
elapsed += float(suite.get("time", 0.0))
|
||||
if suite.get("name"):
|
||||
name = suite.get("name")
|
||||
|
||||
for tc in suite.findall("testcase"):
|
||||
fail_el = tc.find("failure")
|
||||
err_el = tc.find("error")
|
||||
skip_el = tc.find("skipped")
|
||||
cases.append(TestCase(
|
||||
classname=tc.get("classname", ""),
|
||||
name=tc.get("name", ""),
|
||||
time=float(tc.get("time", 0.0)),
|
||||
failure=fail_el.get("message", fail_el.text or "") if fail_el is not None else None,
|
||||
error=err_el.get("message", err_el.text or "") if err_el is not None else None,
|
||||
skipped=skip_el is not None,
|
||||
))
|
||||
|
||||
return SuiteResult(
|
||||
name=name, total=total, failures=failures,
|
||||
errors=errors, skipped=skipped, time=elapsed, cases=cases,
|
||||
)
|
||||
|
||||
|
||||
def load_module(module_dir: Path, results_subdir: str) -> Optional[ModuleResult]:
|
||||
results_dir = module_dir / results_subdir
|
||||
if not results_dir.is_dir():
|
||||
return None
|
||||
|
||||
xml_files = sorted(results_dir.glob("*.xml"))
|
||||
if not xml_files:
|
||||
return None
|
||||
|
||||
mod = ModuleResult(name=module_dir.name)
|
||||
for xml_path in xml_files:
|
||||
try:
|
||||
mod.suites.append(parse_suite_xml(xml_path))
|
||||
except ET.ParseError:
|
||||
pass # skip malformed files silently
|
||||
return mod if mod.suites else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _truncate(text: str, max_len: int = 120) -> str:
|
||||
text = " ".join(text.split()) # collapse whitespace
|
||||
return text[:max_len] + "…" if len(text) > max_len else text
|
||||
|
||||
|
||||
def format_module(mod: ModuleResult) -> str:
|
||||
parts = [f"[{mod.name}]"]
|
||||
|
||||
if mod.is_clean and mod.skipped == 0:
|
||||
parts.append(f"tests: {mod.total} ✅")
|
||||
return " ".join(parts)
|
||||
|
||||
parts.append(f"tests: {mod.total}")
|
||||
if mod.failures: parts.append(f"failed: {mod.failures}")
|
||||
if mod.errors: parts.append(f"errors: {mod.errors}")
|
||||
if mod.skipped: parts.append(f"skipped: {mod.skipped}")
|
||||
|
||||
# Agent hint only when there are actual failures/errors
|
||||
if not mod.is_clean:
|
||||
parts.append(f" # hint: run ./test {mod.name} for details")
|
||||
|
||||
lines = [" ".join(parts)]
|
||||
|
||||
# List each failed/errored test — this IS the actionable info
|
||||
for tc in mod.bad_cases:
|
||||
msg = tc.failure if tc.failure is not None else tc.error
|
||||
label = f" {tc.status}: {tc.short_class} > {tc.name}"
|
||||
if msg:
|
||||
label += f" [{_truncate(msg, 80)}]"
|
||||
lines.append(label)
|
||||
|
||||
# Skipped: compact, one line total
|
||||
if mod.skipped_cases:
|
||||
skipped_names = ", ".join(
|
||||
f"{c.short_class}.{c.name}" for c in mod.skipped_cases[:5]
|
||||
)
|
||||
if len(mod.skipped_cases) > 5:
|
||||
skipped_names += f" (+{len(mod.skipped_cases) - 5} more)"
|
||||
lines.append(f" SKIP: {skipped_names}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(modules_dir: str, results_subdir: str, module_filter: Optional[str]) -> None:
|
||||
base = Path(modules_dir)
|
||||
if not base.is_dir():
|
||||
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve which module dirs to scan
|
||||
if module_filter and module_filter != "all":
|
||||
mod_dir = base / module_filter
|
||||
if not mod_dir.is_dir():
|
||||
print(f"ERROR: module not found: {mod_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
candidates = [mod_dir]
|
||||
else:
|
||||
candidates = sorted(p for p in base.iterdir() if p.is_dir())
|
||||
|
||||
results: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for mod_dir in candidates:
|
||||
if mod_dir.name.startswith("build"):
|
||||
continue
|
||||
mod = load_module(mod_dir, results_subdir)
|
||||
if mod is None:
|
||||
missing.append(mod_dir.name)
|
||||
continue
|
||||
results.append(format_module(mod))
|
||||
|
||||
print("\n".join(results))
|
||||
|
||||
if missing:
|
||||
print(
|
||||
f"\n# Modules without test results: {', '.join(missing)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Minimal test-gap reporter for JUnit XML results across modules."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module", "-m",
|
||||
nargs="?",
|
||||
const="all",
|
||||
default="all",
|
||||
help="Module name to scan, or 'all' (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--modules-dir",
|
||||
default="./modules",
|
||||
help="Root directory containing one sub-directory per module (default: ./modules)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--results-subdir",
|
||||
default="build/test-results/test",
|
||||
help="Sub-path inside each module dir where *.xml files live (default: build/test-results/test)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
filter_ = None if args.module == "all" else args.module
|
||||
run(args.modules_dir, args.results_subdir, filter_)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
## (2026-03-27)
|
||||
## (2026-03-28)
|
||||
## (2026-03-28)
|
||||
## (2026-03-29)
|
||||
## (2026-03-31)
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
@@ -49,16 +49,17 @@ dependencies {
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
events("skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
|
||||
@@ -8,10 +8,12 @@ object Board:
|
||||
|
||||
extension (b: Board)
|
||||
def pieceAt(sq: Square): Option[Piece] = b.get(sq)
|
||||
def updated(sq: Square, piece: Piece): Board = b.updated(sq, piece)
|
||||
def removed(sq: Square): Board = b.removed(sq)
|
||||
def withMove(from: Square, to: Square): (Board, Option[Piece]) =
|
||||
val captured = b.get(to)
|
||||
val updated = b.removed(from).updated(to, b(from))
|
||||
(updated, captured)
|
||||
val updatedBoard = b.removed(from).updated(to, b(from))
|
||||
(updatedBoard, captured)
|
||||
def pieces: Map[Square, Piece] = b
|
||||
|
||||
val initial: Board =
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class BoardTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val e2 = Square(File.E, Rank.R2)
|
||||
private val e4 = Square(File.E, Rank.R4)
|
||||
private val d7 = Square(File.D, Rank.R7)
|
||||
|
||||
test("pieceAt returns Some for occupied square") {
|
||||
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
}
|
||||
|
||||
test("pieceAt returns None for empty square") {
|
||||
Board.initial.pieceAt(e4) shouldBe None
|
||||
}
|
||||
|
||||
test("withMove moves piece and vacates origin") {
|
||||
val (board, captured) = Board.initial.withMove(e2, e4)
|
||||
captured shouldBe None
|
||||
board.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||
board.pieceAt(e2) shouldBe None
|
||||
}
|
||||
|
||||
test("withMove returns captured piece when destination is occupied") {
|
||||
val from = Square(File.A, Rank.R1)
|
||||
val to = Square(File.A, Rank.R8)
|
||||
val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook))
|
||||
val (board, captured) = b.withMove(from, to)
|
||||
captured shouldBe Some(Piece.BlackRook)
|
||||
board.pieceAt(to) shouldBe Some(Piece.WhiteRook)
|
||||
board.pieceAt(from) shouldBe None
|
||||
}
|
||||
|
||||
test("pieces returns the underlying map") {
|
||||
val map = Map(e2 -> Piece.WhitePawn)
|
||||
val b = Board(map)
|
||||
b.pieces shouldBe map
|
||||
}
|
||||
|
||||
test("Board.apply constructs board from map") {
|
||||
val map = Map(e2 -> Piece.WhitePawn)
|
||||
val b = Board(map)
|
||||
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
}
|
||||
|
||||
test("initial board has 32 pieces") {
|
||||
Board.initial.pieces should have size 32
|
||||
}
|
||||
|
||||
test("initial board has 16 white pieces") {
|
||||
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
|
||||
}
|
||||
|
||||
test("initial board has 16 black pieces") {
|
||||
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
|
||||
}
|
||||
|
||||
test("initial board white pawns on rank 2") {
|
||||
File.values.foreach { file =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
|
||||
}
|
||||
}
|
||||
|
||||
test("initial board black pawns on rank 7") {
|
||||
File.values.foreach { file =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
|
||||
}
|
||||
}
|
||||
|
||||
test("initial board white back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe
|
||||
Some(Piece(Color.White, expectedBackRank(i)))
|
||||
}
|
||||
}
|
||||
|
||||
test("initial board black back rank") {
|
||||
val expectedBackRank = Vector(
|
||||
PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen,
|
||||
PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook
|
||||
)
|
||||
File.values.zipWithIndex.foreach { (file, i) =>
|
||||
Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe
|
||||
Some(Piece(Color.Black, expectedBackRank(i)))
|
||||
}
|
||||
}
|
||||
|
||||
test("ranks 3-6 are empty on initial board") {
|
||||
val emptyRanks = Seq(Rank.R3, Rank.R4, Rank.R5, Rank.R6)
|
||||
for
|
||||
rank <- emptyRanks
|
||||
file <- File.values
|
||||
do
|
||||
Board.initial.pieceAt(Square(file, rank)) shouldBe None
|
||||
}
|
||||
|
||||
test("updated adds or replaces piece at square") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val updated = b.updated(e4, Piece.WhiteKnight)
|
||||
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
|
||||
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
|
||||
test("updated replaces existing piece") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||
val updated = b.updated(e2, Piece.WhiteKnight)
|
||||
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
|
||||
test("removed deletes piece from board") {
|
||||
val b = Board(Map(e2 -> Piece.WhitePawn, e4 -> Piece.WhiteKnight))
|
||||
val removed = b.removed(e2)
|
||||
removed.pieceAt(e2) shouldBe None
|
||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ColorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("White.opposite returns Black") {
|
||||
Color.White.opposite shouldBe Color.Black
|
||||
}
|
||||
|
||||
test("Black.opposite returns White") {
|
||||
Color.Black.opposite shouldBe Color.White
|
||||
}
|
||||
|
||||
test("White.label returns 'White'") {
|
||||
Color.White.label shouldBe "White"
|
||||
}
|
||||
|
||||
test("Black.label returns 'Black'") {
|
||||
Color.Black.label shouldBe "Black"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PieceTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Piece holds color and pieceType") {
|
||||
val p = Piece(Color.White, PieceType.Queen)
|
||||
p.color shouldBe Color.White
|
||||
p.pieceType shouldBe PieceType.Queen
|
||||
}
|
||||
|
||||
test("WhitePawn convenience constant") {
|
||||
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
|
||||
}
|
||||
|
||||
test("WhiteKnight convenience constant") {
|
||||
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight)
|
||||
}
|
||||
|
||||
test("WhiteBishop convenience constant") {
|
||||
Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop)
|
||||
}
|
||||
|
||||
test("WhiteRook convenience constant") {
|
||||
Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook)
|
||||
}
|
||||
|
||||
test("WhiteQueen convenience constant") {
|
||||
Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen)
|
||||
}
|
||||
|
||||
test("WhiteKing convenience constant") {
|
||||
Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King)
|
||||
}
|
||||
|
||||
test("BlackPawn convenience constant") {
|
||||
Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn)
|
||||
}
|
||||
|
||||
test("BlackKnight convenience constant") {
|
||||
Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight)
|
||||
}
|
||||
|
||||
test("BlackBishop convenience constant") {
|
||||
Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop)
|
||||
}
|
||||
|
||||
test("BlackRook convenience constant") {
|
||||
Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook)
|
||||
}
|
||||
|
||||
test("BlackQueen convenience constant") {
|
||||
Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen)
|
||||
}
|
||||
|
||||
test("BlackKing convenience constant") {
|
||||
Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Pawn.label returns 'Pawn'") {
|
||||
PieceType.Pawn.label shouldBe "Pawn"
|
||||
}
|
||||
|
||||
test("Knight.label returns 'Knight'") {
|
||||
PieceType.Knight.label shouldBe "Knight"
|
||||
}
|
||||
|
||||
test("Bishop.label returns 'Bishop'") {
|
||||
PieceType.Bishop.label shouldBe "Bishop"
|
||||
}
|
||||
|
||||
test("Rook.label returns 'Rook'") {
|
||||
PieceType.Rook.label shouldBe "Rook"
|
||||
}
|
||||
|
||||
test("Queen.label returns 'Queen'") {
|
||||
PieceType.Queen.label shouldBe "Queen"
|
||||
}
|
||||
|
||||
test("King.label returns 'King'") {
|
||||
PieceType.King.label shouldBe "King"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.nowchess.api.board
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class SquareTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("Square.toString produces lowercase file and rank number") {
|
||||
Square(File.E, Rank.R4).toString shouldBe "e4"
|
||||
}
|
||||
|
||||
test("Square.toString for a1") {
|
||||
Square(File.A, Rank.R1).toString shouldBe "a1"
|
||||
}
|
||||
|
||||
test("Square.toString for h8") {
|
||||
Square(File.H, Rank.R8).toString shouldBe "h8"
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square e4") {
|
||||
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4))
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square a1") {
|
||||
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
|
||||
}
|
||||
|
||||
test("fromAlgebraic parses valid square h8") {
|
||||
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
|
||||
}
|
||||
|
||||
test("fromAlgebraic is case-insensitive for file") {
|
||||
Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4))
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for empty string") {
|
||||
Square.fromAlgebraic("") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for string too short") {
|
||||
Square.fromAlgebraic("e") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for string too long") {
|
||||
Square.fromAlgebraic("e42") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for invalid file character") {
|
||||
Square.fromAlgebraic("z4") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for non-digit rank") {
|
||||
Square.fromAlgebraic("ex") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for rank 0") {
|
||||
Square.fromAlgebraic("e0") shouldBe None
|
||||
}
|
||||
|
||||
test("fromAlgebraic returns None for rank 9") {
|
||||
Square.fromAlgebraic("e9") shouldBe None
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameStateTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("CastlingRights.None has both flags false") {
|
||||
CastlingRights.None.kingSide shouldBe false
|
||||
CastlingRights.None.queenSide shouldBe false
|
||||
}
|
||||
|
||||
test("CastlingRights.Both has both flags true") {
|
||||
CastlingRights.Both.kingSide shouldBe true
|
||||
CastlingRights.Both.queenSide shouldBe true
|
||||
}
|
||||
|
||||
test("CastlingRights constructor sets fields") {
|
||||
val cr = CastlingRights(kingSide = true, queenSide = false)
|
||||
cr.kingSide shouldBe true
|
||||
cr.queenSide shouldBe false
|
||||
}
|
||||
|
||||
test("GameResult cases exist") {
|
||||
GameResult.WhiteWins shouldBe GameResult.WhiteWins
|
||||
GameResult.BlackWins shouldBe GameResult.BlackWins
|
||||
GameResult.Draw shouldBe GameResult.Draw
|
||||
}
|
||||
|
||||
test("GameStatus.NotStarted") {
|
||||
GameStatus.NotStarted shouldBe GameStatus.NotStarted
|
||||
}
|
||||
|
||||
test("GameStatus.InProgress") {
|
||||
GameStatus.InProgress shouldBe GameStatus.InProgress
|
||||
}
|
||||
|
||||
test("GameStatus.Finished carries result") {
|
||||
val status = GameStatus.Finished(GameResult.Draw)
|
||||
status shouldBe GameStatus.Finished(GameResult.Draw)
|
||||
status match
|
||||
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
|
||||
case _ => fail("expected Finished")
|
||||
}
|
||||
|
||||
test("GameState.initial has standard FEN piece placement") {
|
||||
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
}
|
||||
|
||||
test("GameState.initial active color is White") {
|
||||
GameState.initial.activeColor shouldBe Color.White
|
||||
}
|
||||
|
||||
test("GameState.initial white has full castling rights") {
|
||||
GameState.initial.castlingWhite shouldBe CastlingRights.Both
|
||||
}
|
||||
|
||||
test("GameState.initial black has full castling rights") {
|
||||
GameState.initial.castlingBlack shouldBe CastlingRights.Both
|
||||
}
|
||||
|
||||
test("GameState.initial en-passant target is None") {
|
||||
GameState.initial.enPassantTarget shouldBe None
|
||||
}
|
||||
|
||||
test("GameState.initial half-move clock is 0") {
|
||||
GameState.initial.halfMoveClock shouldBe 0
|
||||
}
|
||||
|
||||
test("GameState.initial full-move number is 1") {
|
||||
GameState.initial.fullMoveNumber shouldBe 1
|
||||
}
|
||||
|
||||
test("GameState.initial status is InProgress") {
|
||||
GameState.initial.status shouldBe GameStatus.InProgress
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.nowchess.api.move
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MoveTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val e2 = Square(File.E, Rank.R2)
|
||||
private val e4 = Square(File.E, Rank.R4)
|
||||
|
||||
test("Move defaults moveType to Normal") {
|
||||
val m = Move(e2, e4)
|
||||
m.moveType shouldBe MoveType.Normal
|
||||
}
|
||||
|
||||
test("Move stores from and to squares") {
|
||||
val m = Move(e2, e4)
|
||||
m.from shouldBe e2
|
||||
m.to shouldBe e4
|
||||
}
|
||||
|
||||
test("Move with CastleKingside moveType") {
|
||||
val m = Move(e2, e4, MoveType.CastleKingside)
|
||||
m.moveType shouldBe MoveType.CastleKingside
|
||||
}
|
||||
|
||||
test("Move with CastleQueenside moveType") {
|
||||
val m = Move(e2, e4, MoveType.CastleQueenside)
|
||||
m.moveType shouldBe MoveType.CastleQueenside
|
||||
}
|
||||
|
||||
test("Move with EnPassant moveType") {
|
||||
val m = Move(e2, e4, MoveType.EnPassant)
|
||||
m.moveType shouldBe MoveType.EnPassant
|
||||
}
|
||||
|
||||
test("Move with Promotion to Queen") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Knight") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Bishop") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
||||
}
|
||||
|
||||
test("Move with Promotion to Rook") {
|
||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
|
||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.api.player
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("PlayerId.apply wraps a string") {
|
||||
val id = PlayerId("player-123")
|
||||
id.value shouldBe "player-123"
|
||||
}
|
||||
|
||||
test("PlayerId.value unwraps to original string") {
|
||||
val raw = "abc-456"
|
||||
PlayerId(raw).value shouldBe raw
|
||||
}
|
||||
|
||||
test("PlayerInfo holds id and displayName") {
|
||||
val id = PlayerId("p1")
|
||||
val info = PlayerInfo(id, "Magnus")
|
||||
info.id.value shouldBe "p1"
|
||||
info.displayName shouldBe "Magnus"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.nowchess.api.response
|
||||
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class ApiResponseTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("ApiResponse.Success carries data") {
|
||||
val r = ApiResponse.Success(42)
|
||||
r.data shouldBe 42
|
||||
}
|
||||
|
||||
test("ApiResponse.Failure carries error list") {
|
||||
val err = ApiError("CODE", "msg")
|
||||
val r = ApiResponse.Failure(List(err))
|
||||
r.errors shouldBe List(err)
|
||||
}
|
||||
|
||||
test("ApiResponse.error creates single-error Failure") {
|
||||
val err = ApiError("NOT_FOUND", "not found")
|
||||
val f = ApiResponse.error(err)
|
||||
f shouldBe ApiResponse.Failure(List(err))
|
||||
}
|
||||
|
||||
test("ApiError holds code and message") {
|
||||
val e = ApiError("CODE", "message")
|
||||
e.code shouldBe "CODE"
|
||||
e.message shouldBe "message"
|
||||
e.field shouldBe None
|
||||
}
|
||||
|
||||
test("ApiError holds optional field") {
|
||||
val e = ApiError("INVALID", "bad value", Some("email"))
|
||||
e.field shouldBe Some("email")
|
||||
}
|
||||
|
||||
test("Pagination.totalPages with exact division") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
|
||||
}
|
||||
|
||||
test("Pagination.totalPages rounds up") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when totalItems is 0") {
|
||||
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when pageSize is 0") {
|
||||
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
test("Pagination.totalPages is 0 when pageSize is negative") {
|
||||
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
|
||||
}
|
||||
|
||||
test("PagedResponse holds items and pagination") {
|
||||
val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20)
|
||||
val pr = PagedResponse(List("a", "b"), pagination)
|
||||
pr.items shouldBe List("a", "b")
|
||||
pr.pagination shouldBe pagination
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=8
|
||||
@@ -0,0 +1,144 @@
|
||||
## (2026-03-27)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-03-28)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-03-29)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-03-29)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-03-31)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-04-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
## (2026-04-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
## (2026-04-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
@@ -52,16 +52,17 @@ dependencies {
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
events("skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.chess.controller.GameController
|
||||
import de.nowchess.chess.logic.GameContext
|
||||
|
||||
object Main {
|
||||
def main(args: Array[String]): Unit =
|
||||
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone.
|
||||
* Commands encapsulate user actions and game state transitions.
|
||||
*/
|
||||
trait Command:
|
||||
/** Execute the command and return true if successful, false otherwise. */
|
||||
def execute(): Boolean
|
||||
|
||||
/** Undo the command and return true if successful, false otherwise. */
|
||||
def undo(): Boolean
|
||||
|
||||
/** A human-readable description of this command. */
|
||||
def description: String
|
||||
|
||||
/** Command to move a piece from one square to another.
|
||||
* Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
moveResult.isDefined
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||
|
||||
override def description: String = s"Move from $from to $to"
|
||||
|
||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
/** Command to quit the game. */
|
||||
case class QuitCommand() extends Command:
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Quit game"
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||
|
||||
override def description: String = "Reset board"
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
/** Manages command execution and history for undo/redo support. */
|
||||
class CommandInvoker:
|
||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||
private var currentIndex = -1
|
||||
|
||||
/** Execute a command and add it to history.
|
||||
* Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
def execute(command: Command): Boolean = synchronized {
|
||||
if command.execute() then
|
||||
// Remove any commands after current index (redo stack is discarded)
|
||||
while currentIndex < executedCommands.size - 1 do
|
||||
executedCommands.remove(executedCommands.size - 1)
|
||||
executedCommands += command
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Undo the last executed command if possible. */
|
||||
def undo(): Boolean = synchronized {
|
||||
if currentIndex >= 0 && currentIndex < executedCommands.size then
|
||||
val command = executedCommands(currentIndex)
|
||||
if command.undo() then
|
||||
currentIndex -= 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Redo the next command in history if available. */
|
||||
def redo(): Boolean = synchronized {
|
||||
if currentIndex + 1 < executedCommands.size then
|
||||
val command = executedCommands(currentIndex + 1)
|
||||
if command.execute() then
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Get the history of all executed commands. */
|
||||
def history: List[Command] = synchronized {
|
||||
executedCommands.toList
|
||||
}
|
||||
|
||||
/** Get the current position in command history. */
|
||||
def getCurrentIndex: Int = synchronized {
|
||||
currentIndex
|
||||
}
|
||||
|
||||
/** Clear all command history. */
|
||||
def clear(): Unit = synchronized {
|
||||
executedCommands.clear()
|
||||
currentIndex = -1
|
||||
}
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized {
|
||||
currentIndex >= 0
|
||||
}
|
||||
|
||||
/** Check if redo is available. */
|
||||
def canRedo: Boolean = synchronized {
|
||||
currentIndex + 1 < executedCommands.size
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle}
|
||||
import de.nowchess.chess.view.Renderer
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.*
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result ADT returned by the pure processMove function
|
||||
@@ -17,8 +15,16 @@ object MoveResult:
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove extends MoveResult
|
||||
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class PromotionRequired(
|
||||
from: Square,
|
||||
to: Square,
|
||||
boardBefore: Board,
|
||||
historyBefore: GameHistory,
|
||||
captured: Option[Piece],
|
||||
turn: Color
|
||||
) extends MoveResult
|
||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Checkmate(winner: Color) extends MoveResult
|
||||
case object Stalemate extends MoveResult
|
||||
|
||||
@@ -31,106 +37,70 @@ object GameController:
|
||||
/** Pure function: interprets one raw input line against the current game context.
|
||||
* Has no I/O side effects — all output must be handled by the caller.
|
||||
*/
|
||||
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult =
|
||||
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||
raw.trim match
|
||||
case "quit" | "q" =>
|
||||
MoveResult.Quit
|
||||
case "quit" | "q" => MoveResult.Quit
|
||||
case trimmed =>
|
||||
Parser.parseMove(trimmed) match
|
||||
case None =>
|
||||
MoveResult.InvalidFormat(trimmed)
|
||||
case Some((from, to)) =>
|
||||
ctx.board.pieceAt(from) match
|
||||
case None =>
|
||||
MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn =>
|
||||
MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !MoveValidator.isLegal(ctx, from, to) then
|
||||
MoveResult.IllegalMove
|
||||
else
|
||||
val castleOpt = if MoveValidator.isCastle(ctx.board, from, to)
|
||||
then Some(MoveValidator.castleSide(from, to))
|
||||
else None
|
||||
val (newBoard, captured) = castleOpt match
|
||||
case Some(side) => (ctx.board.withCastle(turn, side), None)
|
||||
case None => ctx.board.withMove(from, to)
|
||||
val newCtx = applyRightsRevocation(
|
||||
ctx.copy(board = newBoard), turn, from, to, castleOpt
|
||||
)
|
||||
GameRules.gameStatus(newCtx, turn.opposite) match
|
||||
case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
|
||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
||||
case None => MoveResult.InvalidFormat(trimmed)
|
||||
case Some((from, to)) => validateAndApply(board, history, turn, from, to)
|
||||
|
||||
private def applyRightsRevocation(
|
||||
ctx: GameContext,
|
||||
turn: Color,
|
||||
/** Apply a previously detected promotion move with the chosen piece.
|
||||
* Called after processMove returned PromotionRequired.
|
||||
*/
|
||||
def completePromotion(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
from: Square,
|
||||
to: Square,
|
||||
castle: Option[CastleSide]
|
||||
): GameContext =
|
||||
// Step 1: Revoke all rights for a castling move (idempotent with step 2)
|
||||
val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None))
|
||||
piece: PromotionPiece,
|
||||
turn: Color
|
||||
): MoveResult =
|
||||
val (boardAfterMove, captured) = board.withMove(from, to)
|
||||
val promotedPieceType = piece match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||
// Promotion is always a pawn move → clock resets
|
||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
// Step 2: Source-square revocation
|
||||
val ctx1 = from match
|
||||
case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None)
|
||||
case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None)
|
||||
case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false))
|
||||
case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false))
|
||||
case _ => ctx0
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Step 3: Destination-square revocation (enemy captures a rook on its home square)
|
||||
to match
|
||||
case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false))
|
||||
case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false))
|
||||
case _ => ctx1
|
||||
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||
board.pieceAt(from) match
|
||||
case None => MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn => MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
|
||||
else if MoveValidator.isPromotionMove(board, from, to) then
|
||||
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
|
||||
else applyNormalMove(board, history, turn, from, to)
|
||||
|
||||
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
|
||||
* prints the outcome, and recurses until the game ends.
|
||||
*/
|
||||
def gameLoop(ctx: GameContext, turn: Color): Unit =
|
||||
println()
|
||||
print(Renderer.render(ctx.board))
|
||||
println(s"${turn.label}'s turn. Enter move: ")
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
processMove(ctx, turn, input) match
|
||||
case MoveResult.Quit =>
|
||||
println("Game over. Goodbye!")
|
||||
case MoveResult.InvalidFormat(raw) =>
|
||||
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
|
||||
gameLoop(ctx, turn)
|
||||
case MoveResult.NoPiece =>
|
||||
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
|
||||
gameLoop(ctx, turn)
|
||||
case MoveResult.WrongColor =>
|
||||
println(s"That is not your piece.")
|
||||
gameLoop(ctx, turn)
|
||||
case MoveResult.IllegalMove =>
|
||||
println(s"Illegal move.")
|
||||
gameLoop(ctx, turn)
|
||||
case MoveResult.Moved(newCtx, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
gameLoop(newCtx, newTurn)
|
||||
case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
println(s"${newTurn.label} is in check!")
|
||||
gameLoop(newCtx, newTurn)
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
println(s"Checkmate! ${winner.label} wins.")
|
||||
gameLoop(GameContext.initial, Color.White)
|
||||
case MoveResult.Stalemate =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
gameLoop(GameContext.initial, Color.White)
|
||||
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
|
||||
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
|
||||
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
|
||||
val (newBoard, captured) = castleOpt match
|
||||
case Some(side) => (board.withCastle(turn, side), None)
|
||||
case None =>
|
||||
val (b, cap) = board.withMove(from, to)
|
||||
if isEP then
|
||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||
else (b, cap)
|
||||
val pieceType = board.pieceAt(from).map(_.pieceType).getOrElse(PieceType.Pawn)
|
||||
val wasPawnMove = pieceType == PieceType.Pawn
|
||||
val wasCapture = captured.isDefined
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture, pieceType = pieceType)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
|
||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
||||
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes.
|
||||
* This class is the single source of truth for the game state.
|
||||
* All user interactions must go through this engine via Commands, and all state changes
|
||||
* are communicated to observers via GameEvent notifications.
|
||||
*/
|
||||
class GameEngine(
|
||||
initialBoard: Board = Board.initial,
|
||||
initialHistory: GameHistory = GameHistory.empty,
|
||||
initialTurn: Color = Color.White,
|
||||
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
|
||||
GameController.completePromotion
|
||||
) extends Observable:
|
||||
private var currentBoard: Board = initialBoard
|
||||
private var currentHistory: GameHistory = initialHistory
|
||||
private var currentTurn: Color = initialTurn
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
/** Inner class for tracking pending promotion state */
|
||||
private case class PendingPromotion(
|
||||
from: Square, to: Square,
|
||||
boardBefore: Board, historyBefore: GameHistory,
|
||||
turn: Color
|
||||
)
|
||||
|
||||
/** Current pending promotion, if any */
|
||||
private var pendingPromotion: Option[PendingPromotion] = None
|
||||
|
||||
/** True if a pawn promotion move is pending and needs a piece choice. */
|
||||
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized { currentBoard }
|
||||
def history: GameHistory = synchronized { currentHistory }
|
||||
def turn: Color = synchronized { currentTurn }
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||
|
||||
/** Check if redo is available. */
|
||||
def canRedo: Boolean = synchronized { invoker.canRedo }
|
||||
|
||||
/** Get the command history for inspection (testing/debugging). */
|
||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
|
||||
|
||||
/** Process a raw move input string and update game state if valid.
|
||||
* Notifies all observers of the outcome via GameEvent.
|
||||
*/
|
||||
def processUserInput(rawInput: String): Unit = synchronized {
|
||||
val trimmed = rawInput.trim.toLowerCase
|
||||
trimmed match
|
||||
case "quit" | "q" =>
|
||||
// Client should handle quit logic; we just return
|
||||
()
|
||||
|
||||
case "undo" =>
|
||||
performUndo()
|
||||
|
||||
case "redo" =>
|
||||
performRedo()
|
||||
|
||||
case "draw" =>
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
|
||||
case "" =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Please enter a valid move or command."
|
||||
)
|
||||
notifyObservers(event)
|
||||
|
||||
case moveInput =>
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
))
|
||||
case Some((from, to)) =>
|
||||
handleParsedMove(from, to, moveInput)
|
||||
}
|
||||
|
||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized {
|
||||
performUndo()
|
||||
}
|
||||
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized {
|
||||
performRedo()
|
||||
}
|
||||
|
||||
/** Apply a player's promotion piece choice.
|
||||
* Must only be called when isPendingPromotion is true.
|
||||
*/
|
||||
def completePromotion(piece: PromotionPiece): Unit = synchronized {
|
||||
pendingPromotion match
|
||||
case None =>
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
|
||||
case Some(pending) =>
|
||||
pendingPromotion = None
|
||||
val cmd = MoveCommand(
|
||||
from = pending.from,
|
||||
to = pending.to,
|
||||
previousBoard = Some(pending.boardBefore),
|
||||
previousHistory = Some(pending.historyBefore),
|
||||
previousTurn = Some(pending.turn)
|
||||
)
|
||||
completePromotionFn(
|
||||
pending.boardBefore, pending.historyBefore,
|
||||
pending.from, pending.to, piece, pending.turn
|
||||
) match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case _ =>
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
|
||||
}
|
||||
|
||||
/** Validate and load a PGN string.
|
||||
* Each move is replayed through the command system so undo/redo is available after loading.
|
||||
* Returns Right(()) on success; Left(error) if any move is illegal or the position impossible. */
|
||||
def loadPgn(pgn: String): Either[String, Unit] = synchronized {
|
||||
PgnParser.validatePgn(pgn) match
|
||||
case Left(err) =>
|
||||
Left(err)
|
||||
case Right(game) =>
|
||||
val initialBoardBeforeLoad = currentBoard
|
||||
val initialHistoryBeforeLoad = currentHistory
|
||||
val initialTurnBeforeLoad = currentTurn
|
||||
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
|
||||
var error: Option[String] = None
|
||||
import scala.util.control.Breaks._
|
||||
breakable {
|
||||
game.moves.foreach { move =>
|
||||
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
|
||||
move.promotionPiece.foreach(completePromotion)
|
||||
|
||||
// If the move failed to execute properly, stop and report
|
||||
// (validatePgn should have caught this, but we're being safe)
|
||||
if pendingPromotion.isDefined && move.promotionPiece.isEmpty then
|
||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||
break()
|
||||
}
|
||||
}
|
||||
|
||||
error match
|
||||
case Some(err) =>
|
||||
currentBoard = initialBoardBeforeLoad
|
||||
currentHistory = initialHistoryBeforeLoad
|
||||
currentTurn = initialTurnBeforeLoad
|
||||
Left(err)
|
||||
case None =>
|
||||
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
|
||||
Right(())
|
||||
}
|
||||
|
||||
/** Load an arbitrary board position, clearing all history and undo/redo state. */
|
||||
def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized {
|
||||
currentBoard = board
|
||||
currentHistory = history
|
||||
currentTurn = turn
|
||||
pendingPromotion = None
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||
}
|
||||
|
||||
/** Reset the board to initial position. */
|
||||
def reset(): Unit = synchronized {
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn
|
||||
))
|
||||
}
|
||||
|
||||
// ──── Private Helpers ────
|
||||
|
||||
private def performUndo(): Unit =
|
||||
if invoker.canUndo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
||||
invoker.undo()
|
||||
notifyObservers(MoveUndoneEvent(currentBoard, currentHistory, currentTurn, notation))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
||||
|
||||
private def performRedo(): Unit =
|
||||
if invoker.canRedo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
||||
updateGameState(nb, nh, nt)
|
||||
invoker.redo()
|
||||
val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("")
|
||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveRedoneEvent(currentBoard, currentHistory, currentTurn, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
||||
|
||||
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
|
||||
currentBoard = newBoard
|
||||
currentHistory = newHistory
|
||||
currentTurn = newTurn
|
||||
|
||||
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
|
||||
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
|
||||
notifyObservers(MoveExecutedEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
newTurn,
|
||||
fromSq,
|
||||
toSq,
|
||||
capturedDesc
|
||||
))
|
||||
|
||||
private def handleFailedMove(moveInput: String): Unit =
|
||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
||||
case MoveResult.NoPiece =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"No piece on that square."
|
||||
))
|
||||
case MoveResult.WrongColor =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"That is not your piece."
|
||||
))
|
||||
case MoveResult.IllegalMove =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Illegal move."
|
||||
))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
enum CastleSide:
|
||||
case Kingside, Queenside
|
||||
|
||||
extension (b: Board)
|
||||
def withCastle(color: Color, side: CastleSide): Board =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val kingFrom = Square(File.E, rank)
|
||||
val (kingTo, rookFrom, rookTo) = side match
|
||||
case CastleSide.Kingside =>
|
||||
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
|
||||
case CastleSide.Queenside =>
|
||||
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
|
||||
|
||||
val king = b.pieceAt(kingFrom).get
|
||||
val rook = b.pieceAt(rookFrom).get
|
||||
|
||||
b.removed(kingFrom).removed(rookFrom)
|
||||
.updated(kingTo, king)
|
||||
.updated(rookTo, rook)
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
|
||||
/** Derives castling rights from move history. */
|
||||
object CastlingRightsCalculator:
|
||||
|
||||
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
|
||||
val (kingRow, kingsideRookFile, queensideRookFile) = color match
|
||||
case Color.White => (Rank.R1, File.H, File.A)
|
||||
case Color.Black => (Rank.R8, File.H, File.A)
|
||||
|
||||
// Check if king has moved
|
||||
val kingHasMoved = history.moves.exists: move =>
|
||||
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
|
||||
|
||||
if kingHasMoved then
|
||||
CastlingRights.None
|
||||
else
|
||||
// Check if kingside rook has moved or was captured
|
||||
val kingsideLost = history.moves.exists: move =>
|
||||
move.from == Square(kingsideRookFile, kingRow) ||
|
||||
move.to == Square(kingsideRookFile, kingRow)
|
||||
|
||||
// Check if queenside rook has moved or was captured
|
||||
val queensideLost = history.moves.exists: move =>
|
||||
move.from == Square(queensideRookFile, kingRow) ||
|
||||
move.to == Square(queensideRookFile, kingRow)
|
||||
|
||||
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
object EnPassantCalculator:
|
||||
|
||||
/** Returns the en passant target square if the last move was a double pawn push.
|
||||
* The target is the square the pawn passed through (e.g. e2→e4 yields e3).
|
||||
*/
|
||||
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
|
||||
history.moves.lastOption.flatMap: move =>
|
||||
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
|
||||
val isDoublePush = math.abs(rankDiff) == 2
|
||||
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
|
||||
if isDoublePush && isPawn then
|
||||
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
|
||||
Some(Square(move.to.file, Rank.values(midRankIdx)))
|
||||
else None
|
||||
|
||||
/** True if moving from→to is an en passant capture. */
|
||||
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
|
||||
enPassantTarget(board, history).contains(to) &&
|
||||
math.abs(to.file.ordinal - from.file.ordinal) == 1
|
||||
|
||||
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
|
||||
* White captures upward → captured pawn is one rank below `to`.
|
||||
* Black captures downward → captured pawn is one rank above `to`.
|
||||
*/
|
||||
def capturedPawnSquare(to: Square, color: Color): Square =
|
||||
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
|
||||
Square(to.file, Rank.values(capturedRankIdx))
|
||||
@@ -1,47 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
|
||||
enum CastleSide:
|
||||
case Kingside, Queenside
|
||||
|
||||
case class GameContext(
|
||||
board: Board,
|
||||
whiteCastling: CastlingRights,
|
||||
blackCastling: CastlingRights
|
||||
):
|
||||
def castlingFor(color: Color): CastlingRights =
|
||||
if color == Color.White then whiteCastling else blackCastling
|
||||
|
||||
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
|
||||
if color == Color.White then copy(whiteCastling = rights)
|
||||
else copy(blackCastling = rights)
|
||||
|
||||
object GameContext:
|
||||
/** Convenience constructor for test boards: no castling rights on either side. */
|
||||
def apply(board: Board): GameContext =
|
||||
GameContext(board, CastlingRights.None, CastlingRights.None)
|
||||
|
||||
val initial: GameContext =
|
||||
GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both)
|
||||
|
||||
extension (b: Board)
|
||||
def withCastle(color: Color, side: CastleSide): Board =
|
||||
val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match
|
||||
case (Color.White, CastleSide.Kingside) =>
|
||||
(Square(File.E, Rank.R1), Square(File.G, Rank.R1),
|
||||
Square(File.H, Rank.R1), Square(File.F, Rank.R1))
|
||||
case (Color.White, CastleSide.Queenside) =>
|
||||
(Square(File.E, Rank.R1), Square(File.C, Rank.R1),
|
||||
Square(File.A, Rank.R1), Square(File.D, Rank.R1))
|
||||
case (Color.Black, CastleSide.Kingside) =>
|
||||
(Square(File.E, Rank.R8), Square(File.G, Rank.R8),
|
||||
Square(File.H, Rank.R8), Square(File.F, Rank.R8))
|
||||
case (Color.Black, CastleSide.Queenside) =>
|
||||
(Square(File.E, Rank.R8), Square(File.C, Rank.R8),
|
||||
Square(File.A, Rank.R8), Square(File.D, Rank.R8))
|
||||
val king = Piece(color, PieceType.King)
|
||||
val rook = Piece(color, PieceType.Rook)
|
||||
Board(b.pieces.removed(kingFrom).removed(rookFrom)
|
||||
.updated(kingTo, king).updated(rookTo, rook))
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{PieceType, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
|
||||
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
|
||||
case class HistoryMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide],
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
pieceType: PieceType = PieceType.Pawn,
|
||||
isCapture: Boolean = false
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
||||
*
|
||||
* @param moves moves played so far, oldest first
|
||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
||||
*/
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
||||
|
||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
||||
*/
|
||||
def addMove(move: HistoryMove): GameHistory =
|
||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
||||
|
||||
/** Add a move by coordinates.
|
||||
*
|
||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
||||
*
|
||||
* If neither flag is set the clock increments by 1.
|
||||
*/
|
||||
def addMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
wasPawnMove: Boolean = false,
|
||||
wasCapture: Boolean = false,
|
||||
pieceType: PieceType = PieceType.Pawn
|
||||
): GameHistory =
|
||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece, pieceType, wasCapture), newClock)
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.GameContext
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
@@ -20,17 +20,17 @@ object GameRules:
|
||||
}
|
||||
|
||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
||||
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] =
|
||||
ctx.board.pieces
|
||||
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
|
||||
board.pieces
|
||||
.collect { case (from, piece) if piece.color == color => from }
|
||||
.flatMap { from =>
|
||||
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling
|
||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
||||
.filter { to =>
|
||||
val newBoard =
|
||||
if MoveValidator.isCastle(ctx.board, from, to) then
|
||||
ctx.board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
if MoveValidator.isCastle(board, from, to) then
|
||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
else
|
||||
ctx.board.withMove(from, to)._1
|
||||
board.withMove(from, to)._1
|
||||
!isInCheck(newBoard, color)
|
||||
}
|
||||
.map(to => from -> to)
|
||||
@@ -38,9 +38,9 @@ object GameRules:
|
||||
.toSet
|
||||
|
||||
/** Position status for the side whose turn it is (`color`). */
|
||||
def gameStatus(ctx: GameContext, color: Color): PositionStatus =
|
||||
val moves = legalMoves(ctx, color)
|
||||
val inCheck = isInCheck(ctx.board, color)
|
||||
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
|
||||
val moves = legalMoves(board, history, color)
|
||||
val inCheck = isInCheck(board, color)
|
||||
if moves.isEmpty && inCheck then PositionStatus.Mated
|
||||
else if moves.isEmpty then PositionStatus.Drawn
|
||||
else if inCheck then PositionStatus.InCheck
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.{GameContext, CastleSide}
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
|
||||
object MoveValidator:
|
||||
|
||||
@@ -73,7 +73,7 @@ object MoveValidator:
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
val dir = if color == Color.White then 1 else -1
|
||||
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
|
||||
val startRank = if color == Color.White then Rank.R2.ordinal else Rank.R7.ordinal
|
||||
|
||||
val oneStep = squareAt(fi, ri + dir)
|
||||
|
||||
@@ -126,42 +126,58 @@ object MoveValidator:
|
||||
def castleSide(from: Square, to: Square): CastleSide =
|
||||
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
|
||||
|
||||
def castlingTargets(ctx: GameContext, color: Color): Set[Square] =
|
||||
val rights = ctx.castlingFor(color)
|
||||
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val kingSq = Square(File.E, rank)
|
||||
val enemy = color.opposite
|
||||
|
||||
if ctx.board.pieceAt(kingSq) != Some(Piece(color, PieceType.King)) then return Set.empty
|
||||
if GameRules.isInCheck(ctx.board, color) then return Set.empty
|
||||
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
|
||||
GameRules.isInCheck(board, color) then Set.empty
|
||||
else
|
||||
val kingsideSq = Option.when(
|
||||
rights.kingSide &&
|
||||
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.G, rank))
|
||||
|
||||
var result = Set.empty[Square]
|
||||
val queensideSq = Option.when(
|
||||
rights.queenSide &&
|
||||
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.C, rank))
|
||||
|
||||
if rights.kingSide then
|
||||
val rookSq = Square(File.H, rank)
|
||||
val transit = List(Square(File.F, rank), Square(File.G, rank))
|
||||
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
|
||||
transit.forall(s => ctx.board.pieceAt(s).isEmpty) &&
|
||||
!transit.exists(s => isAttackedBy(ctx.board, s, enemy)) then
|
||||
result += Square(File.G, rank)
|
||||
kingsideSq.toSet ++ queensideSq.toSet
|
||||
|
||||
if rights.queenSide then
|
||||
val rookSq = Square(File.A, rank)
|
||||
val emptySquares = List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank))
|
||||
val transitSqs = List(Square(File.D, rank), Square(File.C, rank))
|
||||
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
|
||||
emptySquares.forall(s => ctx.board.pieceAt(s).isEmpty) &&
|
||||
!transitSqs.exists(s => isAttackedBy(ctx.board, s, enemy)) then
|
||||
result += Square(File.C, rank)
|
||||
|
||||
result
|
||||
|
||||
def legalTargets(ctx: GameContext, from: Square): Set[Square] =
|
||||
ctx.board.pieceAt(from) match
|
||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
||||
board.pieceAt(from) match
|
||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
||||
legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color)
|
||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
||||
case Some(piece) if piece.pieceType == PieceType.Pawn =>
|
||||
pawnTargets(board, history, from, piece.color)
|
||||
case _ =>
|
||||
legalTargets(ctx.board, from)
|
||||
legalTargets(board, from)
|
||||
|
||||
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean =
|
||||
legalTargets(ctx, from).contains(to)
|
||||
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
|
||||
val existing = pawnTargets(board, from, color)
|
||||
val fi = from.file.ordinal
|
||||
val ri = from.rank.ordinal
|
||||
val dir = if color == Color.White then 1 else -1
|
||||
val epCapture: Set[Square] =
|
||||
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
|
||||
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
|
||||
.toSet
|
||||
existing ++ epCapture
|
||||
|
||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
legalTargets(board, history, from).contains(to)
|
||||
|
||||
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
|
||||
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from) match
|
||||
case Some(Piece(_, PieceType.Pawn)) =>
|
||||
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
|
||||
(from.rank == Rank.R2 && to.rank == Rank.R1)
|
||||
case _ => false
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.{CastlingRights, GameState}
|
||||
import de.nowchess.api.board.Color
|
||||
|
||||
object FenExporter:
|
||||
|
||||
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
|
||||
def boardToFen(board: Board): String =
|
||||
Rank.values.reverse
|
||||
.map(rank => buildRankString(board, rank))
|
||||
.mkString("/")
|
||||
|
||||
/** Build the FEN representation for a single rank. */
|
||||
private def buildRankString(board: Board, rank: Rank): String =
|
||||
val rankSquares = File.values.map(file => Square(file, rank))
|
||||
val rankChars = scala.collection.mutable.ListBuffer[Char]()
|
||||
var emptyCount = 0
|
||||
|
||||
for square <- rankSquares do
|
||||
board.pieceAt(square) match
|
||||
case Some(piece) =>
|
||||
if emptyCount > 0 then
|
||||
rankChars += emptyCount.toString.charAt(0)
|
||||
emptyCount = 0
|
||||
rankChars += pieceToPgnChar(piece)
|
||||
case None =>
|
||||
emptyCount += 1
|
||||
|
||||
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
|
||||
rankChars.mkString
|
||||
|
||||
/** Convert a GameState to a complete FEN string. */
|
||||
def gameStateToFen(state: GameState): String =
|
||||
val piecePlacement = state.piecePlacement
|
||||
val activeColor = if state.activeColor == Color.White then "w" else "b"
|
||||
val castling = castlingString(state.castlingWhite, state.castlingBlack)
|
||||
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
|
||||
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
|
||||
|
||||
/** Convert castling rights to FEN notation. */
|
||||
private def castlingString(white: CastlingRights, black: CastlingRights): String =
|
||||
val wk = if white.kingSide then "K" else ""
|
||||
val wq = if white.queenSide then "Q" else ""
|
||||
val bk = if black.kingSide then "k" else ""
|
||||
val bq = if black.queenSide then "q" else ""
|
||||
val result = s"$wk$wq$bk$bq"
|
||||
if result.isEmpty then "-" else result
|
||||
|
||||
/** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */
|
||||
private def pieceToPgnChar(piece: Piece): Char =
|
||||
val base = piece.pieceType match
|
||||
case PieceType.Pawn => 'p'
|
||||
case PieceType.Knight => 'n'
|
||||
case PieceType.Bishop => 'b'
|
||||
case PieceType.Rook => 'r'
|
||||
case PieceType.Queen => 'q'
|
||||
case PieceType.King => 'k'
|
||||
if piece.color == Color.White then base.toUpper else base
|
||||
@@ -0,0 +1,103 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
|
||||
|
||||
object FenParser:
|
||||
|
||||
/** Parse a complete FEN string into a GameState.
|
||||
* Returns None if the format is invalid. */
|
||||
def parseFen(fen: String): Option[GameState] =
|
||||
val parts = fen.trim.split("\\s+")
|
||||
Option.when(parts.length == 6)(parts).flatMap: parts =>
|
||||
for
|
||||
_ <- parseBoard(parts(0))
|
||||
activeColor <- parseColor(parts(1))
|
||||
castlingRights <- parseCastling(parts(2))
|
||||
enPassant <- parseEnPassant(parts(3))
|
||||
halfMoveClock <- parts(4).toIntOption
|
||||
fullMoveNumber <- parts(5).toIntOption
|
||||
if halfMoveClock >= 0 && fullMoveNumber >= 1
|
||||
yield GameState(
|
||||
piecePlacement = parts(0),
|
||||
activeColor = activeColor,
|
||||
castlingWhite = castlingRights._1,
|
||||
castlingBlack = castlingRights._2,
|
||||
enPassantTarget = enPassant,
|
||||
halfMoveClock = halfMoveClock,
|
||||
fullMoveNumber = fullMoveNumber,
|
||||
status = GameStatus.InProgress
|
||||
)
|
||||
|
||||
/** Parse active color ("w" or "b"). */
|
||||
private def parseColor(s: String): Option[Color] =
|
||||
if s == "w" then Some(Color.White)
|
||||
else if s == "b" then Some(Color.Black)
|
||||
else None
|
||||
|
||||
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
|
||||
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
|
||||
if s == "-" then
|
||||
Some((CastlingRights.None, CastlingRights.None))
|
||||
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
|
||||
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
|
||||
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
|
||||
Some((white, black))
|
||||
else
|
||||
None
|
||||
|
||||
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
|
||||
private def parseEnPassant(s: String): Option[Option[Square]] =
|
||||
if s == "-" then Some(None)
|
||||
else Square.fromAlgebraic(s).map(Some(_))
|
||||
|
||||
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
|
||||
* Returns None if the format is invalid. */
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
val rankStrings = fen.split("/", -1)
|
||||
if rankStrings.length != 8 then None
|
||||
else
|
||||
// Parse each rank, collecting all (Square, Piece) pairs or failing on the first error
|
||||
val parsedRanks: Option[List[List[(Square, Piece)]]] =
|
||||
rankStrings.zipWithIndex.foldLeft(Option(List.empty[List[(Square, Piece)]])):
|
||||
case (None, _) => None
|
||||
case (Some(acc), (rankStr, rankIdx)) =>
|
||||
val rank = Rank.values(7 - rankIdx) // ranks go 8→1, so reverse
|
||||
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
|
||||
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
|
||||
|
||||
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
|
||||
* Returns None if the rank string contains invalid characters or the wrong number of files. */
|
||||
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
|
||||
var fileIdx = 0
|
||||
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
|
||||
var failed = false
|
||||
|
||||
for c <- rankStr if !failed do
|
||||
if fileIdx > 7 then
|
||||
failed = true
|
||||
else if c.isDigit then
|
||||
fileIdx += c.asDigit
|
||||
else
|
||||
charToPiece(c) match
|
||||
case None => failed = true
|
||||
case Some(piece) =>
|
||||
val file = File.values(fileIdx)
|
||||
squares += (Square(file, rank) -> piece)
|
||||
fileIdx += 1
|
||||
|
||||
if failed || fileIdx != 8 then None
|
||||
else Some(squares.toList)
|
||||
|
||||
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
|
||||
private def charToPiece(c: Char): Option[Piece] =
|
||||
val color = if Character.isUpperCase(c) then Color.White else Color.Black
|
||||
val pieceTypeOpt = c.toLower match
|
||||
case 'p' => Some(PieceType.Pawn)
|
||||
case 'n' => Some(PieceType.Knight)
|
||||
case 'b' => Some(PieceType.Bishop)
|
||||
case 'r' => Some(PieceType.Rook)
|
||||
case 'q' => Some(PieceType.Queen)
|
||||
case 'k' => Some(PieceType.King)
|
||||
case _ => None
|
||||
pieceTypeOpt.map(pt => Piece(color, pt))
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.{PieceType, *}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
|
||||
|
||||
object PgnExporter:
|
||||
|
||||
/** Export a game with headers and history to PGN format. */
|
||||
def exportGame(headers: Map[String, String], history: GameHistory): String =
|
||||
val headerLines = headers.map { case (key, value) =>
|
||||
s"""[$key "$value"]"""
|
||||
}.mkString("\n")
|
||||
|
||||
val moveText = if history.moves.isEmpty then ""
|
||||
else
|
||||
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
|
||||
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
|
||||
val moveNum = moveNumber + 1
|
||||
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
else s"$headerLines\n\n$moveText"
|
||||
|
||||
/** Convert a HistoryMove to Standard Algebraic Notation. */
|
||||
def moveToAlgebraic(move: HistoryMove): String =
|
||||
move.castleSide match
|
||||
case Some(CastleSide.Kingside) => "O-O"
|
||||
case Some(CastleSide.Queenside) => "O-O-O"
|
||||
case None =>
|
||||
val dest = move.to.toString
|
||||
val capStr = if move.isCapture then "x" else ""
|
||||
val promSuffix = move.promotionPiece match
|
||||
case Some(PromotionPiece.Queen) => "=Q"
|
||||
case Some(PromotionPiece.Rook) => "=R"
|
||||
case Some(PromotionPiece.Bishop) => "=B"
|
||||
case Some(PromotionPiece.Knight) => "=N"
|
||||
case None => ""
|
||||
move.pieceType match
|
||||
case PieceType.Pawn =>
|
||||
if move.isCapture then s"${move.from.file.toString.toLowerCase}x$dest$promSuffix"
|
||||
else s"$dest$promSuffix"
|
||||
case PieceType.Knight => s"N$capStr$dest$promSuffix"
|
||||
case PieceType.Bishop => s"B$capStr$dest$promSuffix"
|
||||
case PieceType.Rook => s"R$capStr$dest$promSuffix"
|
||||
case PieceType.Queen => s"Q$capStr$dest$promSuffix"
|
||||
case PieceType.King => s"K$capStr$dest$promSuffix"
|
||||
@@ -0,0 +1,267 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
|
||||
|
||||
/** A parsed PGN game containing headers and the resolved move list. */
|
||||
case class PgnGame(
|
||||
headers: Map[String, String],
|
||||
moves: List[HistoryMove]
|
||||
)
|
||||
|
||||
object PgnParser:
|
||||
|
||||
/** Strictly validate a PGN text.
|
||||
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
|
||||
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
|
||||
def validatePgn(pgn: String): Either[String, PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
|
||||
|
||||
/** Parse a complete PGN text into a PgnGame with headers and moves.
|
||||
* Always succeeds (returns Some); malformed tokens are silently skipped. */
|
||||
def parsePgn(pgn: String): Option[PgnGame] =
|
||||
val lines = pgn.split("\n").map(_.trim)
|
||||
val (headerLines, rest) = lines.span(_.startsWith("["))
|
||||
|
||||
val headers = parseHeaders(headerLines)
|
||||
val moveText = rest.mkString(" ")
|
||||
val moves = parseMovesText(moveText)
|
||||
|
||||
Some(PgnGame(headers, moves))
|
||||
|
||||
/** Parse PGN header lines of the form [Key "Value"]. */
|
||||
private def parseHeaders(lines: Array[String]): Map[String, String] =
|
||||
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
|
||||
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
|
||||
|
||||
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
|
||||
private def parseMovesText(moveText: String): List[HistoryMove] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
|
||||
// Fold over tokens, threading (board, history, currentColor, accumulator)
|
||||
val (_, _, _, moves) = tokens.foldLeft(
|
||||
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
|
||||
):
|
||||
case (state @ (board, history, color, acc), token) =>
|
||||
// Skip move-number markers (e.g. "1.", "2.") and result tokens
|
||||
if isMoveNumberOrResult(token) then state
|
||||
else
|
||||
parseAlgebraicMove(token, board, history, color) match
|
||||
case None => state // unrecognised token — skip silently
|
||||
case Some(move) =>
|
||||
val newBoard = applyMoveToBoard(board, move, color)
|
||||
val newHistory = history.addMove(move)
|
||||
(newBoard, newHistory, color.opposite, acc :+ move)
|
||||
|
||||
moves
|
||||
|
||||
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
|
||||
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
|
||||
move.castleSide match
|
||||
case Some(side) => board.withCastle(color, side)
|
||||
case None =>
|
||||
val (boardAfterMove, _) = board.withMove(move.from, move.to)
|
||||
move.promotionPiece match
|
||||
case Some(pp) =>
|
||||
val pieceType = pp match
|
||||
case PromotionPiece.Queen => PieceType.Queen
|
||||
case PromotionPiece.Rook => PieceType.Rook
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
boardAfterMove.updated(move.to, Piece(color, pieceType))
|
||||
case None => boardAfterMove
|
||||
|
||||
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
|
||||
private def isMoveNumberOrResult(token: String): Boolean =
|
||||
token.matches("""\d+\.""") ||
|
||||
token == "*" ||
|
||||
token == "1-0" ||
|
||||
token == "0-1" ||
|
||||
token == "1/2-1/2"
|
||||
|
||||
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
|
||||
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside), pieceType = PieceType.King))
|
||||
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside), pieceType = PieceType.King))
|
||||
|
||||
case _ =>
|
||||
parseRegularMove(notation, board, history, color)
|
||||
|
||||
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
|
||||
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
// The destination square is always the last two characters
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap: toSquare =>
|
||||
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
|
||||
|
||||
// Determine required piece type: upper-case first char = piece letter; else pawn
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
// Collect the disambiguation hint that remains after stripping the piece letter
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig // hint is file/rank info or empty
|
||||
|
||||
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
|
||||
// We prefer pieces that can actually reach the target; if none can (positionally illegal
|
||||
// PGN input), fall back to any piece of the matching type belonging to `color`.
|
||||
val reachable: Set[Square] =
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color &&
|
||||
MoveValidator.legalTargets(board, from).contains(toSquare) => from
|
||||
}.toSet
|
||||
|
||||
val candidates: Set[Square] =
|
||||
if reachable.nonEmpty then reachable
|
||||
else
|
||||
// Fallback for positionally-illegal but syntactically valid PGN notation:
|
||||
// find any piece of `color` with the correct piece type on the board.
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color => from
|
||||
}.toSet
|
||||
|
||||
// Filter by required piece type
|
||||
val byPiece = candidates.filter(from =>
|
||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
||||
)
|
||||
|
||||
// Apply disambiguation hint (file letter or rank digit)
|
||||
val disambiguated =
|
||||
if hint.isEmpty then byPiece
|
||||
else byPiece.filter(from => matchesHint(from, hint))
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
||||
val moveIsCapture = notation.contains('x')
|
||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
||||
|
||||
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
|
||||
private def matchesHint(sq: Square, hint: String): Boolean =
|
||||
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
|
||||
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
|
||||
else true)
|
||||
|
||||
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
|
||||
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
|
||||
val promotionPattern = """=([A-Z])""".r
|
||||
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
|
||||
m.group(1) match
|
||||
case "Q" => Some(PromotionPiece.Queen)
|
||||
case "R" => Some(PromotionPiece.Rook)
|
||||
case "B" => Some(PromotionPiece.Bishop)
|
||||
case "N" => Some(PromotionPiece.Knight)
|
||||
case _ => None
|
||||
}
|
||||
|
||||
/** Convert a piece-letter character to a PieceType. */
|
||||
private def charToPieceType(c: Char): Option[PieceType] =
|
||||
c match
|
||||
case 'N' => Some(PieceType.Knight)
|
||||
case 'B' => Some(PieceType.Bishop)
|
||||
case 'R' => Some(PieceType.Rook)
|
||||
case 'Q' => Some(PieceType.Queen)
|
||||
case 'K' => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
// ── Strict validation helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
|
||||
private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] =
|
||||
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
|
||||
tokens.foldLeft(Right((Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])): Either[String, (Board, GameHistory, Color, List[HistoryMove])]) {
|
||||
case (acc, token) =>
|
||||
acc.flatMap { case (board, history, color, moves) =>
|
||||
if isMoveNumberOrResult(token) then Right((board, history, color, moves))
|
||||
else
|
||||
strictParseAlgebraicMove(token, board, history, color) match
|
||||
case None => Left(s"Illegal or impossible move: '$token'")
|
||||
case Some(move) =>
|
||||
val newBoard = applyMoveToBoard(board, move, color)
|
||||
val newHistory = history.addMove(move)
|
||||
Right((newBoard, newHistory, color.opposite, moves :+ move))
|
||||
}
|
||||
}.map(_._4)
|
||||
|
||||
/** Strict algebraic move parse — no fallback to positionally-illegal moves. */
|
||||
private def strictParseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
notation match
|
||||
case "O-O" | "O-O+" | "O-O#" =>
|
||||
val dest = Square(File.G, rank)
|
||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Kingside), pieceType = PieceType.King)
|
||||
)
|
||||
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
|
||||
val dest = Square(File.C, rank)
|
||||
Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))(
|
||||
HistoryMove(Square(File.E, rank), dest, Some(CastleSide.Queenside), pieceType = PieceType.King)
|
||||
)
|
||||
case _ =>
|
||||
strictParseRegularMove(notation, board, history, color)
|
||||
|
||||
/** Strict regular move parse — uses only legally reachable squares, no fallback. */
|
||||
private def strictParseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
|
||||
val clean = notation
|
||||
.replace("+", "")
|
||||
.replace("#", "")
|
||||
.replace("x", "")
|
||||
.replaceAll("=[NBRQ]$", "")
|
||||
|
||||
if clean.length < 2 then None
|
||||
else
|
||||
val destStr = clean.takeRight(2)
|
||||
Square.fromAlgebraic(destStr).flatMap { toSquare =>
|
||||
val disambig = clean.dropRight(2)
|
||||
|
||||
val requiredPieceType: Option[PieceType] =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
|
||||
else if clean.head.isUpper then charToPieceType(clean.head)
|
||||
else Some(PieceType.Pawn)
|
||||
|
||||
val hint =
|
||||
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
|
||||
else disambig
|
||||
|
||||
// Strict: only squares from which a legal move (including en passant/castling awareness) exists.
|
||||
val reachable: Set[Square] =
|
||||
board.pieces.collect {
|
||||
case (from, piece) if piece.color == color &&
|
||||
MoveValidator.legalTargets(board, history, from).contains(toSquare) => from
|
||||
}.toSet
|
||||
|
||||
val byPiece = reachable.filter(from =>
|
||||
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
|
||||
)
|
||||
|
||||
val disambiguated =
|
||||
if hint.isEmpty then byPiece
|
||||
else byPiece.filter(from => matchesHint(from, hint))
|
||||
|
||||
val promotion = extractPromotion(notation)
|
||||
val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn)
|
||||
val moveIsCapture = notation.contains('x')
|
||||
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Square}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
/** Base trait for all game state events.
|
||||
* Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
sealed trait GameEvent:
|
||||
def board: Board
|
||||
def history: GameHistory
|
||||
def turn: Color
|
||||
|
||||
/** Fired when a move is successfully executed. */
|
||||
case class MoveExecutedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the current player is in check. */
|
||||
case class CheckDetectedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches checkmate. */
|
||||
case class CheckmateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
winner: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches stalemate. */
|
||||
case class StalemateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is invalid. */
|
||||
case class InvalidMoveEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
reason: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
|
||||
case class PromotionRequiredEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
from: Square,
|
||||
to: Square
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
|
||||
case class MoveUndoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
pgnNotation: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
||||
case class MoveRedoneEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
pgnNotation: String,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||
case class PgnLoadedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
/** Observable trait: manages observers and notifies them of events. */
|
||||
trait Observable:
|
||||
private val observers = scala.collection.mutable.Set[Observer]()
|
||||
|
||||
/** Register an observer to receive game events. */
|
||||
def subscribe(observer: Observer): Unit = synchronized {
|
||||
observers += observer
|
||||
}
|
||||
|
||||
/** Unregister an observer. */
|
||||
def unsubscribe(observer: Observer): Unit = synchronized {
|
||||
observers -= observer
|
||||
}
|
||||
|
||||
/** Notify all observers of a game event. */
|
||||
protected def notifyObservers(event: GameEvent): Unit = synchronized {
|
||||
observers.foreach(_.onGameEvent(event))
|
||||
}
|
||||
|
||||
/** Return current list of observers (for testing). */
|
||||
def observerCount: Int = synchronized {
|
||||
observers.size
|
||||
}
|
||||
@@ -11,21 +11,18 @@ object Renderer:
|
||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||
|
||||
def render(board: Board): String =
|
||||
val sb = new StringBuilder
|
||||
sb.append(" a b c d e f g h\n")
|
||||
for rank <- (0 until 8).reverse do
|
||||
sb.append(s"${rank + 1} ")
|
||||
for file <- 0 until 8 do
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
val cellContent = board.pieceAt(sq) match
|
||||
val rows = (0 until 8).reverse.map { rank =>
|
||||
val cells = (0 until 8).map { file =>
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
board.pieceAt(sq) match
|
||||
case Some(piece) =>
|
||||
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||
case None =>
|
||||
s"$bgColor $AnsiReset"
|
||||
sb.append(cellContent)
|
||||
sb.append(s" ${rank + 1}\n")
|
||||
sb.append(" a b c d e f g h\n")
|
||||
sb.toString
|
||||
}.mkString
|
||||
s"${rank + 1} $cells ${rank + 1}"
|
||||
}.mkString("\n")
|
||||
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
// ──── Helper: Command that always fails ────
|
||||
private case class FailingCommand() extends Command:
|
||||
override def execute(): Boolean = false
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Failing command"
|
||||
|
||||
// ──── Helper: Command that conditionally fails on undo or execute ────
|
||||
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
|
||||
override def execute(): Boolean = !shouldFailOnExecute
|
||||
override def undo(): Boolean = !shouldFailOnUndo
|
||||
override def description: String = "Conditional fail"
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd
|
||||
|
||||
// ──── BRANCH: execute() returns false ────
|
||||
test("CommandInvoker.execute() with failing command returns false"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = FailingCommand()
|
||||
invoker.execute(cmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.execute() does not add failed command to history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val failingCmd = FailingCommand()
|
||||
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(failingCmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
|
||||
invoker.execute(successCmd) shouldBe true
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.history(0) shouldBe successCmd
|
||||
|
||||
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
|
||||
test("CommandInvoker.undo() returns false when currentIndex < 0"):
|
||||
val invoker = new CommandInvoker()
|
||||
// currentIndex starts at -1
|
||||
invoker.undo() shouldBe false
|
||||
|
||||
test("CommandInvoker.undo() returns false when empty history"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.canUndo shouldBe false
|
||||
invoker.undo() shouldBe false
|
||||
|
||||
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
|
||||
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex now = 1, history.size = 2
|
||||
|
||||
invoker.undo() // currentIndex becomes 0
|
||||
invoker.undo() // currentIndex becomes -1
|
||||
invoker.undo() // currentIndex still -1, should fail
|
||||
|
||||
// ──── BRANCH: undo() command returns false ────
|
||||
test("CommandInvoker.undo() returns false when command.undo() fails"):
|
||||
val invoker = new CommandInvoker()
|
||||
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
|
||||
|
||||
invoker.execute(failingCmd) shouldBe true
|
||||
invoker.canUndo shouldBe true
|
||||
|
||||
invoker.undo() shouldBe false
|
||||
// Index should not change when undo fails
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
|
||||
val invoker = new CommandInvoker()
|
||||
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
|
||||
|
||||
invoker.execute(successCmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
|
||||
test("CommandInvoker.redo() returns false when nothing to redo"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.redo() shouldBe false
|
||||
|
||||
test("CommandInvoker.redo() returns false when at end of history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(cmd)
|
||||
// currentIndex = 0, history.size = 1
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
|
||||
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.redo() shouldBe false
|
||||
|
||||
// ──── BRANCH: redo() command returns false ────
|
||||
test("CommandInvoker.redo() returns false when command.execute() fails"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
||||
|
||||
invoker.undo()
|
||||
// currentIndex = 0, redoFailCmd is at index 1
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
// Now modify to fail on next execute (redo)
|
||||
redoFailCmd.shouldFailOnExecute = true
|
||||
invoker.redo() shouldBe false
|
||||
// currentIndex should not change
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
// ──── BRANCH: execute() with redo history discarding (while loop) ────
|
||||
test("CommandInvoker.execute() discards redo history via while loop"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2
|
||||
|
||||
invoker.undo()
|
||||
// currentIndex = 0, size = 2
|
||||
// Redo history exists: cmd2 is at index 1
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
invoker.execute(cmd3)
|
||||
// while loop should discard cmd2
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.history(1) shouldBe cmd3
|
||||
|
||||
test("CommandInvoker.execute() discards multiple redo commands"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.execute(cmd3)
|
||||
invoker.execute(cmd4)
|
||||
// currentIndex = 3, size = 4
|
||||
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
// currentIndex = 1, size = 4
|
||||
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
|
||||
invoker.execute(newCmd)
|
||||
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
|
||||
invoker.history.size shouldBe 3
|
||||
invoker.canRedo shouldBe false
|
||||
|
||||
// ──── BRANCH: execute() with no redo history to discard ────
|
||||
test("CommandInvoker.execute() with no redo history (while condition false)"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
// currentIndex = 1, size = 2
|
||||
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
|
||||
|
||||
invoker.canRedo shouldBe false
|
||||
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
||||
invoker.history.size shouldBe 3
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandInvokerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||
MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
|
||||
test("CommandInvoker executes a command and adds it to history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker executes multiple commands in sequence"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
invoker.execute(cmd1) shouldBe true
|
||||
invoker.execute(cmd2) shouldBe true
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.getCurrentIndex shouldBe 1
|
||||
|
||||
test("CommandInvoker.canUndo returns false when empty"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker.canUndo returns true after execution"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.canUndo shouldBe true
|
||||
|
||||
test("CommandInvoker.undo decrements current index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.canRedo returns true after undo"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
test("CommandInvoker.redo re-executes a command"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker clear removes all history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.clear()
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker discards all history when executing after undoing all"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
invoker.undo()
|
||||
// After undoing twice, we're at the beginning (before any commands)
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
invoker.canRedo shouldBe true
|
||||
// Executing a new command from the beginning discards all redo history
|
||||
invoker.execute(cmd3)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.history(0) shouldBe cmd3
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker discards redo history when executing mid-history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(cmd2)
|
||||
invoker.undo()
|
||||
// After one undo, we're at the end of cmd1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
invoker.canRedo shouldBe true
|
||||
// Executing a new command discards cmd2 (the redo history)
|
||||
invoker.execute(cmd3)
|
||||
invoker.canRedo shouldBe false
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.history(0) shouldBe cmd1
|
||||
invoker.history(1) shouldBe cmd3
|
||||
invoker.getCurrentIndex shouldBe 1
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.collection.mutable
|
||||
|
||||
class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
private def createMoveCommand(from: Square, to: Square): MoveCommand =
|
||||
MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
||||
val invoker = new CommandInvoker()
|
||||
@volatile var raceDetected = false
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
|
||||
// Thread 1: executes commands
|
||||
val executorThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for i <- 1 to 1000 do
|
||||
val cmd = createMoveCommand(
|
||||
sq(File.E, Rank.R2),
|
||||
sq(File.E, Rank.R4)
|
||||
)
|
||||
invoker.execute(cmd)
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: reads history during execution
|
||||
val readerThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 1000 do
|
||||
val _ = invoker.history
|
||||
val _ = invoker.getCurrentIndex
|
||||
Thread.sleep(0) // Yield to increase contention
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
readerThread.start()
|
||||
executorThread.join()
|
||||
readerThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
|
||||
val invoker = new CommandInvoker()
|
||||
@volatile var raceDetected = false
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
|
||||
// Pre-populate with some commands
|
||||
for _ <- 1 to 5 do
|
||||
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
// Thread 1: executes new commands
|
||||
val executorThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: undoes commands
|
||||
val undoThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
if invoker.canUndo then
|
||||
invoker.undo()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 3: redoes commands
|
||||
val redoThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
if invoker.canRedo then
|
||||
invoker.redo()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
undoThread.start()
|
||||
redoThread.start()
|
||||
executorThread.join()
|
||||
undoThread.join()
|
||||
redoThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("QuitCommand can be created"):
|
||||
val cmd = QuitCommand()
|
||||
cmd shouldNot be(null)
|
||||
|
||||
test("QuitCommand execute returns true"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.execute() shouldBe true
|
||||
|
||||
test("QuitCommand undo returns false (cannot undo quit)"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("QuitCommand description"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.description shouldBe "Quit game"
|
||||
|
||||
test("ResetCommand with no prior state"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("ResetCommand with prior state can undo"):
|
||||
val cmd = ResetCommand(
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe true
|
||||
|
||||
test("ResetCommand with partial state cannot undo"):
|
||||
val cmd = ResetCommand(
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = None, // missing
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("ResetCommand description"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.description shouldBe "Reset board"
|
||||
|
||||