Compare commits
5 Commits
baed78fea3
...
api-0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| f4c18d22d7 | |||
| 4d800e88eb | |||
| 2df2fdeeb9 | |||
| 9190d1e5a0 | |||
| d675966436 |
@@ -1,120 +0,0 @@
|
||||
# Claude Code – Working Agreement
|
||||
|
||||
## Workflow: Plan → Write Tests → Implement → Verify
|
||||
|
||||
### 1. Plan First
|
||||
Before writing any code, produce an explicit plan:
|
||||
- Restate the requirement in your own words to confirm understanding.
|
||||
- List every file you intend to create or modify.
|
||||
- Identify risks or unknowns upfront.
|
||||
- Wait for confirmation **only** when the plan reveals an ambiguity that cannot be resolved from context. Otherwise proceed immediately.
|
||||
|
||||
### 2. Write Tests
|
||||
Before implementing, write tests that should cover the new behaviour.
|
||||
Only write tests for the new behaviour.
|
||||
|
||||
### 3. Implement
|
||||
Follow the plan. Do not add scope beyond what was agreed.
|
||||
|
||||
### 4. Verify Every Requirement
|
||||
After implementation, go through each requirement one-by-one and confirm it is satisfied:
|
||||
- Run the relevant tests (unit, integration, or build check) for every changed module.
|
||||
- If a requirement **cannot** be fulfilled, do **not** silently skip it — document it immediately (see *Unresolved Requirements* below).
|
||||
|
||||
---
|
||||
|
||||
## No Code Without Verification (Testing)
|
||||
|
||||
- Every new behaviour must be covered by at least one automated test before the task is considered done.
|
||||
- Every bug fix must be accompanied by a regression test that fails before the fix and passes after.
|
||||
- Run `./gradlew :modules:<module>:test` (or the appropriate Gradle task) and confirm a green build before marking work complete.
|
||||
- If a test cannot be written for a legitimate reason, document it in `docs/unresolved.md` with an explanation.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Bug Fixing
|
||||
|
||||
- When a test or build step fails, attempt to fix the root cause immediately — do **not** ask for permission.
|
||||
- Apply the fix, re-run the verification, and continue until green.
|
||||
- If the same failure persists after **three** fix attempts, stop, log the issue in `docs/unresolved.md`, and surface a concise summary.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Requirements → `docs/unresolved.md`
|
||||
|
||||
When a requirement or bug cannot be resolved, append an entry to `docs/unresolved.md`:
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] <Short title>
|
||||
|
||||
**Requirement / Bug:**
|
||||
<What was requested or what failed>
|
||||
|
||||
**Root Cause (if known):**
|
||||
<Why it cannot be resolved right now>
|
||||
|
||||
**Attempted Fixes:**
|
||||
1. <What was tried>
|
||||
2. …
|
||||
|
||||
**Suggested Next Step:**
|
||||
<What a human engineer should investigate>
|
||||
```
|
||||
|
||||
Create the file if it does not exist. Never delete existing entries.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
. ← Repository root (multi-project Gradle setup)
|
||||
├── build.gradle.kts ← Root build file (shared plugins, dependency versions)
|
||||
├── settings.gradle.kts ← Gradle settings (declares all subprojects)
|
||||
├── modules/ ← One subdirectory per microservice
|
||||
│ └── <service>/
|
||||
│ ├── build.gradle.kts
|
||||
│ └── src/
|
||||
└── docs/ ← Architecture Decision Records, API docs, unresolved issues
|
||||
└── unresolved.md
|
||||
```
|
||||
|
||||
### Conventions
|
||||
- All microservices live under `modules/{service-name}`. Never place service code in the root.
|
||||
- Shared configuration (dependency versions, plugin setup) belongs in the **root** `build.gradle.kts` or in `buildSrc` / a version catalog.
|
||||
- `settings.gradle.kts` must include every module via `include(":modules:<service>")`.
|
||||
- Architecture decisions go in `docs/adr/` as numbered Markdown files (`ADR-001-<title>.md`).
|
||||
- API contracts live in `/docs/api/`.
|
||||
- Unit tests extend `AnyFunSuite with Matchers` — no `@Test` annotations, no `: Unit` requirement
|
||||
- Integration tests use `@QuarkusTest` with JUnit 5 — `@Test` methods must be explicitly typed `: Unit`
|
||||
- Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts
|
||||
|
||||
## Coverage Conventions
|
||||
- Branch coverage must be at least 90% - unless there is a good reason not to.
|
||||
- Line coverage must be at least 95% - unless there is a good reason not to.
|
||||
- Method coverage must be at least 90% - unless there is a good reason not to.
|
||||
- To check coverage use jacoco-reporter/scoverage_coverage_gaps.py modules/{service}/build/reports/scoverageTest/scoverage.xml
|
||||
- IMPORTANT: modules/{service}/build/reports/scoverage/scoverage.xml is not used for coverage TEST calculation. Do not use it.
|
||||
|
||||
## Agent Routing Rules
|
||||
|
||||
### Use agents in PARALLEL when:
|
||||
- Tasks touch different, independent microservices
|
||||
- No shared files or state between tasks
|
||||
- Example: "implement service-user AND service-orders simultaneously"
|
||||
|
||||
### Use agents SEQUENTIALLY when:
|
||||
- Tasks have dependencies (architect → implementer → test-writer)
|
||||
- Shared API contracts are involved
|
||||
- Example: design API first, then implement, then test
|
||||
|
||||
## Quick-Reference Checklist
|
||||
|
||||
Before considering any task done, confirm:
|
||||
|
||||
- [ ] Plan was written and requirements restated
|
||||
- [ ] All planned files were created / modified
|
||||
- [ ] Automated tests cover the new behaviour
|
||||
- [ ] `./gradlew build` (or scoped task) is green
|
||||
- [ ] Each requirement has been explicitly verified
|
||||
- [ ] Any unresolved items are logged in `docs/unresolved.md`
|
||||
@@ -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
|
||||
@@ -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 ≥ 95% · Branch ≥ 90% · Method ≥ 90% (document exceptions)
|
||||
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
|
||||
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
|
||||
|
||||
- Multi-module Gradle project; every service lives under `modules/{service-name}`.
|
||||
- Shared dependency versions live in the root `build.gradle.kts` under `extra["VERSIONS"]`.
|
||||
- Each module reads versions via `rootProject.extra["VERSIONS"] as Map<String, String>`.
|
||||
- `settings.gradle.kts` must `include(":modules:<service>")` for every module.
|
||||
## Bug Fixing
|
||||
Fix failures immediately without asking. After 3 failed attempts → log in `docs/unresolved.md` + surface summary.
|
||||
|
||||
### Stack (ADR-001)
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Language | Scala 3.5.x |
|
||||
| Backend framework | Quarkus + `quarkus-scala3` extension |
|
||||
| Persistence | Hibernate / Jakarta Persistence |
|
||||
| Frontend (TBD) | Vite; React/Angular/Vue under evaluation |
|
||||
| TUI | Lanterna |
|
||||
| Container orchestration | Kubernetes + ArgoCD + Kargo |
|
||||
## Agents (new service)
|
||||
Sequential: architect → scala-implementer → test-writer → gradle-builder → code-reviewer (review only, no self-fix)
|
||||
Parallel: only when services are fully independent (no shared contracts/state).
|
||||
|
||||
### Key Scala 3 / Quarkus Rules
|
||||
- Use `given`/`using`, not `implicit` (no Scala 2 idioms).
|
||||
- Use `Option`/`Either`/`Try`, never `null` or `.get`.
|
||||
- Jakarta annotations only (`jakarta.*`), never `javax.*`.
|
||||
- Use reactive types (`Uni`, `Multi`) for I/O; no blocking calls on the event loop.
|
||||
- **Always exclude `org.scala-lang:scala-library` from Quarkus BOM** to avoid Scala 2 conflicts.
|
||||
- **Unit tests use `extends AnyFunSuite with Matchers`** — ScalaTest DSL, no `@Test` annotations needed.
|
||||
- **Integration tests use `@QuarkusTest` with JUnit 5** — explicit `: Unit` return type still required on `@Test` methods.
|
||||
## Unresolved (`docs/unresolved.md`)
|
||||
Append only, never delete:
|
||||
```
|
||||
## [YYYY-MM-DD] <title>
|
||||
**Requirement/Bug:** **Root Cause:** **Attempted Fixes:** **Next Step:**
|
||||
```
|
||||
|
||||
### Agent Workflow (for new services)
|
||||
1. **architect** → writes OpenAPI contract to `docs/api/{service}.yaml` and ADR to `docs/adr/`.
|
||||
2. **scala-implementer** → reads contract, implements service under `modules/{service}/`.
|
||||
3. **test-writer** → writes `@QuarkusTest` integration tests and `AnyFunSuite with Matchers` unit tests.
|
||||
4. **gradle-builder** → resolves any build/dependency issues.
|
||||
5. **code-reviewer** → reviews; reports findings back without self-fixing.
|
||||
|
||||
Detailed working agreement (plan/verify/unresolved workflow) is in `.claude/CLAUDE.MD`.
|
||||
## Done Checklist
|
||||
- [ ] Plan written · files created/modified · tests green · requirements verified · unresolved logged
|
||||
|
||||
@@ -1,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()
|
||||
@@ -0,0 +1,2 @@
|
||||
## (2026-03-27)
|
||||
## (2026-03-28)
|
||||
@@ -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 =
|
||||
|
||||
@@ -100,3 +100,23 @@ class BoardTest extends AnyFunSuite with Matchers:
|
||||
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,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=2
|
||||
@@ -0,0 +1,30 @@
|
||||
## (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))
|
||||
@@ -1,11 +1,12 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.Board
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.chess.controller.GameController
|
||||
import de.nowchess.chess.logic.GameContext
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
object Main {
|
||||
def main(args: Array[String]): Unit =
|
||||
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ package de.nowchess.chess.controller
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{GameContext, MoveValidator, GameRules, PositionStatus, CastleSide, withCastle}
|
||||
import de.nowchess.chess.logic.*
|
||||
import de.nowchess.chess.view.Renderer
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -17,8 +16,8 @@ object MoveResult:
|
||||
case object NoPiece extends MoveResult
|
||||
case object WrongColor extends MoveResult
|
||||
case object IllegalMove extends MoveResult
|
||||
case class Moved(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newCtx: GameContext, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
|
||||
case class Checkmate(winner: Color) extends MoveResult
|
||||
case object Stalemate extends MoveResult
|
||||
|
||||
@@ -31,7 +30,7 @@ object GameController:
|
||||
/** Pure function: interprets one raw input line against the current game context.
|
||||
* Has no I/O side effects — all output must be handled by the caller.
|
||||
*/
|
||||
def processMove(ctx: GameContext, turn: Color, raw: String): MoveResult =
|
||||
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||
raw.trim match
|
||||
case "quit" | "q" =>
|
||||
MoveResult.Quit
|
||||
@@ -40,97 +39,67 @@ object GameController:
|
||||
case None =>
|
||||
MoveResult.InvalidFormat(trimmed)
|
||||
case Some((from, to)) =>
|
||||
ctx.board.pieceAt(from) match
|
||||
board.pieceAt(from) match
|
||||
case None =>
|
||||
MoveResult.NoPiece
|
||||
case Some(piece) if piece.color != turn =>
|
||||
MoveResult.WrongColor
|
||||
case Some(_) =>
|
||||
if !MoveValidator.isLegal(ctx, from, to) then
|
||||
if !MoveValidator.isLegal(board, history, from, to) then
|
||||
MoveResult.IllegalMove
|
||||
else
|
||||
val castleOpt = if MoveValidator.isCastle(ctx.board, from, to)
|
||||
val castleOpt = if MoveValidator.isCastle(board, from, to)
|
||||
then Some(MoveValidator.castleSide(from, to))
|
||||
else None
|
||||
val (newBoard, captured) = castleOpt match
|
||||
case Some(side) => (ctx.board.withCastle(turn, side), None)
|
||||
case None => ctx.board.withMove(from, to)
|
||||
val newCtx = applyRightsRevocation(
|
||||
ctx.copy(board = newBoard), turn, from, to, castleOpt
|
||||
)
|
||||
GameRules.gameStatus(newCtx, turn.opposite) match
|
||||
case PositionStatus.Normal => MoveResult.Moved(newCtx, captured, turn.opposite)
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newCtx, captured, turn.opposite)
|
||||
case Some(side) => (board.withCastle(turn, side), None)
|
||||
case None => board.withMove(from, to)
|
||||
val newHistory = history.addMove(from, to, castleOpt)
|
||||
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
|
||||
|
||||
private def applyRightsRevocation(
|
||||
ctx: GameContext,
|
||||
turn: Color,
|
||||
from: Square,
|
||||
to: Square,
|
||||
castle: Option[CastleSide]
|
||||
): GameContext =
|
||||
// Step 1: Revoke all rights for a castling move (idempotent with step 2)
|
||||
val ctx0 = castle.fold(ctx)(_ => ctx.withUpdatedRights(turn, CastlingRights.None))
|
||||
|
||||
// Step 2: Source-square revocation
|
||||
val ctx1 = from match
|
||||
case Square(File.E, Rank.R1) => ctx0.withUpdatedRights(Color.White, CastlingRights.None)
|
||||
case Square(File.E, Rank.R8) => ctx0.withUpdatedRights(Color.Black, CastlingRights.None)
|
||||
case Square(File.A, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R1) => ctx0.withUpdatedRights(Color.White, ctx0.whiteCastling.copy(kingSide = false))
|
||||
case Square(File.A, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R8) => ctx0.withUpdatedRights(Color.Black, ctx0.blackCastling.copy(kingSide = false))
|
||||
case _ => ctx0
|
||||
|
||||
// Step 3: Destination-square revocation (enemy captures a rook on its home square)
|
||||
to match
|
||||
case Square(File.A, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R1) => ctx1.withUpdatedRights(Color.White, ctx1.whiteCastling.copy(kingSide = false))
|
||||
case Square(File.A, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(queenSide = false))
|
||||
case Square(File.H, Rank.R8) => ctx1.withUpdatedRights(Color.Black, ctx1.blackCastling.copy(kingSide = false))
|
||||
case _ => ctx1
|
||||
|
||||
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
|
||||
* prints the outcome, and recurses until the game ends.
|
||||
*/
|
||||
def gameLoop(ctx: GameContext, turn: Color): Unit =
|
||||
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
|
||||
println()
|
||||
print(Renderer.render(ctx.board))
|
||||
print(Renderer.render(board))
|
||||
println(s"${turn.label}'s turn. Enter move: ")
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
processMove(ctx, turn, input) match
|
||||
processMove(board, history, turn, input) match
|
||||
case MoveResult.Quit =>
|
||||
println("Game over. Goodbye!")
|
||||
case MoveResult.InvalidFormat(raw) =>
|
||||
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
|
||||
gameLoop(ctx, turn)
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.NoPiece =>
|
||||
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
|
||||
gameLoop(ctx, turn)
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.WrongColor =>
|
||||
println(s"That is not your piece.")
|
||||
gameLoop(ctx, turn)
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.IllegalMove =>
|
||||
println(s"Illegal move.")
|
||||
gameLoop(ctx, turn)
|
||||
case MoveResult.Moved(newCtx, captured, newTurn) =>
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
gameLoop(newCtx, newTurn)
|
||||
case MoveResult.MovedInCheck(newCtx, captured, newTurn) =>
|
||||
gameLoop(newBoard, newHistory, newTurn)
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
println(s"${newTurn.label} is in check!")
|
||||
gameLoop(newCtx, newTurn)
|
||||
gameLoop(newBoard, newHistory, newTurn)
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
println(s"Checkmate! ${winner.label} wins.")
|
||||
gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
case MoveResult.Stalemate =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,47 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
|
||||
enum CastleSide:
|
||||
case Kingside, Queenside
|
||||
|
||||
case class GameContext(
|
||||
board: Board,
|
||||
whiteCastling: CastlingRights,
|
||||
blackCastling: CastlingRights
|
||||
):
|
||||
def castlingFor(color: Color): CastlingRights =
|
||||
if color == Color.White then whiteCastling else blackCastling
|
||||
|
||||
def withUpdatedRights(color: Color, rights: CastlingRights): GameContext =
|
||||
if color == Color.White then copy(whiteCastling = rights)
|
||||
else copy(blackCastling = rights)
|
||||
|
||||
object GameContext:
|
||||
/** Convenience constructor for test boards: no castling rights on either side. */
|
||||
def apply(board: Board): GameContext =
|
||||
GameContext(board, CastlingRights.None, CastlingRights.None)
|
||||
|
||||
val initial: GameContext =
|
||||
GameContext(Board.initial, CastlingRights.Both, CastlingRights.Both)
|
||||
|
||||
extension (b: Board)
|
||||
def withCastle(color: Color, side: CastleSide): Board =
|
||||
val (kingFrom, kingTo, rookFrom, rookTo) = (color, side) match
|
||||
case (Color.White, CastleSide.Kingside) =>
|
||||
(Square(File.E, Rank.R1), Square(File.G, Rank.R1),
|
||||
Square(File.H, Rank.R1), Square(File.F, Rank.R1))
|
||||
case (Color.White, CastleSide.Queenside) =>
|
||||
(Square(File.E, Rank.R1), Square(File.C, Rank.R1),
|
||||
Square(File.A, Rank.R1), Square(File.D, Rank.R1))
|
||||
case (Color.Black, CastleSide.Kingside) =>
|
||||
(Square(File.E, Rank.R8), Square(File.G, Rank.R8),
|
||||
Square(File.H, Rank.R8), Square(File.F, Rank.R8))
|
||||
case (Color.Black, CastleSide.Queenside) =>
|
||||
(Square(File.E, Rank.R8), Square(File.C, Rank.R8),
|
||||
Square(File.A, Rank.R8), Square(File.D, Rank.R8))
|
||||
val king = Piece(color, PieceType.King)
|
||||
val rook = Piece(color, PieceType.Rook)
|
||||
Board(b.pieces.removed(kingFrom).removed(rookFrom)
|
||||
.updated(kingTo, king).updated(rookTo, rook))
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.Square
|
||||
|
||||
/** 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]
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves. */
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty):
|
||||
def addMove(move: HistoryMove): GameHistory =
|
||||
GameHistory(moves :+ move)
|
||||
|
||||
def addMove(from: Square, to: Square): GameHistory =
|
||||
addMove(HistoryMove(from, to, None))
|
||||
|
||||
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
|
||||
addMove(HistoryMove(from, to, castleSide))
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.GameContext
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
enum PositionStatus:
|
||||
case Normal, InCheck, Mated, Drawn
|
||||
@@ -20,17 +20,17 @@ object GameRules:
|
||||
}
|
||||
|
||||
/** All (from, to) moves for `color` that do not leave their own king in check. */
|
||||
def legalMoves(ctx: GameContext, color: Color): Set[(Square, Square)] =
|
||||
ctx.board.pieces
|
||||
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
|
||||
board.pieces
|
||||
.collect { case (from, piece) if piece.color == color => from }
|
||||
.flatMap { from =>
|
||||
MoveValidator.legalTargets(ctx, from) // context-aware: includes castling
|
||||
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
|
||||
.filter { to =>
|
||||
val newBoard =
|
||||
if MoveValidator.isCastle(ctx.board, from, to) then
|
||||
ctx.board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
if MoveValidator.isCastle(board, from, to) then
|
||||
board.withCastle(color, MoveValidator.castleSide(from, to))
|
||||
else
|
||||
ctx.board.withMove(from, to)._1
|
||||
board.withMove(from, to)._1
|
||||
!isInCheck(newBoard, color)
|
||||
}
|
||||
.map(to => from -> to)
|
||||
@@ -38,9 +38,9 @@ object GameRules:
|
||||
.toSet
|
||||
|
||||
/** Position status for the side whose turn it is (`color`). */
|
||||
def gameStatus(ctx: GameContext, color: Color): PositionStatus =
|
||||
val moves = legalMoves(ctx, color)
|
||||
val inCheck = isInCheck(ctx.board, color)
|
||||
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
|
||||
val moves = legalMoves(board, history, color)
|
||||
val inCheck = isInCheck(board, color)
|
||||
if moves.isEmpty && inCheck then PositionStatus.Mated
|
||||
else if moves.isEmpty then PositionStatus.Drawn
|
||||
else if inCheck then PositionStatus.InCheck
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.chess.logic.{GameContext, CastleSide}
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
|
||||
object MoveValidator:
|
||||
|
||||
@@ -126,42 +126,37 @@ object MoveValidator:
|
||||
def castleSide(from: Square, to: Square): CastleSide =
|
||||
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
|
||||
|
||||
def castlingTargets(ctx: GameContext, color: Color): Set[Square] =
|
||||
val rights = ctx.castlingFor(color)
|
||||
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
val rank = if color == Color.White then Rank.R1 else Rank.R8
|
||||
val kingSq = Square(File.E, rank)
|
||||
val enemy = color.opposite
|
||||
|
||||
if ctx.board.pieceAt(kingSq) != Some(Piece(color, PieceType.King)) then return Set.empty
|
||||
if GameRules.isInCheck(ctx.board, color) then return Set.empty
|
||||
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
|
||||
GameRules.isInCheck(board, color) then Set.empty
|
||||
else
|
||||
val kingsideSq = Option.when(
|
||||
rights.kingSide &&
|
||||
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.G, rank))
|
||||
|
||||
var result = Set.empty[Square]
|
||||
val queensideSq = Option.when(
|
||||
rights.queenSide &&
|
||||
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
|
||||
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
|
||||
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
|
||||
)(Square(File.C, rank))
|
||||
|
||||
if rights.kingSide then
|
||||
val rookSq = Square(File.H, rank)
|
||||
val transit = List(Square(File.F, rank), Square(File.G, rank))
|
||||
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
|
||||
transit.forall(s => ctx.board.pieceAt(s).isEmpty) &&
|
||||
!transit.exists(s => isAttackedBy(ctx.board, s, enemy)) then
|
||||
result += Square(File.G, rank)
|
||||
kingsideSq.toSet ++ queensideSq.toSet
|
||||
|
||||
if rights.queenSide then
|
||||
val rookSq = Square(File.A, rank)
|
||||
val emptySquares = List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank))
|
||||
val transitSqs = List(Square(File.D, rank), Square(File.C, rank))
|
||||
if ctx.board.pieceAt(rookSq).contains(Piece(color, PieceType.Rook)) &&
|
||||
emptySquares.forall(s => ctx.board.pieceAt(s).isEmpty) &&
|
||||
!transitSqs.exists(s => isAttackedBy(ctx.board, s, enemy)) then
|
||||
result += Square(File.C, rank)
|
||||
|
||||
result
|
||||
|
||||
def legalTargets(ctx: GameContext, from: Square): Set[Square] =
|
||||
ctx.board.pieceAt(from) match
|
||||
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
|
||||
board.pieceAt(from) match
|
||||
case Some(piece) if piece.pieceType == PieceType.King =>
|
||||
legalTargets(ctx.board, from) ++ castlingTargets(ctx, piece.color)
|
||||
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
|
||||
case _ =>
|
||||
legalTargets(ctx.board, from)
|
||||
legalTargets(board, from)
|
||||
|
||||
def isLegal(ctx: GameContext, from: Square, to: Square): Boolean =
|
||||
legalTargets(ctx, from).contains(to)
|
||||
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
|
||||
legalTargets(board, history, from).contains(to)
|
||||
|
||||
@@ -11,21 +11,18 @@ object Renderer:
|
||||
private val AnsiBlackPiece = "\u001b[30m" // black text
|
||||
|
||||
def render(board: Board): String =
|
||||
val sb = new StringBuilder
|
||||
sb.append(" a b c d e f g h\n")
|
||||
for rank <- (0 until 8).reverse do
|
||||
sb.append(s"${rank + 1} ")
|
||||
for file <- 0 until 8 do
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
val cellContent = board.pieceAt(sq) match
|
||||
val rows = (0 until 8).reverse.map { rank =>
|
||||
val cells = (0 until 8).map { file =>
|
||||
val sq = Square(File.values(file), Rank.values(rank))
|
||||
val isLightSq = (file + rank) % 2 != 0
|
||||
val bgColor = if isLightSq then AnsiLightSquare else AnsiDarkSquare
|
||||
board.pieceAt(sq) match
|
||||
case Some(piece) =>
|
||||
val fgColor = if piece.color == Color.White then AnsiWhitePiece else AnsiBlackPiece
|
||||
s"$bgColor$fgColor ${piece.unicode} $AnsiReset"
|
||||
case None =>
|
||||
s"$bgColor $AnsiReset"
|
||||
sb.append(cellContent)
|
||||
sb.append(s" ${rank + 1}\n")
|
||||
sb.append(" a b c d e f g h\n")
|
||||
sb.toString
|
||||
}.mkString
|
||||
s"${rank + 1} $cells ${rank + 1}"
|
||||
}.mkString("\n")
|
||||
s" a b c d e f g h\n$rows\n a b c d e f g h\n"
|
||||
|
||||
+127
-167
@@ -2,7 +2,7 @@ package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{GameContext, CastleSide}
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -11,54 +11,61 @@ import java.io.ByteArrayInputStream
|
||||
class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private val initial = GameContext.initial
|
||||
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
|
||||
GameController.processMove(board, history, turn, raw)
|
||||
|
||||
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
|
||||
GameController.gameLoop(board, history, turn)
|
||||
|
||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
|
||||
// ──── processMove ────────────────────────────────────────────────────
|
||||
|
||||
test("processMove: 'quit' input returns Quit"):
|
||||
GameController.processMove(initial, Color.White, "quit") shouldBe MoveResult.Quit
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: 'q' input returns Quit"):
|
||||
GameController.processMove(initial, Color.White, "q") shouldBe MoveResult.Quit
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: quit with surrounding whitespace returns Quit"):
|
||||
GameController.processMove(initial, Color.White, " quit ") shouldBe MoveResult.Quit
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
|
||||
|
||||
test("processMove: unparseable input returns InvalidFormat"):
|
||||
GameController.processMove(initial, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
|
||||
|
||||
test("processMove: valid format but empty square returns NoPiece"):
|
||||
// E3 is empty in the initial position
|
||||
GameController.processMove(initial, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
|
||||
|
||||
test("processMove: piece of wrong color returns WrongColor"):
|
||||
// E7 has a Black pawn; it is White's turn
|
||||
GameController.processMove(initial, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
|
||||
|
||||
test("processMove: geometrically illegal move returns IllegalMove"):
|
||||
// White pawn at E2 cannot jump three squares to E5
|
||||
GameController.processMove(initial, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
|
||||
|
||||
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
|
||||
GameController.processMove(initial, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(newCtx, captured, newTurn) =>
|
||||
newCtx.board.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
||||
newCtx.board.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
|
||||
newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None
|
||||
captured shouldBe None
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: legal capture returns Moved with the captured piece"):
|
||||
val captureCtx = GameContext(Board(Map(
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.H, Rank.R1) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
||||
)))
|
||||
GameController.processMove(captureCtx, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(newCtx, captured, newTurn) =>
|
||||
))
|
||||
processMove(board, GameHistory.empty, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
captured shouldBe Some(Piece.BlackPawn)
|
||||
newCtx.board.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
||||
newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn)
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
@@ -70,33 +77,33 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("gameLoop: 'quit' exits cleanly without exception"):
|
||||
withInput("quit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: EOF (null readLine) exits via quit fallback"):
|
||||
withInput(""):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: invalid format prints message and recurses until quit"):
|
||||
withInput("badmove\nquit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: NoPiece prints message and recurses until quit"):
|
||||
// E3 is empty in the initial position
|
||||
withInput("e3e4\nquit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: WrongColor prints message and recurses until quit"):
|
||||
// E7 has a Black pawn; it is White's turn
|
||||
withInput("e7e6\nquit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: IllegalMove prints message and recurses until quit"):
|
||||
withInput("e2e5\nquit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: legal non-capture move recurses with new board then quits"):
|
||||
withInput("e2e4\nquit\n"):
|
||||
GameController.gameLoop(GameContext.initial, Color.White)
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: capture move prints capture message then recurses and quits"):
|
||||
val captureBoard = Board(Map(
|
||||
@@ -106,7 +113,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteKing
|
||||
))
|
||||
withInput("e5d6\nquit\n"):
|
||||
GameController.gameLoop(GameContext(captureBoard), Color.White)
|
||||
gameLoop(captureBoard, GameHistory.empty, Color.White)
|
||||
|
||||
// ──── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -120,37 +127,37 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
||||
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
|
||||
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
|
||||
val ctx = GameContext(Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)))
|
||||
GameController.processMove(ctx, Color.White, "a1a8") match
|
||||
case MoveResult.MovedInCheck(_, _, newTurn) => newTurn shouldBe Color.Black
|
||||
))
|
||||
processMove(b, GameHistory.empty, 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 position)
|
||||
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
|
||||
val ctx = GameContext(Board(Map(
|
||||
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(ctx, Color.White, "a1h8") match
|
||||
))
|
||||
processMove(b, GameHistory.empty, 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 position)
|
||||
val ctx = GameContext(Board(Map(
|
||||
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(ctx, Color.White, "b1b6") match
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "b1b6") match
|
||||
case MoveResult.Stalemate => succeed
|
||||
case other => fail(s"Expected Stalemate, got $other")
|
||||
|
||||
@@ -165,7 +172,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1h8\nquit\n"):
|
||||
GameController.gameLoop(GameContext(b), Color.White)
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Checkmate! White wins.")
|
||||
|
||||
test("gameLoop: stalemate prints draw message and resets to new game"):
|
||||
@@ -176,7 +183,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("b1b6\nquit\n"):
|
||||
GameController.gameLoop(GameContext(b), Color.White)
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Stalemate! The game is a draw.")
|
||||
|
||||
test("gameLoop: MovedInCheck without capture prints check message"):
|
||||
@@ -187,7 +194,7 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
GameController.gameLoop(GameContext(b), Color.White)
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Black is in check!")
|
||||
|
||||
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
|
||||
@@ -200,208 +207,161 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
GameController.gameLoop(GameContext(b), Color.White)
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("captures")
|
||||
output should include("Black is in check!")
|
||||
|
||||
// ──── castling execution ─────────────────────────────────────────────
|
||||
|
||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(newCtx, captured, newTurn) =>
|
||||
newCtx.board.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newCtx.board.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
newCtx.board.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
newCtx.board.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
||||
captured shouldBe None
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1c1") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.board.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newCtx.board.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1c1") match
|
||||
case MoveResult.Moved(newBoard, _, _, _) =>
|
||||
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
// ──── rights revocation ──────────────────────────────────────────────
|
||||
|
||||
test("processMove: e1g1 revokes both white castling rights"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.whiteCastling shouldBe CastlingRights.None
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: moving rook from h1 revokes white kingside right"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "h1h4") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.kingSide shouldBe false
|
||||
newCtx.whiteCastling.queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.kingSide shouldBe false
|
||||
newCtx.whiteCastling.queenSide shouldBe true
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "h1h4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving king from e1 revokes both white rights"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1e2") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.whiteCastling shouldBe CastlingRights.None
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1e2") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: enemy capture on h1 revokes white kingside right"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.Black, "h2h1") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.kingSide shouldBe false
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.kingSide shouldBe false
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).kingSide shouldBe false
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: castle attempt when rights revoked returns IllegalMove"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.None,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
))
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
|
||||
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
|
||||
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
|
||||
|
||||
test("processMove: moving king from e8 revokes both black rights"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.None,
|
||||
blackCastling = CastlingRights.Both
|
||||
)
|
||||
GameController.processMove(ctx, Color.Black, "e8e7") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.blackCastling shouldBe CastlingRights.None
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.blackCastling shouldBe CastlingRights.None
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving rook from a8 revokes black queenside right"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.None,
|
||||
blackCastling = CastlingRights.Both
|
||||
)
|
||||
GameController.processMove(ctx, Color.Black, "a8a7") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.blackCastling.queenSide shouldBe false
|
||||
newCtx.blackCastling.kingSide shouldBe true
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.blackCastling.queenSide shouldBe false
|
||||
newCtx.blackCastling.kingSide shouldBe true
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: moving rook from h8 revokes black kingside right"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.None,
|
||||
blackCastling = CastlingRights.Both
|
||||
)
|
||||
GameController.processMove(ctx, Color.Black, "h8h7") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.blackCastling.kingSide shouldBe false
|
||||
newCtx.blackCastling.queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.blackCastling.kingSide shouldBe false
|
||||
newCtx.blackCastling.queenSide shouldBe true
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.Black).kingSide shouldBe false
|
||||
castlingRights(newHistory, Color.Black).queenSide shouldBe true
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
test("processMove: enemy capture on a1 revokes white queenside right"):
|
||||
val ctx = GameContext(
|
||||
board = Board(Map(
|
||||
val b = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameController.processMove(ctx, Color.Black, "a2a1") match
|
||||
case MoveResult.Moved(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.queenSide shouldBe false
|
||||
case MoveResult.MovedInCheck(newCtx, _, _) =>
|
||||
newCtx.whiteCastling.queenSide shouldBe false
|
||||
))
|
||||
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CastlingRightsCalculatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("Empty history gives full castling rights"):
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(GameHistory.empty, Color.White)
|
||||
rights shouldBe CastlingRights.Both
|
||||
|
||||
test("White loses kingside rights after h1 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R1), sq(File.H, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.kingSide shouldBe false
|
||||
rights.queenSide shouldBe true
|
||||
|
||||
test("White loses queenside rights after a1 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.queenSide shouldBe false
|
||||
rights.kingSide shouldBe true
|
||||
|
||||
test("White loses all rights after king moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Black loses kingside rights after h8 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.H, Rank.R8), sq(File.H, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights.kingSide shouldBe false
|
||||
rights.queenSide shouldBe true
|
||||
|
||||
test("Black loses queenside rights after a8 rook moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.A, Rank.R8), sq(File.A, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights.queenSide shouldBe false
|
||||
rights.kingSide shouldBe true
|
||||
|
||||
test("Black loses all rights after king moves"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R8), sq(File.E, Rank.R7))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Castle move revokes all castling rights"):
|
||||
val history = GameHistory.empty.addMove(
|
||||
sq(File.E, Rank.R1),
|
||||
sq(File.G, Rank.R1),
|
||||
Some(CastleSide.Kingside)
|
||||
)
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.None
|
||||
|
||||
test("Other pieces moving does not revoke castling rights"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights shouldBe CastlingRights.Both
|
||||
|
||||
test("Multiple moves preserve white kingside but lose queenside"):
|
||||
val history = GameHistory.empty
|
||||
.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) // White queenside rook moves
|
||||
.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) // Black pawn moves
|
||||
val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White)
|
||||
rights.kingSide shouldBe true
|
||||
rights.queenSide shouldBe false
|
||||
@@ -1,81 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameContextTest 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)
|
||||
|
||||
test("GameContext.initial has Board.initial and CastlingRights.Both for both sides"):
|
||||
GameContext.initial.board shouldBe Board.initial
|
||||
GameContext.initial.whiteCastling shouldBe CastlingRights.Both
|
||||
GameContext.initial.blackCastling shouldBe CastlingRights.Both
|
||||
|
||||
test("castlingFor returns white rights for Color.White"):
|
||||
GameContext.initial.castlingFor(Color.White) shouldBe CastlingRights.Both
|
||||
|
||||
test("castlingFor returns black rights for Color.Black"):
|
||||
GameContext.initial.castlingFor(Color.Black) shouldBe CastlingRights.Both
|
||||
|
||||
test("withUpdatedRights updates white castling without touching black"):
|
||||
val ctx = GameContext.initial.withUpdatedRights(Color.White, CastlingRights.None)
|
||||
ctx.whiteCastling shouldBe CastlingRights.None
|
||||
ctx.blackCastling shouldBe CastlingRights.Both
|
||||
|
||||
test("withUpdatedRights updates black castling without touching white"):
|
||||
val ctx = GameContext.initial.withUpdatedRights(Color.Black, CastlingRights.None)
|
||||
ctx.blackCastling shouldBe CastlingRights.None
|
||||
ctx.whiteCastling shouldBe CastlingRights.Both
|
||||
|
||||
test("withCastle: white kingside — king e1→g1, rook h1→f1"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook
|
||||
)
|
||||
val after = b.withCastle(Color.White, CastleSide.Kingside)
|
||||
after.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
after.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
after.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
after.pieceAt(sq(File.H, Rank.R1)) shouldBe None
|
||||
|
||||
test("withCastle: white queenside — king e1→c1, rook a1→d1"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook
|
||||
)
|
||||
val after = b.withCastle(Color.White, CastleSide.Queenside)
|
||||
after.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
after.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
after.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
after.pieceAt(sq(File.A, Rank.R1)) shouldBe None
|
||||
|
||||
test("withCastle: black kingside — king e8→g8, rook h8→f8"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook
|
||||
)
|
||||
val after = b.withCastle(Color.Black, CastleSide.Kingside)
|
||||
after.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
after.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
after.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
after.pieceAt(sq(File.H, Rank.R8)) shouldBe None
|
||||
|
||||
test("withCastle: black queenside — king e8→c8, rook a8→d8"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook
|
||||
)
|
||||
val after = b.withCastle(Color.Black, CastleSide.Queenside)
|
||||
after.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
after.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
after.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
after.pieceAt(sq(File.A, Rank.R8)) shouldBe None
|
||||
|
||||
test("GameContext single-arg apply defaults to CastlingRights.None for both sides"):
|
||||
val ctx = GameContext(Board.initial)
|
||||
ctx.whiteCastling shouldBe CastlingRights.None
|
||||
ctx.blackCastling shouldBe CastlingRights.None
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameHistoryTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
test("GameHistory starts empty"):
|
||||
val history = GameHistory.empty
|
||||
history.moves shouldBe empty
|
||||
|
||||
test("GameHistory can add a move"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
history.moves should have length 1
|
||||
history.moves.head.from shouldBe sq(File.E, Rank.R2)
|
||||
history.moves.head.to shouldBe sq(File.E, Rank.R4)
|
||||
history.moves.head.castleSide shouldBe None
|
||||
|
||||
test("GameHistory can add multiple moves in order"):
|
||||
val h1 = GameHistory.empty
|
||||
val h2 = h1.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
val h3 = h2.addMove(sq(File.C, Rank.R7), sq(File.C, Rank.R5))
|
||||
h3.moves should have length 2
|
||||
h3.moves(0).from shouldBe sq(File.E, Rank.R2)
|
||||
h3.moves(1).from shouldBe sq(File.C, Rank.R7)
|
||||
|
||||
test("GameHistory can add a castle move"):
|
||||
val history = GameHistory.empty.addMove(
|
||||
sq(File.E, Rank.R1),
|
||||
sq(File.G, Rank.R1),
|
||||
Some(CastleSide.Kingside)
|
||||
)
|
||||
history.moves.head.castleSide shouldBe Some(CastleSide.Kingside)
|
||||
|
||||
test("GameHistory.addMove with two arguments uses None for castleSide default"):
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
history.moves should have length 1
|
||||
history.moves.head.castleSide shouldBe None
|
||||
@@ -2,7 +2,7 @@ package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.GameContext
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -12,7 +12,11 @@ class GameRulesTest extends AnyFunSuite with Matchers:
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
/** Wrap a board in a GameContext with no castling rights — for non-castling tests. */
|
||||
private def ctx(entries: (Square, Piece)*): GameContext = GameContext(Board(entries.toMap))
|
||||
private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] =
|
||||
GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color)
|
||||
|
||||
private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus =
|
||||
GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color)
|
||||
|
||||
// ──── isInCheck ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,20 +45,20 @@ class GameRulesTest extends AnyFunSuite with Matchers:
|
||||
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 moves = GameRules.legalMoves(ctx(
|
||||
val moves = testLegalMoves(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R4) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
), Color.White)
|
||||
)(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 moves = GameRules.legalMoves(ctx(
|
||||
val moves = testLegalMoves(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook
|
||||
), Color.White)
|
||||
)(Color.White)
|
||||
moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5))
|
||||
|
||||
// ──── gameStatus ──────────────────────────────────────────────────────
|
||||
@@ -62,70 +66,96 @@ class GameRulesTest extends AnyFunSuite with Matchers:
|
||||
test("gameStatus: checkmate returns Mated"):
|
||||
// White Qh8, Ka6; Black Ka8
|
||||
// Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position)
|
||||
GameRules.gameStatus(ctx(
|
||||
testGameStatus(
|
||||
sq(File.H, Rank.R8) -> Piece.WhiteQueen,
|
||||
sq(File.A, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
), Color.Black) shouldBe PositionStatus.Mated
|
||||
)(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)
|
||||
GameRules.gameStatus(ctx(
|
||||
testGameStatus(
|
||||
sq(File.B, Rank.R6) -> Piece.WhiteQueen,
|
||||
sq(File.C, Rank.R6) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
), Color.Black) shouldBe PositionStatus.Drawn
|
||||
)(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
|
||||
GameRules.gameStatus(ctx(
|
||||
testGameStatus(
|
||||
sq(File.A, Rank.R8) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
), Color.Black) shouldBe PositionStatus.InCheck
|
||||
)(Color.Black) shouldBe PositionStatus.InCheck
|
||||
|
||||
test("gameStatus: normal starting position returns Normal"):
|
||||
GameRules.gameStatus(GameContext(Board.initial), Color.White) shouldBe PositionStatus.Normal
|
||||
GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
|
||||
|
||||
test("legalMoves: includes castling destination when available"):
|
||||
val c = GameContext(
|
||||
board = board(
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameRules.legalMoves(c, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
||||
)
|
||||
GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
||||
|
||||
test("legalMoves: excludes castling when king is in check"):
|
||||
val c = GameContext(
|
||||
board = board(
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
),
|
||||
whiteCastling = CastlingRights.Both,
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
GameRules.legalMoves(c, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
||||
)
|
||||
GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1))
|
||||
|
||||
test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"):
|
||||
// White King e1, Rook h1 (kingside castling available).
|
||||
// Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both,
|
||||
// f1 attacked by f2. King cannot move to any adjacent square without entering
|
||||
// an attacked square or an enemy piece. Only legal move: castle to g1.
|
||||
val c = GameContext(
|
||||
board = board(
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.F, Rank.R2) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
),
|
||||
whiteCastling = CastlingRights(kingSide = true, queenSide = false),
|
||||
blackCastling = CastlingRights.None
|
||||
)
|
||||
// No history means castling rights are intact
|
||||
GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal
|
||||
|
||||
test("CastleSide.withCastle correctly positions pieces for Queenside castling"):
|
||||
// Directly test the withCastle extension for Queenside (coverage gap on line 10)
|
||||
val b = board(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
GameRules.gameStatus(c, Color.White) shouldBe PositionStatus.Normal
|
||||
val result = b.withCastle(Color.White, CastleSide.Queenside)
|
||||
result.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
|
||||
result.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
|
||||
result.pieceAt(sq(File.E, Rank.R1)) shouldBe None
|
||||
result.pieceAt(sq(File.A, Rank.R1)) shouldBe None
|
||||
|
||||
test("CastleSide.withCastle correctly positions pieces for Black Kingside castling"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
)
|
||||
val result = b.withCastle(Color.Black, CastleSide.Kingside)
|
||||
result.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
result.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
result.pieceAt(sq(File.H, Rank.R8)) shouldBe None
|
||||
|
||||
test("CastleSide.withCastle correctly positions pieces for Black Queenside castling"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteKing
|
||||
)
|
||||
val result = b.withCastle(Color.Black, CastleSide.Queenside)
|
||||
result.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing)
|
||||
result.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook)
|
||||
result.pieceAt(sq(File.E, Rank.R8)) shouldBe None
|
||||
result.pieceAt(sq(File.A, Rank.R8)) shouldBe None
|
||||
|
||||
@@ -2,7 +2,7 @@ package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{GameContext, CastleSide}
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -211,168 +211,3 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
|
||||
sq(File.E, Rank.R4) -> Piece.BlackRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
|
||||
|
||||
// ──── castlingTargets ────────────────────────────────────────────────
|
||||
|
||||
private def ctxWithRights(
|
||||
entries: (Square, Piece)*
|
||||
)(white: CastlingRights = CastlingRights.Both,
|
||||
black: CastlingRights = CastlingRights.Both
|
||||
): GameContext =
|
||||
GameContext(Board(entries.toMap), white, black)
|
||||
|
||||
test("castlingTargets: white kingside available when all conditions met"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.G, Rank.R1))
|
||||
|
||||
test("castlingTargets: white queenside available when all conditions met"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should contain(sq(File.C, Rank.R1))
|
||||
|
||||
test("castlingTargets: black kingside available when all conditions met"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.G, Rank.R8))
|
||||
|
||||
test("castlingTargets: black queenside available when all conditions met"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.Black) should contain(sq(File.C, Rank.R8))
|
||||
|
||||
test("castlingTargets: blocked when transit square is occupied"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.F, Rank.R1) -> Piece.WhiteBishop,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
|
||||
|
||||
test("castlingTargets: blocked when king is in check"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty
|
||||
|
||||
test("castlingTargets: blocked when transit square f1 is attacked"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.F, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
|
||||
|
||||
test("castlingTargets: blocked when landing square g1 is attacked"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.G, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
|
||||
|
||||
test("castlingTargets: blocked when kingSide right is false"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)(white = CastlingRights(kingSide = false, queenSide = true))
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
|
||||
|
||||
test("castlingTargets: blocked when queenSide right is false"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)(white = CastlingRights(kingSide = true, queenSide = false))
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.C, Rank.R1)
|
||||
|
||||
test("castlingTargets: blocked when relevant rook is not on home square"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) should not contain sq(File.G, Rank.R1)
|
||||
|
||||
// ──── context-aware legalTargets includes castling ────────────────────
|
||||
|
||||
test("legalTargets(ctx, from): king on e1 includes g1 when castling available"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.legalTargets(ctx, sq(File.E, Rank.R1)) should contain(sq(File.G, Rank.R1))
|
||||
|
||||
test("legalTargets(ctx, from): non-king pieces unchanged by context"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteKing
|
||||
)()
|
||||
MoveValidator.legalTargets(ctx, sq(File.D, Rank.R4)) shouldBe
|
||||
MoveValidator.legalTargets(ctx.board, sq(File.D, Rank.R4))
|
||||
|
||||
// ──── isCastle / castleSide / isLegal(ctx) ───────────────────────────
|
||||
|
||||
test("isCastle: returns true when king moves two files"):
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook
|
||||
))
|
||||
MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true
|
||||
|
||||
test("isCastle: returns false when king moves one file"):
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
MoveValidator.isCastle(board, sq(File.E, Rank.R1), sq(File.F, Rank.R1)) shouldBe false
|
||||
|
||||
test("castleSide: returns Kingside when moving to higher file"):
|
||||
MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe CastleSide.Kingside
|
||||
|
||||
test("castleSide: returns Queenside when moving to lower file"):
|
||||
MoveValidator.castleSide(sq(File.E, Rank.R1), sq(File.C, Rank.R1)) shouldBe CastleSide.Queenside
|
||||
|
||||
test("isLegal(ctx): returns true for legal castling move"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe true
|
||||
|
||||
test("isLegal(ctx): returns false for illegal castling move when rights revoked"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)(white = CastlingRights.None)
|
||||
MoveValidator.isLegal(ctx, sq(File.E, Rank.R1), sq(File.G, Rank.R1)) shouldBe false
|
||||
|
||||
test("castlingTargets: returns empty when king not on home square"):
|
||||
val ctx = ctxWithRights(
|
||||
sq(File.D, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
)()
|
||||
MoveValidator.castlingTargets(ctx, Color.White) shouldBe empty
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=2
|
||||
PATCH=0
|
||||
Reference in New Issue
Block a user