Compare commits
27 Commits
62e180c6d9
...
api-0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9eb471de | |||
| 45013c87a9 | |||
| 80518719d5 | |||
| 2d6ead7e47 | |||
| 3ff80318b4 | |||
| 9fb743d135 | |||
| 412ed986a9 | |||
| 8bbeead702 | |||
| e5e20c566e | |||
| 13bfc16cfe | |||
| 85cbf95c18 | |||
| 1361dfc895 | |||
| 707c4826a4 | |||
| 919beb3b4b | |||
| ee79dc5b98 | |||
| f28e69dc18 | |||
| 5f485fed9b | |||
| f4c18d22d7 | |||
| 4d800e88eb | |||
| 2df2fdeeb9 | |||
| 9190d1e5a0 | |||
| d675966436 | |||
| b4116e9a82 | |||
| 11efb1a42d | |||
| 00d326c1ba | |||
| d0289e16f4 | |||
| 7b1f8b1176 |
@@ -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 with JUnitSuiteLike` — 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`
|
||||
@@ -23,7 +23,7 @@ report findings to team-leader, who re-invokes scala-implementer for fixes.
|
||||
- `@QuarkusTest` methods (JUnit 5) must be explicitly typed `: Unit`
|
||||
|
||||
### Tests
|
||||
- Unit tests must extend `AnyFunSuite with Matchers with JUnitSuiteLike`, not plain JUnit 5
|
||||
- Unit tests must extend `AnyFunSuite with Matchers`
|
||||
- Integration tests use `@QuarkusTest` with JUnit 5 `@Test` methods
|
||||
- No raw `@Test` annotations on plain unit test classes
|
||||
|
||||
|
||||
@@ -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,7 +6,17 @@
|
||||
<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="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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 with JUnitSuiteLike`** — 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 with JUnitSuiteLike` 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
|
||||
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
|
||||
else
|
||||
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
|
||||
fi
|
||||
@@ -1,43 +0,0 @@
|
||||
# ADR-001: Technology Stack Selection
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The "NowChessSystems" project requires a modern, scalable,
|
||||
and maintainable technology stack to support web-based interfaces.
|
||||
The system is designed as a microservice architecture to allow for independent scaling and development of various components (e.g., engine, matchmaking, user management).
|
||||
|
||||
## Decision
|
||||
We have decided to use the following technologies for the core system:
|
||||
|
||||
### Backend
|
||||
- **Language:** [Scala 3](https://scala-lang.org/) for its powerful type system, functional programming capabilities, and seamless JVM integration.
|
||||
- **Framework:** [Quarkus](https://quarkus.io/) with the `io.quarkiverse.scala:quarkus-scala3` extension to leverage GraalVM native compilation and fast startup times.
|
||||
- **Persistence:** [Hibernate](https://hibernate.org/) and [Jakarta Persistence](https://jakarta.ee/specifications/persistence/) for standard-based ORM.
|
||||
|
||||
### Frontend
|
||||
- **Build Tool:** [Vite](https://vitejs.dev/) for a fast development experience.
|
||||
- **Framework:** TBD (Evaluation between React, Angular, and Vue).
|
||||
- **Terminal UI:** [Lanterna](https://github.com/mabe02/lanterna) for a text-based user interface (TUI).
|
||||
|
||||
### DevOps & Infrastructure
|
||||
- **Orchestration:** [Kubernetes](https://kubernetes.io/) for container orchestration.
|
||||
- **GitOps & Delivery:** [ArgoCD](https://argoproj.github.io/cd/) for continuous delivery and [Kargo](https://kargo.io/) for multi-stage lifecycle management.
|
||||
|
||||
### AI-Assisted Development
|
||||
- [Claude Code Pro](https://claude.ai/) and [Claude Agent Teams](https://claude.ai/team) for coding and reviews.
|
||||
- [Google Stitch](https://stitch.google.com/) (Free) for UI design and prototyping.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **High Performance:** Quarkus and GraalVM enable low memory footprint and fast startup.
|
||||
- **Developer Productivity:** Scala 3 and AI tools provide a high-level, expressive environment.
|
||||
- **Robustness:** Kubernetes and ArgoCD ensure reliable deployment and scaling.
|
||||
- **Accessibility:** Offering both a TUI and a web interface caters to different user preferences.
|
||||
|
||||
### Negative / Risks
|
||||
- **Complexity:** Managing a microservices architecture with Kubernetes adds operational overhead.
|
||||
- **Learning Curve:** Scala 3 and the specific Quarkus-Scala integration may require training for new developers.
|
||||
- **Consistency:** Maintaining parity between the TUI and Web frontend functionality.
|
||||
@@ -1,86 +0,0 @@
|
||||
# ADR-002: Shared-Models Library (`modules/api`)
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
NowChessSystems is a microservice platform. As soon as two or more services need to
|
||||
exchange data — whether through REST, messaging, or internal function calls — they must
|
||||
agree on common data types. Without a shared home for those types, the same case class
|
||||
(e.g. `Square`, `Move`, `GameState`) is duplicated in every module, diverges over time,
|
||||
and causes silent serialisation mismatches at runtime.
|
||||
|
||||
The `core` module currently owns the chess engine logic. Future modules (matchmaking,
|
||||
game history, user management, notation export, etc.) will all need to refer to the
|
||||
same chess domain vocabulary. A cross-cutting place to hold that vocabulary is therefore
|
||||
required before any second service is built.
|
||||
|
||||
## Decision
|
||||
|
||||
We introduce `modules/api` as a **shared-models library**: a plain Scala 3 library
|
||||
(no Quarkus, no Jakarta, no persistence) that contains only:
|
||||
|
||||
- Pure Scala 3 data types: `case class`, `sealed trait`, and `enum` definitions
|
||||
- Value objects that model the chess domain (pieces, colors, squares, moves, game state)
|
||||
- Cross-service API envelope types (`ApiResponse[A]`, `ApiError`, `Pagination`)
|
||||
- Minimal player/user identity stubs (IDs and display names only)
|
||||
|
||||
Every service module that needs these types declares:
|
||||
|
||||
```kotlin
|
||||
implementation(project(":modules:api"))
|
||||
```
|
||||
|
||||
in its own `build.gradle.kts`. The `modules/api` module itself carries no runtime
|
||||
dependencies beyond the Scala 3 standard library.
|
||||
|
||||
### Package layout
|
||||
|
||||
```
|
||||
de.nowchess.api
|
||||
├── board – Color, PieceType, Piece, File, Rank, Square
|
||||
├── game – CastlingRights, GameState, GameResult, GameStatus
|
||||
├── move – MoveType, Move, PromotionPiece
|
||||
├── player – PlayerId, PlayerInfo
|
||||
└── response – ApiResponse, ApiError, Pagination
|
||||
```
|
||||
|
||||
## What belongs in `modules/api`
|
||||
|
||||
| Belongs | Does NOT belong |
|
||||
|---|---|
|
||||
| `case class`, `sealed trait`, `enum` for chess domain | Quarkus `@ApplicationScoped` beans |
|
||||
| API envelope types (`ApiResponse`, `ApiError`) | Jakarta Persistence entities (`@Entity`) |
|
||||
| Player identity stubs (ID + display name) | REST resource classes |
|
||||
| FEN/board-state representation types | Business logic, engine algorithms |
|
||||
| Pure type aliases and value objects | Database queries or repositories |
|
||||
|
||||
The rule of thumb: if a type carries a framework annotation or requires I/O to produce,
|
||||
it does not belong in `modules/api`.
|
||||
|
||||
## How other modules depend on it
|
||||
|
||||
1. `modules/api` is a regular Gradle subproject already declared in `settings.gradle.kts`.
|
||||
2. Consuming modules add `implementation(project(":modules:api"))` — nothing else.
|
||||
3. Because `modules/api` has no Quarkus BOM, consuming modules must not re-export Quarkus
|
||||
transitive dependencies through it.
|
||||
4. If a future module needs JSON serialisation, it adds its own JSON library (e.g.
|
||||
`circe`, `jsoniter-scala`) as a dependency and derives codecs for the shared types
|
||||
there — codec derivation stays out of `modules/api`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Single source of truth for all chess domain vocabulary.
|
||||
- Adding a new microservice requires only one `implementation(project(":modules:api"))`
|
||||
line — no copy-paste of types.
|
||||
- The library is fast to compile (no framework processing) and cheap to test in isolation.
|
||||
- Enforces a strict boundary: if a type needs a framework annotation it is forced into the
|
||||
correct service module.
|
||||
|
||||
### Negative / Risks
|
||||
- Any breaking change to a shared type (rename, field removal) is a cross-cutting change
|
||||
that touches every consuming module simultaneously.
|
||||
- Developers must resist the temptation to add convenience methods or logic to these
|
||||
types; discipline is required to keep the library pure.
|
||||
@@ -1,244 +0,0 @@
|
||||
# ScalaTest + Scoverage Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace JaCoCo with Scoverage and add ScalaTest (with its JUnit 5 bridge) as the test library across all modules.
|
||||
|
||||
**Architecture:** Three build files are modified — the root for shared dependency versions, and each module for plugins, dependencies, and task wiring. No source files are created. The Scoverage Gradle plugin is applied per-module with its version hardcoded inline (Gradle resolves `plugins {}` before `rootProject.extra` is available).
|
||||
|
||||
**Tech Stack:** Scala 3, Gradle (Kotlin DSL), ScalaTest 3.2.19, scalatestplus-junit-5-11 3.2.19.1, Scoverage Gradle plugin 8.1.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `build.gradle.kts` (root) | Add `SCALATEST` and `SCALATESTPLUS_JUNIT5` version entries |
|
||||
| `modules/core/build.gradle.kts` | Replace `jacoco` with `org.scoverage`; swap JUnit deps for ScalaTest; merge two `tasks.test {}` blocks |
|
||||
| `modules/api/build.gradle.kts` | Same as core; also add missing `useJUnitPlatform()` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add ScalaTest version entries to root build
|
||||
|
||||
**Files:**
|
||||
- Modify: `build.gradle.kts` (root)
|
||||
|
||||
- [ ] **Step 1: Add version entries**
|
||||
|
||||
Open `build.gradle.kts` at the root. The `versions` map currently looks like:
|
||||
|
||||
```kotlin
|
||||
val versions = mapOf(
|
||||
"QUARKUS_SCALA3" to "1.0.0",
|
||||
"SCALA3" to "3.5.1",
|
||||
"SCALA_LIBRARY" to "2.13.18"
|
||||
)
|
||||
```
|
||||
|
||||
Add two entries so it becomes:
|
||||
|
||||
```kotlin
|
||||
val versions = mapOf(
|
||||
"QUARKUS_SCALA3" to "1.0.0",
|
||||
"SCALA3" to "3.5.1",
|
||||
"SCALA_LIBRARY" to "2.13.18",
|
||||
"SCALATEST" to "3.2.19",
|
||||
"SCALATESTPLUS_JUNIT5" to "3.2.19.1"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the root build file parses**
|
||||
|
||||
```bash
|
||||
./gradlew help --quiet
|
||||
```
|
||||
|
||||
Expected: exits 0 with no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add build.gradle.kts
|
||||
git commit -m "build: add ScalaTest version entries to root versions map"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate `modules/core` to ScalaTest + Scoverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/core/build.gradle.kts`
|
||||
|
||||
- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`**
|
||||
|
||||
In the `plugins {}` block, replace:
|
||||
```kotlin
|
||||
jacoco
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
id("org.scoverage") version "8.1"
|
||||
```
|
||||
|
||||
The full plugins block should be:
|
||||
```kotlin
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
application
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap JUnit dependencies for ScalaTest**
|
||||
|
||||
In the `dependencies {}` block, remove:
|
||||
```kotlin
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
```
|
||||
|
||||
Add in their place:
|
||||
```kotlin
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring**
|
||||
|
||||
The file currently has two separate `tasks.test {}` blocks and a `tasks.jacocoTestReport {}` block. Delete all three. Add the following single merged block **after** the `dependencies {}` block:
|
||||
|
||||
```kotlin
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL. (Zero tests is fine — there are no test files yet. The build must not fail with dependency resolution or plugin errors.)
|
||||
|
||||
- [ ] **Step 5: Run the coverage report**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:reportScoverage
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL. A report is generated under `modules/core/build/reports/scoverage/`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/build.gradle.kts
|
||||
git commit -m "build(core): replace JaCoCo with Scoverage, add ScalaTest dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate `modules/api` to ScalaTest + Scoverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/api/build.gradle.kts`
|
||||
|
||||
- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`**
|
||||
|
||||
In the `plugins {}` block, replace:
|
||||
```kotlin
|
||||
jacoco
|
||||
```
|
||||
with:
|
||||
```kotlin
|
||||
id("org.scoverage") version "8.1"
|
||||
```
|
||||
|
||||
The full plugins block should be:
|
||||
```kotlin
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap JUnit dependencies for ScalaTest**
|
||||
|
||||
In the `dependencies {}` block, remove:
|
||||
```kotlin
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
```
|
||||
|
||||
Add in their place:
|
||||
```kotlin
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring**
|
||||
|
||||
The `modules/api` file also has two `tasks.test {}` blocks and a `jacocoTestReport` block. Delete all three. Add the following merged block **after** the `dependencies {}` block:
|
||||
|
||||
```kotlin
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `modules/api` did not previously have `useJUnitPlatform()` — it is being **added** here, not preserved.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:api:test
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL.
|
||||
|
||||
- [ ] **Step 5: Run the coverage report**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:api:reportScoverage
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL. A report is generated under `modules/api/build/reports/scoverage/`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/api/build.gradle.kts
|
||||
git commit -m "build(api): replace JaCoCo with Scoverage, add ScalaTest dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full build verification
|
||||
|
||||
- [ ] **Step 1: Run the full build**
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL with no errors across all modules.
|
||||
|
||||
- [ ] **Step 2: Confirm no JaCoCo references remain**
|
||||
|
||||
```bash
|
||||
grep -r "jacoco\|jacocoTestReport" --include="*.kts" .
|
||||
```
|
||||
|
||||
Expected: no output (zero matches).
|
||||
@@ -1,579 +0,0 @@
|
||||
# Chess Check / Checkmate / Stalemate Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add check detection, checkmate (win by opponent having no legal reply while in check), and stalemate (draw by opponent having no legal reply while not in check) to the chess game loop.
|
||||
|
||||
**Architecture:** A new `GameRules` object owns all check-aware logic; the existing `MoveValidator` keeps its geometric-only contract unchanged. `GameController.processMove` calls `GameRules.gameStatus` after each move and returns new `MoveResult` variants (`MovedInCheck`, `Checkmate`, `Stalemate`). Terminal states reset the board.
|
||||
|
||||
**Tech Stack:** Scala 3.5, ScalaTest (`AnyFunSuite with Matchers`), Gradle (`:modules:core:test`)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | **Create** | `isInCheck`, `legalMoves`, `gameStatus`, `PositionStatus` enum |
|
||||
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | **Create** | Unit tests for all three `GameRules` methods |
|
||||
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | **Modify** | Add `MovedInCheck`/`Checkmate`/`Stalemate` to `MoveResult`; wire `processMove` and `gameLoop` |
|
||||
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | **Modify** | Add `processMove` and `gameLoop` tests for the three new results |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `GameRules` stub
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
|
||||
|
||||
- [ ] **Step 1: Create the stub file**
|
||||
|
||||
```scala
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
|
||||
object GameRules:
|
||||
|
||||
/** True if `color`'s king is under attack on this board. */
|
||||
def isInCheck(board: Board, color: Color): Boolean = false
|
||||
|
||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
||||
def legalMoves(board: Board, color: Color): Set[(Square, Square)] = Set.empty
|
||||
|
||||
/** Position status for the side whose turn it is (`color`). */
|
||||
def gameStatus(board: Board, color: Color): PositionStatus = PositionStatus.Normal
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the project compiles**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:compileScala
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
|
||||
git commit -m "feat: add GameRules stub with PositionStatus enum"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Write `GameRulesTest` (all tests must fail)
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala`
|
||||
|
||||
- [ ] **Step 1: Create the test file**
|
||||
|
||||
```scala
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameRulesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
// ──── isInCheck ──────────────────────────────────────────────────────
|
||||
|
||||
test("isInCheck: king attacked by enemy rook on same rank"):
|
||||
// White King E1, Black Rook A1 — rook slides along rank 1 to E1
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.BlackRook
|
||||
)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe true
|
||||
|
||||
test("isInCheck: king not attacked"):
|
||||
// Black Rook A3 does not cover E1
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R3) -> Piece.BlackRook
|
||||
)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
||||
|
||||
test("isInCheck: no king on board returns false"):
|
||||
val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook)
|
||||
GameRules.isInCheck(b, Color.White) shouldBe false
|
||||
|
||||
// ──── legalMoves ─────────────────────────────────────────────────────
|
||||
|
||||
test("legalMoves: move that exposes own king to rook is excluded"):
|
||||
// White King E1, White Rook E4 (pinned on E-file), Black Rook E8
|
||||
// Moving the White Rook off the E-file would expose the king
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R4) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
)
|
||||
val moves = GameRules.legalMoves(b, Color.White)
|
||||
moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4))
|
||||
|
||||
test("legalMoves: move that blocks check is included"):
|
||||
// White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
)
|
||||
val moves = GameRules.legalMoves(b, Color.White)
|
||||
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
|
||||
|
||||
// ──── gameStatus ──────────────────────────────────────────────────────
|
||||
|
||||
test("gameStatus: checkmate returns Mated"):
|
||||
// White Qh8, Ka6; Black Ka8
|
||||
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
|
||||
val b = board(
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Mated
|
||||
|
||||
test("gameStatus: stalemate returns Drawn"):
|
||||
// White Qb6, Kc6; Black Ka8
|
||||
// Black king has no legal moves and is not in check (spec-verified position)
|
||||
val b = board(
|
||||
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.Drawn
|
||||
|
||||
test("gameStatus: king in check with legal escape returns InCheck"):
|
||||
// White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7
|
||||
val b = board(
|
||||
sq(File.A, Rank.R8) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
GameRules.gameStatus(b, Color.Black) shouldBe PositionStatus.InCheck
|
||||
|
||||
test("gameStatus: normal starting position returns Normal"):
|
||||
GameRules.gameStatus(Board.initial, Color.White) shouldBe PositionStatus.Normal
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and confirm they all fail**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
|
||||
```
|
||||
|
||||
Expected: all 8 tests FAIL (stubs always return `false` / `Set.empty` / `Normal`)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala
|
||||
git commit -m "test: add failing GameRulesTest for check/checkmate/stalemate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement `GameRules`
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
|
||||
|
||||
- [ ] **Step 1: Replace the stub bodies with real implementations**
|
||||
|
||||
```scala
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
|
||||
object GameRules:
|
||||
|
||||
def isInCheck(board: Board, color: Color): Boolean =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, Piece(`color`, PieceType.King)) => sq }
|
||||
.exists { kingSq =>
|
||||
board.pieces.exists { case (sq, piece) =>
|
||||
piece.color != color &&
|
||||
MoveValidator.legalTargets(board, sq).contains(kingSq)
|
||||
}
|
||||
}
|
||||
|
||||
def legalMoves(board: Board, color: Color): Set[(Square, Square)] =
|
||||
board.pieces
|
||||
.collect { case (from, piece) if piece.color == color => from }
|
||||
.flatMap { from =>
|
||||
MoveValidator.legalTargets(board, from)
|
||||
.filter { to =>
|
||||
val (newBoard, _) = board.withMove(from, to)
|
||||
!isInCheck(newBoard, color)
|
||||
}
|
||||
.map(to => from -> to)
|
||||
}
|
||||
.toSet
|
||||
|
||||
def gameStatus(board: Board, color: Color): PositionStatus =
|
||||
val moves = legalMoves(board, 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
|
||||
else PositionStatus.Normal
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the GameRules tests and confirm they all pass**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test --tests "de.nowchess.chess.logic.GameRulesTest"
|
||||
```
|
||||
|
||||
Expected: all 8 tests PASS
|
||||
|
||||
- [ ] **Step 3: Run the full test suite to make sure nothing regressed**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`, all existing tests still pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala
|
||||
git commit -m "feat: implement GameRules with isInCheck, legalMoves, gameStatus"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add new `MoveResult` variants and stub `processMove`
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
|
||||
|
||||
- [ ] **Step 1: Add three new variants to `MoveResult` and import `GameRules`**
|
||||
|
||||
In `GameController.scala`, update the `MoveResult` object and `processMove`. The new variants go after `Moved`. The import of `GameRules`/`PositionStatus` is added at the top. The stub `processMove` calls `GameRules.gameStatus` but always maps to `Moved` — this makes it compile while the new tests will fail:
|
||||
|
||||
```scala
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.board.{Board, Color, Piece}
|
||||
import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus}
|
||||
import de.nowchess.chess.view.Renderer
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result ADT returned by the pure processMove function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case object Quit extends MoveResult
|
||||
case class InvalidFormat(raw: String) extends MoveResult
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove extends MoveResult
|
||||
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Checkmate(winner: Color) extends MoveResult
|
||||
case object Stalemate extends MoveResult
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
object GameController:
|
||||
|
||||
def processMove(board: Board, turn: Color, raw: String): MoveResult =
|
||||
raw.trim match
|
||||
case "quit" | "q" =>
|
||||
MoveResult.Quit
|
||||
case trimmed =>
|
||||
Parser.parseMove(trimmed) match
|
||||
case None =>
|
||||
MoveResult.InvalidFormat(trimmed)
|
||||
case Some((from, to)) =>
|
||||
board.pieceAt(from) match
|
||||
case None =>
|
||||
MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn =>
|
||||
MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !MoveValidator.isLegal(board, from, to) then
|
||||
MoveResult.IllegalMove
|
||||
else
|
||||
val (newBoard, captured) = board.withMove(from, to)
|
||||
MoveResult.Moved(newBoard, captured, turn.opposite) // stub — Task 6 will fix
|
||||
|
||||
def gameLoop(board: Board, turn: Color): Unit =
|
||||
println()
|
||||
print(Renderer.render(board))
|
||||
println(s"${turn.label}'s turn. Enter move: ")
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
processMove(board, 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(board, turn)
|
||||
case MoveResult.NoPiece =>
|
||||
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.WrongColor =>
|
||||
println(s"That is not your piece.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.IllegalMove =>
|
||||
println(s"Illegal move.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.Moved(newBoard, 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(newBoard, newTurn)
|
||||
case MoveResult.MovedInCheck(newBoard, captured, newTurn) => // stub — Task 6
|
||||
gameLoop(newBoard, newTurn)
|
||||
case MoveResult.Checkmate(winner) => // stub — Task 6
|
||||
gameLoop(Board.initial, Color.White)
|
||||
case MoveResult.Stalemate => // stub — Task 6
|
||||
gameLoop(Board.initial, Color.White)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm everything still compiles and existing tests pass**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL` — existing tests still pass, no compilation errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
|
||||
git commit -m "feat: add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Write new `GameControllerTest` cases (all must fail)
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala`
|
||||
|
||||
- [ ] **Step 1: Append the following tests to the existing file**
|
||||
|
||||
Add after the last existing test (the `gameLoop: capture` test). Add the `captureOutput` helper alongside `withInput`:
|
||||
|
||||
```scala
|
||||
// ──── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private def captureOutput(block: => Unit): String =
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
scala.Console.withOut(out)(block)
|
||||
out.toString("UTF-8")
|
||||
|
||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
||||
|
||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, putting Kh8 in check
|
||||
// (Ra8 attacks along rank 8: b8..h8; king escapes to g7/g8/h7 — InCheck, not Mated)
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
GameController.processMove(b, Color.White, "a1a8") match
|
||||
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected MovedInCheck, got $other")
|
||||
|
||||
test("processMove: legal move that results in checkmate returns Checkmate"):
|
||||
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1-h8)
|
||||
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified)
|
||||
// Note: Qa1 does NOT currently attack Ka8 (path along file A is blocked by Ka6)
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
GameController.processMove(b, Color.White, "a1h8") match
|
||||
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
|
||||
case other => fail(s"Expected Checkmate(White), got $other")
|
||||
|
||||
test("processMove: legal move that results in stalemate returns Stalemate"):
|
||||
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
|
||||
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified)
|
||||
val b = Board(Map(
|
||||
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
GameController.processMove(b, Color.White, "b1b6") match
|
||||
case MoveResult.Stalemate => succeed
|
||||
case other => fail(s"Expected Stalemate, got $other")
|
||||
|
||||
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
|
||||
|
||||
test("gameLoop: checkmate prints winner message and resets to new game"):
|
||||
// Same position as checkmate processMove test above; after Qa1-Qh8 game resets
|
||||
// Second move "quit" exits the new game cleanly
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1h8\nquit\n"):
|
||||
GameController.gameLoop(b, Color.White)
|
||||
output should include("Checkmate! White wins.")
|
||||
|
||||
test("gameLoop: stalemate prints draw message and resets to new game"):
|
||||
val b = Board(Map(
|
||||
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("b1b6\nquit\n"):
|
||||
GameController.gameLoop(b, Color.White)
|
||||
output should include("Stalemate! The game is a draw.")
|
||||
|
||||
test("gameLoop: MovedInCheck without capture prints check message"):
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
GameController.gameLoop(b, Color.White)
|
||||
output should include("Black is in check!")
|
||||
|
||||
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
|
||||
// White Rook A1 captures Black Pawn on A8, putting Black King (H8) in check
|
||||
// Ra8 attacks rank 8 → Black Kh8 is in check; king can escape to g7/g8/h7
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackPawn,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
GameController.gameLoop(b, Color.White)
|
||||
output should include("captures")
|
||||
output should include("Black is in check!")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run only the new tests and confirm they fail**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
|
||||
```
|
||||
|
||||
Expected: the 7 new tests FAIL; the existing 17 tests PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
|
||||
git commit -m "test: add failing GameControllerTest cases for check/checkmate/stalemate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement `processMove` dispatch and `gameLoop` branches
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala`
|
||||
|
||||
- [ ] **Step 1: Replace the stub `processMove` else-branch and the three stub `gameLoop` cases**
|
||||
|
||||
Replace only the `else` branch inside `processMove` (keep everything else identical):
|
||||
|
||||
```scala
|
||||
else
|
||||
val (newBoard, captured) = board.withMove(from, to)
|
||||
GameRules.gameStatus(newBoard, turn.opposite) match
|
||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite)
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, captured, turn.opposite)
|
||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
||||
```
|
||||
|
||||
Replace the three stub `gameLoop` cases:
|
||||
|
||||
```scala
|
||||
case MoveResult.MovedInCheck(newBoard, 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(newBoard, newTurn)
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
println(s"Checkmate! ${winner.label} wins.")
|
||||
gameLoop(Board.initial, Color.White)
|
||||
case MoveResult.Stalemate =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
gameLoop(Board.initial, Color.White)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all controller tests**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test --tests "de.nowchess.chess.controller.GameControllerTest"
|
||||
```
|
||||
|
||||
Expected: all 24 tests PASS
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
|
||||
git commit -m "feat: wire check/checkmate/stalemate into processMove and gameLoop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Coverage check and final verification
|
||||
|
||||
- [ ] **Step 1: Run the full build with coverage**
|
||||
|
||||
```bash
|
||||
./gradlew :modules:core:test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Step 2: Check coverage gaps**
|
||||
|
||||
```bash
|
||||
python jacoco-reporter/scoverage_coverage_gaps.py modules/core/build/reports/scoverageTest/scoverage.xml
|
||||
```
|
||||
|
||||
Review output. If any newly added method falls below the thresholds from `CLAUDE.md` (branch ≥ 90%, line ≥ 95%, method ≥ 90%), add targeted tests to close the gaps before considering the task done.
|
||||
|
||||
- [ ] **Step 3: Commit coverage fixes (if any)**
|
||||
|
||||
```bash
|
||||
git add -p
|
||||
git commit -m "test: improve coverage for GameRules and GameController"
|
||||
```
|
||||
@@ -1,85 +0,0 @@
|
||||
# Design: Add ScalaTest + Replace JaCoCo with Scoverage
|
||||
|
||||
**Date:** 2026-03-22
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the current JUnit-only test setup and JaCoCo coverage with ScalaTest (via its JUnit 5 bridge) and Scoverage across both `modules/core` and `modules/api`.
|
||||
|
||||
## Motivation
|
||||
|
||||
- The CLAUDE.md working agreement prescribes `AnyFunSuite with Matchers with JUnitSuiteLike` as the unit test style, which requires ScalaTest.
|
||||
- Scoverage is the standard Scala code coverage tool and understands Scala semantics; JaCoCo's JVM bytecode instrumentation is less accurate for Scala code.
|
||||
|
||||
## Scope
|
||||
|
||||
Two modules are affected: `modules/core` and `modules/api`. The root `build.gradle.kts` is updated for shared dependency versions only.
|
||||
|
||||
## Changes
|
||||
|
||||
### Root `build.gradle.kts`
|
||||
|
||||
Add to the `versions` map (dependency versions only — plugin version is hardcoded per module, see note below):
|
||||
- `SCALATEST` → `3.2.19`
|
||||
- `SCALATESTPLUS_JUNIT5` → `3.2.19.1`
|
||||
|
||||
> **Note on plugin versioning:** Gradle resolves the `plugins {}` block before `rootProject.extra` is available, so the Scoverage plugin version (`8.1`) must be declared inline in each module's `plugins {}` block. It cannot be read from the root versions map.
|
||||
|
||||
### `modules/core/build.gradle.kts` and `modules/api/build.gradle.kts`
|
||||
|
||||
Both modules require the same set of changes. Both currently have **two separate `tasks.test {}` blocks** that must be merged into one.
|
||||
|
||||
**Plugins block:**
|
||||
- Remove `jacoco`
|
||||
- Add `id("org.scoverage") version "8.1"`
|
||||
|
||||
**Dependencies block:**
|
||||
- Remove `testImplementation(platform("org.junit:junit-bom:5.10.0"))`
|
||||
- Remove `testImplementation("org.junit.jupiter:junit-jupiter")`
|
||||
- Remove `testRuntimeOnly("org.junit.platform:junit-platform-launcher")`
|
||||
- Add `testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")`
|
||||
- Add `testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")`
|
||||
|
||||
**Task wiring — merge both `tasks.test {}` blocks into one and replace jacoco wiring:**
|
||||
|
||||
Both `modules/core` and `modules/api` currently have two `tasks.test {}` blocks. Delete both and replace with the following single merged block placed **after** the `dependencies {}` block (conventional position):
|
||||
|
||||
```kotlin
|
||||
tasks.test {
|
||||
useJUnitPlatform() // required — scalatestplus JUnit 5 bridge relies on this
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `modules/api` does not currently have `useJUnitPlatform()` — it must be **added** (not just kept) in the merged block.
|
||||
|
||||
Remove the `jacocoTestReport` task block entirely from both modules.
|
||||
|
||||
**Task name confirmation:** The Scoverage Gradle plugin 8.1 registers `reportScoverage` as the HTML report task.
|
||||
|
||||
## Versions
|
||||
|
||||
| Artifact | Version | Notes |
|
||||
|---|---|---|
|
||||
| `org.scalatest:scalatest_3` | 3.2.19 | Core ScalaTest for Scala 3 |
|
||||
| `org.scalatestplus:junit-5-11_3` | 3.2.19.1 | JUnit 5.11 runner bridge; `.1` = build 1 |
|
||||
| Scoverage Gradle plugin | 8.1 | Hardcoded inline in `plugins {}` block |
|
||||
|
||||
## Testing the Change
|
||||
|
||||
After applying:
|
||||
1. `./gradlew :modules:core:test` and `./gradlew :modules:api:test` must pass (green, even with zero test files).
|
||||
2. `./gradlew :modules:core:reportScoverage` must produce a coverage report.
|
||||
3. `./gradlew build` must be fully green.
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `build.gradle.kts` (root) — add two version entries
|
||||
- `modules/core/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, replace jacoco wiring
|
||||
- `modules/api/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, add `useJUnitPlatform()`, replace jacoco wiring
|
||||
|
||||
No new source files are created.
|
||||
@@ -1,169 +0,0 @@
|
||||
# Chess Check / Checkmate / Stalemate — Design Spec
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Implement check detection, checkmate (win condition), and stalemate (draw) on top of the existing normal-move rules. En passant, castling, and pawn promotion are **out of scope** for this iteration.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### New: `GameRules` object
|
||||
|
||||
**File:** `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala`
|
||||
|
||||
Owns all check-aware game logic. `MoveValidator` retains its documented geometric-only contract ("ignoring check/pin").
|
||||
|
||||
```
|
||||
GameRules
|
||||
isInCheck(board, color): Boolean
|
||||
legalMoves(board, color): Set[(Square, Square)]
|
||||
gameStatus(board, color): PositionStatus
|
||||
```
|
||||
|
||||
#### `isInCheck(board, color)`
|
||||
|
||||
Finds the king square for `color` by scanning `board.pieces` for a `Piece(color, PieceType.King)`. If no king is found (constructed/test boards), returns `false`.
|
||||
|
||||
Then checks whether any enemy piece's `MoveValidator.legalTargets` contains that square. This works correctly for all piece types, including the king: `kingTargets` returns the squares the king can move to, which are identical to the squares the king attacks, so using `legalTargets` for attack detection is correct by design.
|
||||
|
||||
Returns `true` if the king square is covered by at least one enemy piece.
|
||||
|
||||
|
||||
#### `legalMoves(board, color)`
|
||||
|
||||
1. Filter `board.pieces` to entries where `piece.color == color`.
|
||||
2. For each such `(from, piece)`, call `MoveValidator.legalTargets(board, from)` to get geometric candidates.
|
||||
3. For each candidate `to`, apply `board.withMove(from, to)` to get `newBoard`.
|
||||
4. Keep only moves where `isInCheck(newBoard, color)` is `false` (i.e., the move does not leave own king in check).
|
||||
5. Return the full set of `(from, to)` pairs that survive this filter.
|
||||
|
||||
#### `gameStatus(board, color)`
|
||||
|
||||
Returns a `PositionStatus` enum value based on `legalMoves(board, color)` and `isInCheck(board, color)`:
|
||||
|
||||
- `Mated` — `legalMoves` is empty **and** king is in check → the side to move has been checkmated
|
||||
- `Drawn` — `legalMoves` is empty **and** king is **not** in check → stalemate (draw)
|
||||
- `InCheck` — `legalMoves` is non-empty **and** king is in check → game continues under check
|
||||
- `Normal` — otherwise
|
||||
|
||||
#### Local `PositionStatus` enum
|
||||
|
||||
Defined in `GameRules.scala`. Names are intentionally distinct from `MoveResult` variants to avoid unqualified-name collisions in `GameController.scala`:
|
||||
|
||||
```scala
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Modified: `MoveResult` (in `GameController.scala`)
|
||||
|
||||
Three new variants; existing variants are unchanged:
|
||||
|
||||
| Variant | When used |
|
||||
|---|---|
|
||||
| `MovedInCheck(newBoard, captured, newTurn)` | Move was legal; opponent is now in check but has legal replies |
|
||||
| `Checkmate(winner: Color)` | Move was legal; opponent is `Mated` → `winner` is the side that just moved |
|
||||
| `Stalemate` | Move was legal; opponent is `Drawn` (no legal reply, not in check) |
|
||||
|
||||
`Moved` continues to be used when `gameStatus` returns `Normal`.
|
||||
|
||||
---
|
||||
|
||||
### Modified: `GameController.processMove`
|
||||
|
||||
After computing `(newBoard, captured)` from `board.withMove`:
|
||||
|
||||
1. Call `GameRules.gameStatus(newBoard, newTurn)`.
|
||||
2. Map to the appropriate `MoveResult`:
|
||||
|
||||
```
|
||||
PositionStatus.Normal → Moved(newBoard, captured, newTurn)
|
||||
PositionStatus.InCheck → MovedInCheck(newBoard, captured, newTurn)
|
||||
PositionStatus.Mated → Checkmate(turn) // turn = the side that just moved
|
||||
PositionStatus.Drawn → Stalemate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Modified: `GameController.gameLoop`
|
||||
|
||||
**New terminal branches** (both print a message then restart):
|
||||
|
||||
- `Checkmate(winner)` → print `"Checkmate! {winner.label} wins."`, then recurse with `(Board.initial, Color.White)`
|
||||
- `Stalemate` → print `"Stalemate! The game is a draw."`, then recurse with `(Board.initial, Color.White)`
|
||||
|
||||
**New non-terminal branch:**
|
||||
|
||||
- `MovedInCheck(newBoard, captured, newTurn)` → print the same optional capture message as `Moved` (when `captured.isDefined`), then print `"{newTurn.label} is in check!"`, then recurse with `(newBoard, newTurn)`
|
||||
|
||||
**Restart vs. exit:** Checkmate and stalemate restart the game automatically (no prompt). This is intentionally asymmetric with `Quit`, which exits. `Quit` is an explicit user request to stop; Checkmate/Stalemate are natural game endings that should roll into a new game.
|
||||
|
||||
---
|
||||
|
||||
## Test Strategy
|
||||
|
||||
All tests are unit tests extending `AnyFunSuite with Matchers with JUnitSuiteLike`.
|
||||
|
||||
### `GameRulesTest` — new file
|
||||
|
||||
| Scenario | Method | Expected |
|
||||
|---|---|---|
|
||||
| King attacked by enemy rook on same rank | `isInCheck` | `true` |
|
||||
| King not attacked (only own pieces nearby) | `isInCheck` | `false` |
|
||||
| No king on board (constructed board) | `isInCheck` | `false` |
|
||||
| Move that exposes own king to rook is excluded | `legalMoves` | does not contain that move |
|
||||
| Move that blocks check is included | `legalMoves` | contains the blocking move |
|
||||
| Checkmate: White Qh8, Ka6; Black Ka8 — Black king is in check (Qh8 along rank 8), cannot escape to a7 (Ka6), b7 (Ka6), or b8 (Qh8) | `gameStatus` | `Mated` |
|
||||
| Stalemate: White Qb6, Kc6; Black Ka8 — Black king has no legal moves (a7/b7/b8 all controlled by Qb6), not in check | `gameStatus` | `Drawn` |
|
||||
| King in check with at least one escape square | `gameStatus` | `InCheck` |
|
||||
| Normal midgame position, not in check, has moves | `gameStatus` | `Normal` |
|
||||
|
||||
### `GameControllerTest` additions — new `processMove` cases
|
||||
|
||||
| Scenario | Expected `MoveResult` |
|
||||
|---|---|
|
||||
| Move leaves opponent in check (has escape) | `MovedInCheck` |
|
||||
| Move results in checkmate | `Checkmate(winner)` where winner is the side that moved |
|
||||
| Move results in stalemate | `Stalemate` |
|
||||
|
||||
### `GameControllerTest` additions — new `gameLoop` cases
|
||||
|
||||
| Scenario | Expected output / behavior |
|
||||
|---|---|
|
||||
| `gameLoop` receives `Checkmate(White)` | Prints "Checkmate! White wins." and continues (new game) |
|
||||
| `gameLoop` receives `Stalemate` | Prints "Stalemate! The game is a draw." and continues (new game) |
|
||||
| `gameLoop` receives `MovedInCheck` with a capture | Prints capture message AND check message |
|
||||
| `gameLoop` receives `MovedInCheck` without a capture | Prints check message only |
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow (TDD)
|
||||
|
||||
1. Create `GameRules.scala` with empty/stub method bodies that compile but return placeholder values (`false`, `Set.empty`, `PositionStatus.Normal`).
|
||||
2. Write all `GameRulesTest` tests — they should **fail**.
|
||||
3. Implement `GameRules` logic until `GameRulesTest` is green.
|
||||
4. Add new `MoveResult` variants to `GameController.scala`; update `processMove` to call `GameRules.gameStatus` (stub the match arms initially).
|
||||
5. Write new `GameControllerTest` cases — they should **fail**.
|
||||
6. Implement `processMove` match arms and `gameLoop` new branches until all tests pass.
|
||||
7. Run `./gradlew :modules:core:test` — full green build required.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala` | New |
|
||||
| `modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala` | New |
|
||||
| `modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala` | Add `MoveResult` variants; update `processMove` and `gameLoop` |
|
||||
| `modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala` | Add new test cases |
|
||||
|
||||
No changes to `modules/api` or `MoveValidator`.
|
||||
@@ -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,584 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://schema.gradle.org/dependency-verification https://schema.gradle.org/dependency-verification/dependency-verification-1.3.xsd">
|
||||
<configuration>
|
||||
<verify-metadata>true</verify-metadata>
|
||||
<verify-signatures>true</verify-signatures>
|
||||
<ignored-keys>
|
||||
<ignored-key id="01D9B9C7952C4A1F" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="1B6E3BDDD4415872" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="29967E804D85663F" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="41CB98F33B06146E" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="60BE32B1404779E5" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="63BB5E152DFF95F0" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="7090AF43A5E10D0B" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="7DC3076FE22D4F88" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="84E913A8E3A748C0" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="85911F425EC61B51" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="9AEE152CDCCEBFCB" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="9DAADC1C9FCC82D0" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="BCF4173966770193" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="BFFA420097F49C8A" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="C03EF1D7D692BCFF" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="C2952540150670BE" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="D364ABAA39A47320" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="DCD5181297A43D24" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="ED2378CD09A08CDE" reason="Key couldn't be downloaded from any key server"/>
|
||||
<ignored-key id="F42E87F9665015C9" reason="Key couldn't be downloaded from any key server"/>
|
||||
</ignored-keys>
|
||||
<trusted-keys>
|
||||
<trusted-key id="0181A4828FA27B6BE6F1F5A68611CD28F472E006" group="org.jline"/>
|
||||
<trusted-key id="073F7A9345756F3B40CDB99E6C70A3B7599C5736" group="org.jline"/>
|
||||
<trusted-key id="120D6F34E627ED3A772EBBFE55C7E5E701832382" group="org.yaml" name="snakeyaml" version="2.0"/>
|
||||
<trusted-key id="1FA868A348719E88B6D0DE24C03EF1D7D692BCFF" group="org.scala-lang"/>
|
||||
<trusted-key id="23D4275AC69688098AF3997BA6C4333204634502" group="org.scoverage"/>
|
||||
<trusted-key id="28118C070CB22A0175A2E8D43D12CA2AC19F3181" group="^com[.]fasterxml($|([.].*))" regex="true"/>
|
||||
<trusted-key id="2A5E8B338438CAC7033F9D8FB8A045C0A6EC398E" group="org.scala-lang"/>
|
||||
<trusted-key id="2BE67AC00D699E04E840B7FE29967E804D85663F" group="com.eed3si9n"/>
|
||||
<trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
|
||||
<trusting group="commons-codec"/>
|
||||
<trusting group="commons-io"/>
|
||||
<trusting group="org.apache.commons"/>
|
||||
</trusted-key>
|
||||
<trusted-key id="2E3A1AFFE42B5F53AF19F780BCF4173966770193" group="org.jetbrains" name="annotations" version="15.0"/>
|
||||
<trusted-key id="3F3633D644494880818AD64601D9B9C7952C4A1F" group="org.scala-lang.modules" name="scala-asm" version="9.6.0-scala-1"/>
|
||||
<trusted-key id="4008F9DFF7DBC968F35F9E712642156411CCE8B3" group="com.vladsch.flexmark"/>
|
||||
<trusted-key id="50B670A8DE1F3CD89583895241CB98F33B06146E">
|
||||
<trusting group="nl.big-o"/>
|
||||
<trusting group="ua.co.k"/>
|
||||
</trusted-key>
|
||||
<trusted-key id="58DF461CAAC5F4E5FB2BE32CBFFA420097F49C8A" group="com.lmax" name="disruptor" version="3.4.2"/>
|
||||
<trusted-key id="600D21219963F228200A72375365A8A69292AF1A" group="org.scala-lang.modules" name="scala-xml_3" version="2.1.0"/>
|
||||
<trusted-key id="624B96CEB9896889C97B258F7DC3076FE22D4F88" group="org.nibor.autolink" name="autolink" version="0.6.0"/>
|
||||
<trusted-key id="6766B3EC6ECC2FFD5F899F7C63BB5E152DFF95F0">
|
||||
<trusting group="org.scalactic"/>
|
||||
<trusting group="org.scalatest"/>
|
||||
</trusted-key>
|
||||
<trusted-key id="6E601AC418304FD7DCB373CA3D30EF3598565988" group="org.scoverage"/>
|
||||
<trusted-key id="7B121B76A7ED6CE6E60AD51784E913A8E3A748C0" group="org.bouncycastle" name="bcprov-jdk18on" version="1.83"/>
|
||||
<trusted-key id="7CEAC05AFEB808AD75C2097D60BE32B1404779E5" group="co.helmethair" name="scalatest-junit-runner" version="0.1.11"/>
|
||||
<trusted-key id="84789D24DF77A32433CE1F079EB80E92EB2135B1" group="org.apache" name="apache" version="35"/>
|
||||
<trusted-key id="8A10792983023D5D14C93B488D7F1BEC1E2ECAE7" group="^com[.]fasterxml[.]jackson($|([.].*))" regex="true"/>
|
||||
<trusted-key id="9D0A56AAA0D60E0C0C7DCCC0B4C70893B62BABE8" group="^org[.]apache[.]logging($|([.].*))" regex="true"/>
|
||||
<trusted-key id="A7D8BE3D575D6C5040E889331B6E3BDDD4415872" group="net.openhft"/>
|
||||
<trusted-key id="ACF39CCDED38E2C6F0898BF28F7F6C0451967B84" group="org.scala-lang" name="scala3-library_3"/>
|
||||
<trusted-key id="B9611F878B0CE6D92145157FA6ED77BB4C0EAE26" group="org.scoverage" name="gradle-scoverage" version="8.1"/>
|
||||
<trusted-key id="C44A68FD10FF456C91E2757D18088D07854014B3" group="org.scala-lang.modules" name="scala-parallel-collections_2.13" version="0.2.0"/>
|
||||
<trusted-key id="C7BE5BCC9FEC15518CFDA882B0F3710FA64900E7" group="com.google.code.gson"/>
|
||||
<trusted-key id="CD5464315F0B98C77E6E8ECD9DAADC1C9FCC82D0" group="commons-io" name="commons-io" version="2.6"/>
|
||||
<trusted-key id="D1436C0DBACEA48702AF97C363F1DD7753B8B315" group="^org[.]sonarsource($|([.].*))" regex="true"/>
|
||||
<trusted-key id="D477D51812E692011DB11E66A6EA2E2BF22E0543" group="io.github.java-diff-utils"/>
|
||||
<trusted-key id="D54A395B5CF3F86EB45F6E426B1B008864323B92" group="org.antlr"/>
|
||||
<trusted-key id="DBE61B6BA51DFCAEDED256477090AF43A5E10D0B" group="org.scala-lang.modules" name="scala-parser-combinators_2.13" version="1.1.2"/>
|
||||
<trusted-key id="DC98224C6421A7A5BB87F346ED2378CD09A08CDE" group="org.fusesource.jansi" name="jansi" version="2.4.0"/>
|
||||
<trusted-key id="EA313384CA0EBA950EA017E937890E298D9A2BFA">
|
||||
<trusting group="com.eed3si9n"/>
|
||||
<trusting group="^org[.]scala-sbt($|([.].*))" regex="true"/>
|
||||
</trusted-key>
|
||||
<trusted-key id="EE2CFEB6A2AECF44C781C5C3DCD5181297A43D24" group="com.swoval" name="file-tree-views" version="2.1.12"/>
|
||||
<trusted-key id="F3184BCD55F4D016E30D4C9BF42E87F9665015C9" group="org.jsoup" name="jsoup" version="1.17.2"/>
|
||||
<trusted-key id="F3D9FF1EE50634CC57D1E380C2952540150670BE" group="org.scala-lang.modules"/>
|
||||
<trusted-key id="FA7929F83AD44C4590F6CC6815C71C0A4E0B8EDD" group="net.java.dev.jna" name="jna" version="5.14.0"/>
|
||||
</trusted-keys>
|
||||
</configuration>
|
||||
<components>
|
||||
<component group="co.helmethair" name="scalatest-junit-runner" version="0.1.11">
|
||||
<artifact name="scalatest-junit-runner-0.1.11.jar">
|
||||
<sha256 value="d2528b296efc33c8aef2175ea7da9cb41252eddfe24b62a29b7ec7fbe5f664d7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-junit-runner-0.1.11.module">
|
||||
<sha256 value="673aedd69783976df2c0a15c55f6bf12870a3edf05b3e13921752fa81c02195b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.eed3si9n" name="shaded-scalajson_2.13" version="1.0.0-M4">
|
||||
<artifact name="shaded-scalajson_2.13-1.0.0-M4.jar">
|
||||
<sha256 value="7b6b6d85727bd8abab940b559de8e32aa5081add29f7531c855bb0761ae8de67" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="shaded-scalajson_2.13-1.0.0-M4.pom">
|
||||
<sha256 value="256d22f6d5634dc4be9358c6ab692d81e511d468c3e7836db2833a3ed88a84f8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.eed3si9n" name="sjson-new-core_2.13" version="0.9.0">
|
||||
<artifact name="sjson-new-core_2.13-0.9.0.pom">
|
||||
<sha256 value="185df6fb71d7d900e960277896adb790b36ba65d299ff29f27060ab3d65323ee" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.errorprone" name="error_prone_annotations" version="2.41.0">
|
||||
<artifact name="error_prone_annotations-2.41.0.jar">
|
||||
<sha256 value="a56e782b5b50811ac204073a355a21d915a2107fce13ec711331ad036f660fcc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="error_prone_annotations-2.41.0.pom">
|
||||
<sha256 value="a151df1e2e0b48618d8b06a180748a29b3abb39b1b2396f6a1c879a727488c6e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.errorprone" name="error_prone_parent" version="2.41.0">
|
||||
<artifact name="error_prone_parent-2.41.0.pom">
|
||||
<sha256 value="c538388d760a5c1c98dcf06f6ed3cfe5f11a651827db5cbd2ed8288c795cad42" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.lmax" name="disruptor" version="3.4.2">
|
||||
<artifact name="disruptor-3.4.2.jar">
|
||||
<sha256 value="f412ecbb235c2460b45e63584109723dea8d94b819c78c9bfc38f50cba8546c0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="disruptor-3.4.2.pom">
|
||||
<sha256 value="7311e5e261ca62f259b2d14e6d6f1ce375a64718731a730fd7cec0228d50f5da" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.swoval" name="file-tree-views" version="2.1.12">
|
||||
<artifact name="file-tree-views-2.1.12.jar">
|
||||
<sha256 value="fd7373889b7a92cf3e97db36c920ba272aec158a9387b3259fca9f2dfaeda914" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="file-tree-views-2.1.12.pom">
|
||||
<sha256 value="edd270dc776d1d85dd300e415cff9e0609757d7afeb223a6b187bad5b0abe746" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="commons-io" name="commons-io" version="2.6">
|
||||
<artifact name="commons-io-2.6.jar">
|
||||
<sha256 value="f877d304660ac2a142f3865badfc971dec7ed73c747c7f8d5d2f5139ca736513" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="commons-io-2.6.pom">
|
||||
<sha256 value="0c23863893a2291f5a7afdbd8d15923b3948afd87e563fa341cdcf6eae338a60" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.hakky54" name="ayza" version="10.0.2">
|
||||
<artifact name="ayza-10.0.2.jar">
|
||||
<sha256 value="9aa06304993aff5677dba769c677e578364f5793cbaf1569b2b5f39b71119a7b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="ayza-10.0.2.pom">
|
||||
<sha256 value="441136232173d5eb533feffc96daa0353f8f0cb695033ca9631ecc8e68ddd335" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.hakky54" name="ayza-bom" version="10.0.2">
|
||||
<artifact name="ayza-bom-10.0.2.pom">
|
||||
<sha256 value="817c3e174101e3d843bae65a13130dad8e1b93396e1355b5ad676941045d8dd2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.hakky54" name="ayza-parent" version="10.0.2">
|
||||
<artifact name="ayza-parent-10.0.2.pom">
|
||||
<sha256 value="f021993e03a484a8bd1d06dc6c7f2a6007a92ac1102bd5ddac9be13059df1cdf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.hakky54" name="sude" version="2.0.2">
|
||||
<artifact name="sude-2.0.2.jar">
|
||||
<sha256 value="f88e3d031dbbd2fea1b98481df0646a25b8d63d92796d6f30f907d5187595b39" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="sude-2.0.2.pom">
|
||||
<sha256 value="c38a30206ea8b95811805348ecb79cb3d3517df835ba10c660d395ec76181441" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="net.openhft" name="java-parent-pom" version="1.1.28">
|
||||
<artifact name="java-parent-pom-1.1.28.pom">
|
||||
<sha256 value="02199c347a9d2b9e6f5dbf8e13d4c34e8febfab90c9b81fba13a16e8208809bf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="net.openhft" name="root-parent-pom" version="1.2.12">
|
||||
<artifact name="root-parent-pom-1.2.12.pom">
|
||||
<sha256 value="31802b4c86422d91ac5337dad705113535ca986f7cd7bc239701b9f9df967ccf" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="net.openhft" name="zero-allocation-hashing" version="0.16">
|
||||
<artifact name="zero-allocation-hashing-0.16.jar">
|
||||
<sha256 value="3bc39c640cc8314575de4ebcb1a0bca540516d3c60d49f8de7d638b09868553d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="zero-allocation-hashing-0.16.pom">
|
||||
<sha256 value="0949496963193655f81afb9dba28743444dd2a23a6f4933638a6991cfd728fc6" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="nl.big-o" name="liqp" version="0.8.2">
|
||||
<artifact name="liqp-0.8.2.jar">
|
||||
<sha256 value="a948c26558e31fb445b5f1a2561c4518136a5d74046e4ab12bfb6010f6b0cc5b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="liqp-0.8.2.pom">
|
||||
<sha256 value="846da39098c20be8631523c62928e4dd2b4cf7686d428678aae50060eef009b8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apache" name="apache" version="18">
|
||||
<artifact name="apache-18.pom">
|
||||
<pgp value="190D5A957FF22273E601F7A7C92C5FEC70161C62"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apache" name="apache" version="23">
|
||||
<artifact name="apache-23.pom">
|
||||
<pgp value="FA77DCFEF2EE6EB2DEBEDD2C012579464D01C06A"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apache" name="apache" version="35">
|
||||
<artifact name="apache-35.pom">
|
||||
<sha256 value="ea297dcd114136e8b8e8b630230d52a76c2fc69f6c5db25d672b1857000728b8" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apache.commons" name="commons-parent" version="42">
|
||||
<artifact name="commons-parent-42.pom">
|
||||
<pgp value="CE8075A251547BEE249BC151A2115AE15F6B8B72"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apache.commons" name="commons-parent" version="85">
|
||||
<artifact name="commons-parent-85.pom">
|
||||
<sha256 value="d189ff2c0027e96bb65d31e6f227ed2af966169b36af1e973dd5ba08926dc7b5" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.apiguardian" name="apiguardian-api" version="1.1.2">
|
||||
<artifact name="apiguardian-api-1.1.2.jar">
|
||||
<sha256 value="b509448ac506d607319f182537f0b35d71007582ec741832a1f111e5b5b70b38" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="apiguardian-api-1.1.2.module">
|
||||
<sha256 value="e08028131375b357d1d28734e9a4fb4216da84b240641cb3ef7e7c7d628223fc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.bouncycastle" name="bcprov-jdk18on" version="1.83">
|
||||
<artifact name="bcprov-jdk18on-1.83.jar">
|
||||
<sha256 value="82cf3a2af766c3bc874f6d36b9f20a8b99a8f09762dc776e8a227a45d8daaafb" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="bcprov-jdk18on-1.83.pom">
|
||||
<sha256 value="c87cf06f5aac4656380f1d441b2459fbe066ec812b29469bd0b3fcb8bb20574a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.fusesource" name="fusesource-pom" version="1.12">
|
||||
<artifact name="fusesource-pom-1.12.pom">
|
||||
<sha256 value="c40d960daadcef7b01c1b1c6657afbac4fffb5e53168f8fcb0b28b84e6fdcca1" origin="Generated by Gradle" reason="Artifact is not signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.fusesource.jansi" name="jansi" version="2.4.0">
|
||||
<artifact name="jansi-2.4.0.jar">
|
||||
<sha256 value="6cd91991323dd7b2fb28ca93d7ac12af5a86a2f53279e2b35827b30313fd0b9f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="jansi-2.4.0.pom">
|
||||
<sha256 value="ac40a9f2d0c1ee631fc3b08ef8e2f0bd14ba22011ca76ff1bcf65fb569eadf35" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains" name="annotations" version="15.0">
|
||||
<artifact name="annotations-15.0.jar">
|
||||
<sha256 value="d74599cef2b363fdb3cdd3198515aca090e3ea3e98b2ba473c6e46f114dab272" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="annotations-15.0.pom">
|
||||
<sha256 value="6726678ac07b481b5e35d3aeefce526b95fd18ede33d0d85cb1c688bcdf0e840" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jsoup" name="jsoup" version="1.17.2">
|
||||
<artifact name="jsoup-1.17.2.jar">
|
||||
<sha256 value="f60b33b38e9d7ac93eaaa68a6c70f706bb99036494b2e2add2bfee11d09ac6f5" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="jsoup-1.17.2.pom">
|
||||
<sha256 value="7a349d217790c3730be308ced1ea9ee32c4e74f72058e83c2b60e5a28954dd0d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit" name="junit-bom" version="5.13.1">
|
||||
<artifact name="junit-bom-5.13.1.module">
|
||||
<sha256 value="33c07ab9724790a6e5859ba07d69117ac530439724545a81c4179e3272c75de8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-bom-5.13.1.pom">
|
||||
<sha256 value="fa68451ea830572ed43ffe51d75b6a05f7a5e665a602a51f49d6be02063a65f3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit" name="junit-bom" version="5.13.4">
|
||||
<artifact name="junit-bom-5.13.4.module">
|
||||
<sha256 value="e959288fde1b1b050d9bc082fc786789128da5d2853091468fca504104bdf400" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-bom-5.13.4.pom">
|
||||
<sha256 value="d7a08a99b2502f0bb68cd4e1f984f0bf69324aaa208bd0f73366c03fc3548a42" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit" name="junit-bom" version="5.14.1">
|
||||
<artifact name="junit-bom-5.14.1.module">
|
||||
<sha256 value="278acb11ccc9998694224386f96fb4941a22edb42cb446c92e0f1f33014b6b48" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-bom-5.14.1.pom">
|
||||
<sha256 value="01b01dfa366550b40ac5760548a7d728b6109d17c451e83864d1e5e0ce862c94" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.jupiter" name="junit-jupiter" version="5.13.4">
|
||||
<artifact name="junit-jupiter-5.13.4.jar">
|
||||
<sha256 value="b960f79217dd01c863031b678f07df4730bbf1eac650c74ad6b0c61faad78379" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-jupiter-5.13.4.module">
|
||||
<sha256 value="46946227c2967d1659e955f53d34ec8731811d4af401c2ac7d646f793c78e1f9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.jupiter" name="junit-jupiter-api" version="5.13.4">
|
||||
<artifact name="junit-jupiter-api-5.13.4.jar">
|
||||
<sha256 value="d1bb81abfd9e03418306b4e6a3390c8db52c58372e749c2980ac29f0c08278f1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-jupiter-api-5.13.4.module">
|
||||
<sha256 value="fe464d37f5c810a805ff319198165cac33c2558e2261021d8f312a825a48671f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.jupiter" name="junit-jupiter-engine" version="5.13.4">
|
||||
<artifact name="junit-jupiter-engine-5.13.4.jar">
|
||||
<sha256 value="027404a92fe618b72465792a257951495c503a7d5751e2791e0f51c87f67f5bc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-jupiter-engine-5.13.4.module">
|
||||
<sha256 value="ceeee6d0034a738135bd9f3820cfe089c6569163c623ba8e3e9b44f7208fd21a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.jupiter" name="junit-jupiter-params" version="5.13.4">
|
||||
<artifact name="junit-jupiter-params-5.13.4.jar">
|
||||
<sha256 value="3a8c6365716dbb698c0d49a05456c1e1ad05c406613c550f9dd50037872efc41" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-jupiter-params-5.13.4.module">
|
||||
<sha256 value="fc366fbe607999afc8cf02b9dca95d1e02a06b0ce872a45605a9d968c246f4b4" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.platform" name="junit-platform-commons" version="1.13.4">
|
||||
<artifact name="junit-platform-commons-1.13.4.jar">
|
||||
<sha256 value="1c25ca641ebaae44ff3ad21ca1b2ef68d0dd84bfeb07c4805ba7840899b77408" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-platform-commons-1.13.4.module">
|
||||
<sha256 value="1a7a2de7c798995fb97b244d6ef9e99c3a5799b57a0fbacd68496ba7ee8159d7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.platform" name="junit-platform-engine" version="1.13.4">
|
||||
<artifact name="junit-platform-engine-1.13.4.jar">
|
||||
<sha256 value="390c5f77b84283a64b644f88251b397e0b0debb80bdcc50f899881aecff43a5a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-platform-engine-1.13.4.module">
|
||||
<sha256 value="35e4fd68ebf314e62660148936436b39ae105d355beaac1aa54eb91c2066ca77" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit.platform" name="junit-platform-launcher" version="1.13.4">
|
||||
<artifact name="junit-platform-launcher-1.13.4.jar">
|
||||
<sha256 value="0b0beaeb6880a31149641d2d848b863712885469670c12099586d7f798522564" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="junit-platform-launcher-1.13.4.module">
|
||||
<sha256 value="115f77455740e0c3c5398bcdd841c8aa699c2d8002b1100f2ae7a643d9405928" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.nibor.autolink" name="autolink" version="0.6.0">
|
||||
<artifact name="autolink-0.6.0.jar">
|
||||
<sha256 value="a80be030f6386f18111cad9161c0b6983157352a1b59a59e6002172f0d321c04" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="autolink-0.6.0.pom">
|
||||
<sha256 value="916755647a34ccb367e11834d28380198c834adfcf660e0d983e375b8f5c28f2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.opentest4j" name="opentest4j" version="1.3.0">
|
||||
<artifact name="opentest4j-1.3.0.jar">
|
||||
<sha256 value="48e2df636cab6563ced64dcdff8abb2355627cb236ef0bf37598682ddf742f1b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="opentest4j-1.3.0.module">
|
||||
<sha256 value="48bf1d6c8b5dc94f74652bd17900f654deb714350248cf5e8fca27b9090c8e0d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang" name="scala-compiler" version="2.13.15">
|
||||
<artifact name="scala-compiler-2.13.15.jar">
|
||||
<sha256 value="4c200cd193c082bec14a2a2dffe6a1ba5f8130b1b27c79ee54c936dfcafc8ed9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-compiler-2.13.15.pom">
|
||||
<sha256 value="6ae13081e950a55545e53e7e6f9bf6754ed0ec17af331772ae8fae4fb406f697" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang" name="scala-library" version="2.13.14">
|
||||
<artifact name="scala-library-2.13.14.jar">
|
||||
<sha256 value="43e0ca1583df1966eaf02f0fbddcfb3784b995dd06bfc907209347758ce4b7e3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-library-2.13.14.pom">
|
||||
<sha256 value="cee86c6df5653aaf55403666902fcbb0aaaf400eb2cffb27f09ca5d75ec703bc" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang" name="scala-library" version="2.13.15">
|
||||
<artifact name="scala-library-2.13.15.jar">
|
||||
<sha256 value="8e4dbc3becf70d59c787118f6ad06fab6790136a0699cd6412bc9da3d336944e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-library-2.13.15.pom">
|
||||
<sha256 value="f81d6f32917a0e931daa6559a8500be1c62ff8c6c82db071dcdbebf60bbd4786" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang" name="scala-library" version="2.13.16">
|
||||
<artifact name="scala-library-2.13.16.jar">
|
||||
<sha256 value="1ebb2b6f9e4eb4022497c19b1e1e825019c08514f962aaac197145f88ed730f1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-library-2.13.16.pom">
|
||||
<sha256 value="b25b72ba96eb30934868d86d307298d24d3ac154d362e7a4eeb37ba51ba86853" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang" name="scala-reflect" version="2.13.15">
|
||||
<artifact name="scala-reflect-2.13.15.jar">
|
||||
<sha256 value="78d0cc350e1ee42d87c6e11cf5b0dc7bf0b70829c00aa38f27bfb019d439dc11" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-reflect-2.13.15.pom">
|
||||
<sha256 value="aa9cac59324824e5e73dc3456fd3c3ab5f504df63d2f1ddb6413783abb1cecd6" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang.modules" name="scala-asm" version="9.6.0-scala-1">
|
||||
<artifact name="scala-asm-9.6.0-scala-1.jar">
|
||||
<sha256 value="bf16f8b69e89cadab550bce266a052780af7f1eb29dd1c04c3bd014113752c12" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-asm-9.6.0-scala-1.pom">
|
||||
<sha256 value="48bb35622e019293c52c850a528e7bf1c1ba798562ed7829a0b30b37fd38251d" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang.modules" name="scala-parser-combinators_2.13" version="1.1.2">
|
||||
<artifact name="scala-parser-combinators_2.13-1.1.2.jar">
|
||||
<sha256 value="5c285b72e6dc0a98e99ae0a1ceeb4027dab9adfa441844046bd3f19e0efdcb54" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-parser-combinators_2.13-1.1.2.pom">
|
||||
<sha256 value="5c856fefc046a88de0118ac5e45cddf638975fa980c007d242633276f7266f02" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang.modules" name="scala-xml_2.13" version="2.3.0">
|
||||
<artifact name="scala-xml_2.13-2.3.0.jar">
|
||||
<sha256 value="4b4d6698c74bff84a105102bbf58390980dc7bb8c40bdea4bc727040b3f966bd" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-xml_2.13-2.3.0.pom">
|
||||
<sha256 value="9e52b1e093853e146b0b75605af98543219193cad4ea50d07b94465f4afa815c" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scala-lang.modules" name="scala-xml_3" version="2.3.0">
|
||||
<artifact name="scala-xml_3-2.3.0.jar">
|
||||
<sha256 value="3220723238102107ab83182468e5dbe351b081a0601386710ef46c81a95d38d0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scala-xml_3-2.3.0.pom">
|
||||
<sha256 value="b83f69d158032e9a83781a0c0a0f99fa8b929411f7198703734a1213c37f095f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalactic" name="scalactic_3" version="3.2.19">
|
||||
<artifact name="scalactic_3-3.2.19.jar">
|
||||
<sha256 value="26ef71a6d0993301d28d9693bada18ff81b373336b70368fcff01ed4eb4b958e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalactic_3-3.2.19.pom">
|
||||
<sha256 value="af2e7bff0e0e7dfbb175b9f109917307d4cde9c56bed23893cfbe6a336780024" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-compatible" version="3.2.19">
|
||||
<artifact name="scalatest-compatible-3.2.19.jar">
|
||||
<sha256 value="5dc6b8fa5396fe9e1a7c2b72df174a8eb3e92770cdc3e70636d3eba673cd0da3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-compatible-3.2.19.pom">
|
||||
<sha256 value="e7f309922cb6d072bd6098674e72e948c2738c0ac7470a63e20bd15614daa3c0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-core_3" version="3.2.19">
|
||||
<artifact name="scalatest-core_3-3.2.19.jar">
|
||||
<sha256 value="f6e3d38c2034a9cab7313f644d8a933bf1b5241ff35002cc76916a427a826223" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-core_3-3.2.19.pom">
|
||||
<sha256 value="069655a6db966a255690c5d9048d4e799c17026055d60d76869e0103da9c1fdb" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-diagrams_3" version="3.2.19">
|
||||
<artifact name="scalatest-diagrams_3-3.2.19.jar">
|
||||
<sha256 value="835acf8ec2cb0d39beb1052ee2139029fdac28d172fc867db89ff49d640b255e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-diagrams_3-3.2.19.pom">
|
||||
<sha256 value="cbc5724b8607cbc9d3852c5bde9c09c9d29e86ac3a5c396bf112c757cdf048f2" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-featurespec_3" version="3.2.19">
|
||||
<artifact name="scalatest-featurespec_3-3.2.19.jar">
|
||||
<sha256 value="3d49deeede2cd01578e037065862d7734afd3a6330c35dc3c4906f53f57302db" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-featurespec_3-3.2.19.pom">
|
||||
<sha256 value="589b5d533e9080491301c175e510422e98de22c6f92364a4c0dc598a0664ed83" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-flatspec_3" version="3.2.19">
|
||||
<artifact name="scalatest-flatspec_3-3.2.19.jar">
|
||||
<sha256 value="85a6fb2285f20445615c6780a498c3bca99e4c2aad32fab6f74202bdc61e56a9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-flatspec_3-3.2.19.pom">
|
||||
<sha256 value="d5bcba3b01fdb316c9608a397b8af6f60fd4ffe83ee73479ef9b7acc4cf5a770" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-freespec_3" version="3.2.19">
|
||||
<artifact name="scalatest-freespec_3-3.2.19.jar">
|
||||
<sha256 value="ebc8573874766368316366495dcdfe0cca6d8082dc9cc08b5a2fd0834cdaecc0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-freespec_3-3.2.19.pom">
|
||||
<sha256 value="0b64ca3b958c2cc35eff6a082b4654e87b6b20aaf47afd2377c2d830da8d857b" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-funspec_3" version="3.2.19">
|
||||
<artifact name="scalatest-funspec_3-3.2.19.jar">
|
||||
<sha256 value="872b6889fac777aa813d21fb5f1e89710407785a61eb18a570142b6be10389a7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-funspec_3-3.2.19.pom">
|
||||
<sha256 value="25bafeabb74f734eb36ddee6f178c631a65346019d41285844d9ef2895ee2bc1" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-funsuite_3" version="3.2.19">
|
||||
<artifact name="scalatest-funsuite_3-3.2.19.jar">
|
||||
<sha256 value="42129cc156bd8978d9a438abd57001fc42ababf18f6178cbee91d0a9489334e0" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-funsuite_3-3.2.19.pom">
|
||||
<sha256 value="4045d7402436a35bb87baf447427598892f77280a356c5b670352426e4293478" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-matchers-core_3" version="3.2.19">
|
||||
<artifact name="scalatest-matchers-core_3-3.2.19.jar">
|
||||
<sha256 value="723fecdf0ea4542947ef5174068c4e05cd2145a3dcb6ffc797079368c94a187e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-matchers-core_3-3.2.19.pom">
|
||||
<sha256 value="8b1f6a246ff1914f44550f3f98a95293a06b1d3cf9e505f7be1a8fe901620016" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-mustmatchers_3" version="3.2.19">
|
||||
<artifact name="scalatest-mustmatchers_3-3.2.19.jar">
|
||||
<sha256 value="837f76b73ff299fb6748ba0aff4eb7c9d9c00252741ad2bc15af3998d2e0558c" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-mustmatchers_3-3.2.19.pom">
|
||||
<sha256 value="16bff93b9c86d1c43ab945c111167081e80c1968ca541e670b33f2cfe6b35b9e" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-propspec_3" version="3.2.19">
|
||||
<artifact name="scalatest-propspec_3-3.2.19.jar">
|
||||
<sha256 value="6b033e73f3a53717a32a0d4d35ae2021a0afe8a028c42da62fb937932934bce3" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-propspec_3-3.2.19.pom">
|
||||
<sha256 value="fc65de4813534fa43a6de25dc09e76eb51dcc4b507c3fda79242c1851dc2d326" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-refspec_3" version="3.2.19">
|
||||
<artifact name="scalatest-refspec_3-3.2.19.jar">
|
||||
<sha256 value="827b78a65c25a1dc4af747a7711e24c785fae92c39600fd357a7d486fcce2e7a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-refspec_3-3.2.19.pom">
|
||||
<sha256 value="16b9e907ccff48dc7d331bad1a6239dbcb89babdb5c969f99c322a5f7923073a" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-shouldmatchers_3" version="3.2.19">
|
||||
<artifact name="scalatest-shouldmatchers_3-3.2.19.jar">
|
||||
<sha256 value="76ddce37f710ea96bdb3eebcb4bb0a0125fc70fb2ebaa7cc74c9bd28284b6a23" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-shouldmatchers_3-3.2.19.pom">
|
||||
<sha256 value="826ebb218593a34770e1c77834cfe0bb6315fabc8b32406c8d6dbb8b26a05a75" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest-wordspec_3" version="3.2.19">
|
||||
<artifact name="scalatest-wordspec_3-3.2.19.jar">
|
||||
<sha256 value="c6acce0958b086cb857c4da6107f903b6166a46dfa251f54d3a0869212e229c7" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest-wordspec_3-3.2.19.pom">
|
||||
<sha256 value="aec178b094f2176c1ad340be467184176065a3ba04cde4c187947cf750f643de" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scalatest" name="scalatest_3" version="3.2.19">
|
||||
<artifact name="scalatest_3-3.2.19.jar">
|
||||
<sha256 value="cd886ba42615fe0d730dd57197e6ee53eeb062cfd0b4d8c5d9757c977c0fdcf8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="scalatest_3-3.2.19.pom">
|
||||
<sha256 value="b26fcbf4ff2cdbda2654d3da86e7ad7e6fde16ccc46a81ec40247e068ae9326f" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.scoverage" name="org.scoverage.gradle.plugin" version="8.1">
|
||||
<artifact name="org.scoverage.gradle.plugin-8.1.pom">
|
||||
<sha256 value="099b26b0039c24fd4026aabcf0c191fc160bb7881d9e988b7ab480d0d16f85c5" origin="Generated by Gradle" reason="Artifact is not signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.sonarqube" name="org.sonarqube.gradle.plugin" version="7.2.3.7755">
|
||||
<artifact name="org.sonarqube.gradle.plugin-7.2.3.7755.pom">
|
||||
<sha256 value="7b9a2bfb6b7929f789dd2c729569ba7cba5fc8572bd8e9a72c64279da299f0e8" origin="Generated by Gradle" reason="Artifact is not signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.sonatype.oss" name="oss-parent" version="9">
|
||||
<artifact name="oss-parent-9.pom">
|
||||
<pgp value="44FBDBBC1A00FE414F1C1873586654072EAD6677"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="ua.co.k" name="strftime4j" version="1.0.5">
|
||||
<artifact name="strftime4j-1.0.5.jar">
|
||||
<sha256 value="8ee3be181a1d3871d2b14e1e145cbc48918abbbf3596268fdd4b3d7292b07fc9" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
<artifact name="strftime4j-1.0.5.pom">
|
||||
<sha256 value="df50d06823a4f87c3fd739ebd1718f09bc126bb8206dfde8c477b3a816edf500" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
|
||||
</artifact>
|
||||
</component>
|
||||
</components>
|
||||
</verification-metadata>
|
||||
@@ -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,13 @@
|
||||
## (2026-03-27)
|
||||
## (2026-03-28)
|
||||
## (2026-03-28)
|
||||
## (2026-03-29)
|
||||
## (2026-03-31)
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
## (2026-04-01)
|
||||
## (2026-04-02)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
@@ -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=1
|
||||
PATCH=0
|
||||
@@ -0,0 +1,167 @@
|
||||
## (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))
|
||||
## (2026-04-02)
|
||||
|
||||
### 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-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
* 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))
|
||||
@@ -23,7 +23,7 @@ scoverage {
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("de.nowchess.chess.chessMain")
|
||||
mainClass.set("de.nowchess.chess.Main")
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
@@ -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,10 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.controller.GameController
|
||||
|
||||
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(Board.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,9 +1,8 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.board.{Board, Color, Piece}
|
||||
import de.nowchess.chess.logic.{MoveValidator, GameRules, PositionStatus}
|
||||
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
|
||||
@@ -11,15 +10,23 @@ import de.nowchess.chess.view.Renderer
|
||||
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case object Quit extends MoveResult
|
||||
case class InvalidFormat(raw: String) extends MoveResult
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove extends MoveResult
|
||||
case class Moved(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newBoard: Board, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Checkmate(winner: Color) extends MoveResult
|
||||
case object Stalemate extends MoveResult
|
||||
case object Quit extends MoveResult
|
||||
case class InvalidFormat(raw: String) extends MoveResult
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove 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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
@@ -27,73 +34,73 @@ object MoveResult:
|
||||
|
||||
object GameController:
|
||||
|
||||
/** Pure function: interprets one raw input line against the current board state.
|
||||
/** 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(board: Board, 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)) =>
|
||||
board.pieceAt(from) match
|
||||
case None =>
|
||||
MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn =>
|
||||
MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !MoveValidator.isLegal(board, from, to) then
|
||||
MoveResult.IllegalMove
|
||||
else
|
||||
val (newBoard, captured) = board.withMove(from, to)
|
||||
GameRules.gameStatus(newBoard, turn.opposite) match
|
||||
case PositionStatus.Normal => MoveResult.Moved(newBoard, captured, turn.opposite)
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, 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)
|
||||
|
||||
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
|
||||
* prints the outcome, and recurses until the game ends.
|
||||
/** Apply a previously detected promotion move with the chosen piece.
|
||||
* Called after processMove returned PromotionRequired.
|
||||
*/
|
||||
def gameLoop(board: Board, turn: Color): Unit =
|
||||
println()
|
||||
print(Renderer.render(board))
|
||||
println(s"${turn.label}'s turn. Enter move: ")
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
processMove(board, 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(board, turn)
|
||||
case MoveResult.NoPiece =>
|
||||
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.WrongColor =>
|
||||
println(s"That is not your piece.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.IllegalMove =>
|
||||
println(s"Illegal move.")
|
||||
gameLoop(board, turn)
|
||||
case MoveResult.Moved(newBoard, 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(newBoard, newTurn)
|
||||
case MoveResult.MovedInCheck(newBoard, 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(newBoard, newTurn)
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
println(s"Checkmate! ${winner.label} wins.")
|
||||
gameLoop(Board.initial, Color.White)
|
||||
case MoveResult.Stalemate =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
gameLoop(Board.initial, Color.White)
|
||||
def completePromotion(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
from: Square,
|
||||
to: Square,
|
||||
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)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
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))
|
||||
@@ -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,6 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
@@ -19,13 +20,17 @@ object GameRules:
|
||||
}
|
||||
|
||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
||||
def legalMoves(board: Board, color: Color): Set[(Square, Square)] =
|
||||
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(board, from)
|
||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
||||
.filter { to =>
|
||||
val (newBoard, _) = board.withMove(from, to)
|
||||
val newBoard =
|
||||
if MoveValidator.isCastle(board, from, to) then
|
||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
else
|
||||
board.withMove(from, to)._1
|
||||
!isInCheck(newBoard, color)
|
||||
}
|
||||
.map(to => from -> to)
|
||||
@@ -33,8 +38,8 @@ object GameRules:
|
||||
.toSet
|
||||
|
||||
/** Position status for the side whose turn it is (`color`). */
|
||||
def gameStatus(board: Board, color: Color): PositionStatus =
|
||||
val moves = legalMoves(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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
|
||||
object MoveValidator:
|
||||
|
||||
@@ -72,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)
|
||||
|
||||
@@ -110,3 +111,73 @@ object MoveValidator:
|
||||
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
|
||||
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
|
||||
.toSet
|
||||
|
||||
// ── Castling helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
|
||||
board.pieces.exists { case (from, piece) =>
|
||||
piece.color == attackerColor && legalTargets(board, from).contains(sq)
|
||||
}
|
||||
|
||||
def isCastle(board: Board, from: Square, to: Square): Boolean =
|
||||
board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
|
||||
math.abs(to.file.ordinal - from.file.ordinal) == 2
|
||||
|
||||
def castleSide(from: Square, to: Square): CastleSide =
|
||||
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
|
||||
|
||||
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 !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))
|
||||
|
||||
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))
|
||||
|
||||
kingsideSq.toSet ++ queensideSq.toSet
|
||||
|
||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
||||
board.pieceAt(from) match
|
||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
||||
case Some(piece) if piece.pieceType == PieceType.Pawn =>
|
||||
pawnTargets(board, history, from, piece.color)
|
||||
case _ =>
|
||||
legalTargets(board, from)
|
||||
|
||||
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
|
||||