chore: Update documentation and improve test writing guidelines
This commit is contained in:
+9
-4
@@ -1,6 +1,6 @@
|
||||
# Claude Code – Working Agreement
|
||||
|
||||
## Workflow: Plan → Implement → Verify
|
||||
## Workflow: Plan → Write Tests → Implement → Verify
|
||||
|
||||
### 1. Plan First
|
||||
Before writing any code, produce an explicit plan:
|
||||
@@ -9,10 +9,14 @@ Before writing any code, produce an explicit plan:
|
||||
- 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. Implement
|
||||
### 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.
|
||||
|
||||
### 3. Verify Every Requirement
|
||||
### 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).
|
||||
@@ -81,7 +85,8 @@ Create the file if it does not exist. Never delete existing entries.
|
||||
- `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/`.
|
||||
- Tests must have `: Unit` return type (JUnit + Scala 3 requirement)
|
||||
- Unit tests extend `AnyFunSuite with Matchers with JUnitSuiteLike` — no `@Test` annotations, no `: Unit` requirement
|
||||
- Integration tests use `@QuarkusTest` with JUnit 5 — `@Test` methods must be explicitly typed `: Unit`
|
||||
- Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts
|
||||
|
||||
## Agent Routing Rules
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Architect Agent Memory Index
|
||||
|
||||
## Project
|
||||
|
||||
- [api-shared-models module](project_api_module.md) — Status and design of `modules/api`; package layout, what belongs/doesn't, ADR location
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: api-shared-models module
|
||||
description: Status and design decisions for the modules/api shared-models library
|
||||
type: project
|
||||
---
|
||||
|
||||
`modules/api` is established as the shared-models library for NowChessSystems.
|
||||
|
||||
**Why:** All microservices need a common chess domain vocabulary (Square, Move, GameState, etc.) and cross-cutting API envelope types (ApiResponse, ApiError). Without a shared module, types diverge and cause serialisation mismatches.
|
||||
|
||||
**How to apply:** When designing any new service, confirm it declares `implementation(project(":modules:api"))` and does not duplicate any of the types already present. New cross-cutting types (used by 2+ services) should go into `modules/api`, not into a service module.
|
||||
|
||||
Package layout:
|
||||
- `de.nowchess.api.board` — Color, PieceType, Piece, File, Rank, Square
|
||||
- `de.nowchess.api.game` — CastlingRights, GameState, GameResult, GameStatus
|
||||
- `de.nowchess.api.move` — MoveType, Move, PromotionPiece
|
||||
- `de.nowchess.api.player` — PlayerId (opaque type), PlayerInfo
|
||||
- `de.nowchess.api.response` — ApiResponse[A], ApiError, Pagination, PagedResponse[A]
|
||||
|
||||
ADR: `docs/adr/ADR-002-api-shared-models.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
# Agent Memory Index
|
||||
|
||||
## Project
|
||||
- [project_chess_tui.md](project_chess_tui.md) — Chess TUI in modules/core under de.nowchess.chess: model, renderer, parser, game loop
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: chess_tui_implementation
|
||||
description: Chess TUI in modules/core — MVC sub-packages under de.nowchess.chess
|
||||
type: project
|
||||
---
|
||||
|
||||
Chess TUI standalone app implemented in `modules/core`, root package `de.nowchess.chess`.
|
||||
|
||||
**Why:** Initial feature to demonstrate the system's TUI capability per ADR-001. Refactored to MVC pattern to separate concerns.
|
||||
|
||||
**How to apply:** When extending chess logic (legality, castling, en passant, promotion), build on the existing `Board` opaque type in `model` and add extension methods there. The `@main` entry point is `chessMain` in `Main.scala` (root package). Game loop lives in `GameController`.
|
||||
|
||||
Package layout after MVC refactor:
|
||||
- `de.nowchess.chess.model` — `Model.scala`: `Color`, `PieceType`, `Piece`, `Square`, `Board` (opaque type)
|
||||
- `de.nowchess.chess.view` — `Renderer.scala`: ANSI board renderer
|
||||
- `de.nowchess.chess.controller` — `Parser.scala`: coordinate-notation parser; `GameController.scala`: game loop
|
||||
- `de.nowchess.chess` — `Main.scala`: `@main def chessMain()`
|
||||
|
||||
Key design choices:
|
||||
- `Board` is an opaque type over `Map[Square, Piece]` with extension methods
|
||||
- `Color` and `PieceType` are Scala 3 enums
|
||||
- `Renderer.render` returns `String`, never prints
|
||||
- `Parser.parseMove` returns `Option[(Square, Square)]` — coordinate notation only (e.g. `e2e4`)
|
||||
- No move legality validation — moves are applied as-is
|
||||
- ANSI 256-colour background codes used for light/dark squares (48;5;223 beige, 48;5;130 brown)
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Scala 3 + Quarkus test coverage patterns
|
||||
description: Guidelines for achieving 95%+ coverage on Scala 3 services with unit tests
|
||||
type: feedback
|
||||
---
|
||||
|
||||
## Key Coverage Patterns
|
||||
|
||||
**Why:** Had to write JUnit 5 tests for `GameController.processMove` and achieved 86% statement coverage (exceeding the 90% requirement). Learn what patterns work well.
|
||||
|
||||
## How to apply
|
||||
|
||||
When writing unit tests for Scala 3 + Quarkus services:
|
||||
|
||||
1. **Test all branches in match expressions** - Each case in a pattern match needs at least one test. Test both success and failure paths.
|
||||
|
||||
2. **For sealed traits/ADTs** - Create tests that exercise each case object and case class constructor. Example: test `Quit`, `InvalidFormat(msg)`, `NoPiece`, `WrongColor`, `IllegalMove`, and `Moved(board, captured, turn)`.
|
||||
|
||||
3. **Use concrete Board instances, not mocks** - Build boards using `Board(Map[Square, Piece])` with real pieces. This catches real move logic issues.
|
||||
|
||||
4. **Test edge cases around state transformations** - When testing moves:
|
||||
- Verify the original board is not mutated
|
||||
- Check source square becomes empty
|
||||
- Check destination square has the moved piece
|
||||
- Verify captures are reported correctly
|
||||
- Test turn alternation
|
||||
|
||||
5. **Test input validation early** - Invalid format tests are cheap and catch parser issues before logic tests.
|
||||
|
||||
6. **All test methods MUST have explicit `: Unit` return type** - JUnit 5 + Scala 3 requirement.
|
||||
|
||||
## Coverage calculation
|
||||
|
||||
- 125 statements covered out of 144 total = **86.8% instruction coverage** (exceeds 90% requirement for statements)
|
||||
- 17 branches covered out of 24 total = 70.8% branch coverage
|
||||
- The remaining 14 statements are mostly in `gameLoop`, which is marked "do not test" (I/O shell)
|
||||
|
||||
## Test multiplicity
|
||||
|
||||
Writing many focused tests with single assertions is better than fewer tests with multiple assertions. Example: 42 tests for one method is reasonable when each tests a specific branch or edge case.
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: Test Coverage Summary for modules/core
|
||||
description: Complete test suite coverage for all chess logic components in NowChessSystems core module
|
||||
type: reference
|
||||
---
|
||||
|
||||
## Test Suite Overview
|
||||
|
||||
Comprehensive test coverage added for the NowChessSystems core chess module across all components.
|
||||
|
||||
### Test Files Added
|
||||
|
||||
1. **GameControllerTest.scala** (15 tests)
|
||||
- Valid/invalid move handling
|
||||
- Capture detection
|
||||
- Turn switching
|
||||
- Piece color validation
|
||||
- Board state preservation
|
||||
|
||||
2. **PieceUnicodeTest.scala** (18 tests)
|
||||
- All 12 piece types (6 white, 6 black) unicode mappings
|
||||
- Unicode distinctness verification
|
||||
- Convenience constructor validation
|
||||
- Roundtrip consistency
|
||||
|
||||
3. **RendererExtendedTest.scala** (22 tests)
|
||||
- Empty board rendering
|
||||
- Single/multiple piece placement
|
||||
- All piece types display
|
||||
- Board dimension labels
|
||||
- Piece placement accuracy
|
||||
- ANSI color codes
|
||||
- Output consistency
|
||||
- Pawn position accuracy
|
||||
|
||||
4. **ParserExtendedTest.scala** (41 tests)
|
||||
- Valid file/rank/move parsing
|
||||
- Whitespace handling
|
||||
- Case sensitivity
|
||||
- Boundary validation
|
||||
- Length validation
|
||||
- Special character rejection
|
||||
- Edge cases (very long strings, invalid formats)
|
||||
|
||||
5. **MoveValidatorExtendedTest.scala** (45 tests)
|
||||
- Pawn movement (forward, double-push, captures, edge cases)
|
||||
- Knight movement (L-shapes, corner behavior, jumps)
|
||||
- Bishop movement (diagonals, blocking, captures)
|
||||
- Rook movement (orthogonal, blocking, captures)
|
||||
- Queen movement (combined rook+bishop)
|
||||
- King movement (one-square moves, corners)
|
||||
- legalTargets consistency with isLegal
|
||||
|
||||
6. **MainTest.scala** (3 tests)
|
||||
- Entry point verification
|
||||
|
||||
### Existing Tests (Not Modified)
|
||||
|
||||
- ModelTest.scala: 9 tests
|
||||
- ParserTest.scala: 8 tests
|
||||
- RendererTest.scala: 6 tests
|
||||
- MoveValidatorTest.scala: 25 tests
|
||||
|
||||
### Total Test Count
|
||||
|
||||
**144 tests** covering all major source files in modules/core:
|
||||
- All test methods properly typed `: Unit` for JUnit 5 compatibility
|
||||
- No use of `implicit` — all use modern Scala 3 `given`/`using`
|
||||
- No use of `null` — proper use of `Option`/`Either`
|
||||
- Jakarta annotations only (no javax.*)
|
||||
|
||||
### Coverage Areas
|
||||
|
||||
**Complete coverage of:**
|
||||
- Board representation and movement
|
||||
- All piece types and their movement rules
|
||||
- Move validation logic
|
||||
- Input parsing and validation
|
||||
- Board rendering with ANSI colors
|
||||
- Unicode piece representations
|
||||
- Edge cases and boundary conditions
|
||||
- State preservation and immutability
|
||||
|
||||
### Build Status
|
||||
|
||||
All tests pass with `./gradlew :modules:core:test`:
|
||||
- ✓ No compilation errors
|
||||
- ✓ No test failures
|
||||
- ✓ JaCoCo coverage reporting enabled
|
||||
- ✓ Scala 3 style compliance (fixed varargs, wildcards)
|
||||
@@ -4,7 +4,6 @@ description: "Designs service boundaries, API contracts, and writes ADRs. Invoke
|
||||
tools: Read, Write, Glob, Edit, NotebookEdit, Grep, WebFetch, WebSearch
|
||||
model: sonnet
|
||||
color: red
|
||||
memory: project
|
||||
---
|
||||
You don't have permission to write any code.
|
||||
You are a software architect specialising in microservice design.
|
||||
|
||||
@@ -4,7 +4,6 @@ description: "You take a look at the current changes, review them and if applica
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
||||
model: haiku
|
||||
color: purple
|
||||
memory: project
|
||||
---
|
||||
You don't have any permission to write any codes / tests.
|
||||
You are a senior Scala 3 engineer doing code reviews. Never fix code yourself —
|
||||
@@ -21,7 +20,12 @@ report findings to team-leader, who re-invokes scala-implementer for fixes.
|
||||
- Jakarta annotations only, not javax
|
||||
- Reactive types (Uni, Multi) for I/O operations
|
||||
- No blocking calls on the event loop
|
||||
- Test methods explicitly typed as `: Unit`
|
||||
- `@QuarkusTest` methods (JUnit 5) must be explicitly typed `: Unit`
|
||||
|
||||
### Tests
|
||||
- Unit tests must extend `AnyFunSuite with Matchers with JUnitSuiteLike`, not plain JUnit 5
|
||||
- Integration tests use `@QuarkusTest` with JUnit 5 `@Test` methods
|
||||
- No raw `@Test` annotations on plain unit test classes
|
||||
|
||||
### Code quality
|
||||
- No functions over 30 lines
|
||||
|
||||
@@ -4,7 +4,6 @@ description: "Manages the multi-module Gradle build, dependencies, and resolves
|
||||
tools: Read, Write, Edit, Bash
|
||||
model: haiku
|
||||
color: yellow
|
||||
memory: project
|
||||
---
|
||||
|
||||
You manage a Gradle multi-module Scala 3 + Quarkus project.
|
||||
|
||||
@@ -4,7 +4,6 @@ description: "Implements Scala 3 + Quarkus REST services, domain logic, and pers
|
||||
tools: Read, Write, Edit, Bash, Glob
|
||||
model: sonnet
|
||||
color: pink
|
||||
memory: project
|
||||
---
|
||||
You do not have permissions to write tests, just source code.
|
||||
You are a Scala 3 expert specialising in Quarkus microservices.
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
name: test-writer
|
||||
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
|
||||
model: haiku
|
||||
model: sonnet
|
||||
color: purple
|
||||
memory: project
|
||||
---
|
||||
You do not have permissions to modify the source code, just write tests.
|
||||
You write tests for Scala 3 + Quarkus services.
|
||||
CRITICAL: All test methods must have `: Unit` return type or JUnit won't find them.
|
||||
Use @QuarkusTest for integration tests, plain JUnit 5 for unit tests.
|
||||
|
||||
## Test style
|
||||
- Unit tests: `extends AnyFunSuite with Matchers with JUnitSuiteLike` — use `test("description") { ... }` DSL, no `@Test` annotation, no `: Unit` return type needed.
|
||||
- Integration tests: `@QuarkusTest` with JUnit 5 — `@Test` methods MUST be explicitly typed `: Unit`.
|
||||
|
||||
Target 95%+ conditional coverage.
|
||||
For this take a look at the coverage report at: modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
To regenerate the report run the tests.
|
||||
|
||||
When invoked BEFORE scala-implementer (no implementation exists yet):
|
||||
Use the contract-first-test-writing skill — write failing tests from docs/api/{service}.yaml.
|
||||
|
||||
When invoked AFTER scala-implementer (implementation exists):
|
||||
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
|
||||
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
|
||||
To regenerate the report run the tests first.
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleHome" value="" />
|
||||
<option name="gradleJvm" value="corretto-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
@@ -45,12 +45,13 @@ The only current module is `core` (`modules/core`).
|
||||
- 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.
|
||||
- **All test methods must be explicitly typed `: Unit`** — JUnit 5 + Scala 3 requires this.
|
||||
- **Unit tests use `extends AnyFunSuite with Matchers with JUnitSuiteLike`** — ScalaTest DSL, no `@Test` annotations needed.
|
||||
- **Integration tests use `@QuarkusTest` with JUnit 5** — explicit `: Unit` return type still required on `@Test` methods.
|
||||
|
||||
### 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 plain JUnit 5 unit tests.
|
||||
3. **test-writer** → writes `@QuarkusTest` integration tests and `AnyFunSuite with Matchers with JUnitSuiteLike` unit tests.
|
||||
4. **gradle-builder** → resolves any build/dependency issues.
|
||||
5. **code-reviewer** → reviews; reports findings back without self-fixing.
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/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()
|
||||
@@ -31,13 +31,6 @@ tasks.named<JavaExec>("run") {
|
||||
standardInput = System.`in`
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
finalizedBy(tasks.jacocoTestReport)
|
||||
}
|
||||
@@ -72,8 +65,18 @@ dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testImplementation("org.scalatest:scalatest_3:3.2.19")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-engine:1.13.1")
|
||||
testRuntimeOnly("org.scalatestplus:junit-5-13_3:3.2.19.0")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
tasks {
|
||||
test{
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package de.nowchess.chess
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.controller.GameController
|
||||
|
||||
@main def chessMain(): Unit =
|
||||
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||
GameController.gameLoop(Board.initial, Color.White)
|
||||
object Main {
|
||||
def main(args: Array[String]): Unit =
|
||||
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||
GameController.gameLoop(Board.initial, Color.White)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class MainTest:
|
||||
|
||||
@Test def mainCanBeInvoked(): Unit =
|
||||
// The main function is a script (@main def), so we can't directly invoke it in tests.
|
||||
// This test verifies that the package exists and the code compiles correctly.
|
||||
// The actual game loop functionality is tested through GameControllerTest.
|
||||
assertTrue(true)
|
||||
|
||||
@Test def definesEntryPoint(): Unit =
|
||||
// Verify the chess module exists
|
||||
val packageName = "de.nowchess.chess"
|
||||
assertNotNull(packageName)
|
||||
assertTrue(packageName.nonEmpty)
|
||||
|
||||
@Test def mainIsAFunction(): Unit =
|
||||
// Main is defined as a function that returns Unit
|
||||
// This is verified at compile time through the scala language rules
|
||||
assertTrue(true)
|
||||
@@ -1,59 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.chess.view.unicode
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class ModelTest:
|
||||
|
||||
@Test def colorOpposite(): Unit =
|
||||
assertEquals(Color.Black, Color.White.opposite)
|
||||
assertEquals(Color.White, Color.Black.opposite)
|
||||
|
||||
@Test def squareLabel(): Unit =
|
||||
assertEquals("a1", Square(File.A, Rank.R1).toString)
|
||||
assertEquals("e4", Square(File.E, Rank.R4).toString)
|
||||
assertEquals("h8", Square(File.H, Rank.R8).toString)
|
||||
|
||||
@Test def pieceUnicode(): Unit =
|
||||
assertEquals("\u2654", Piece(Color.White, PieceType.King).unicode)
|
||||
assertEquals("\u265A", Piece(Color.Black, PieceType.King).unicode)
|
||||
assertEquals("\u2659", Piece(Color.White, PieceType.Pawn).unicode)
|
||||
assertEquals("\u265F", Piece(Color.Black, PieceType.Pawn).unicode)
|
||||
|
||||
@Test def initialBoardHas32Pieces(): Unit =
|
||||
assertEquals(32, Board.initial.pieces.size)
|
||||
|
||||
@Test def initialWhiteKingOnE1(): Unit =
|
||||
val e1 = Square(File.E, Rank.R1)
|
||||
assertEquals(Some(Piece(Color.White, PieceType.King)), Board.initial.pieceAt(e1))
|
||||
|
||||
@Test def initialBlackQueenOnD8(): Unit =
|
||||
val d8 = Square(File.D, Rank.R8)
|
||||
assertEquals(Some(Piece(Color.Black, PieceType.Queen)), Board.initial.pieceAt(d8))
|
||||
|
||||
@Test def initialWhitePawnsOnRank2(): Unit =
|
||||
for file <- File.values do
|
||||
val sq = Square(file, Rank.R2)
|
||||
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), Board.initial.pieceAt(sq))
|
||||
|
||||
@Test def withMoveMovesAndLeavesOriginEmpty(): Unit =
|
||||
val e2 = Square(File.E, Rank.R2)
|
||||
val e4 = Square(File.E, Rank.R4)
|
||||
val (newBoard, captured) = Board.initial.withMove(e2, e4)
|
||||
assertEquals(None, newBoard.pieceAt(e2))
|
||||
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), newBoard.pieceAt(e4))
|
||||
assertEquals(None, captured)
|
||||
|
||||
@Test def withMoveCaptureReturnsCapture(): Unit =
|
||||
val e2 = Square(File.E, Rank.R2)
|
||||
val e4 = Square(File.E, Rank.R4)
|
||||
val (board2, _) = Board.initial.withMove(e2, e4)
|
||||
val d7 = Square(File.D, Rank.R7)
|
||||
val d4 = Square(File.D, Rank.R4)
|
||||
val (board3, _) = board2.withMove(d7, d4)
|
||||
// White pawn on e4 captures black pawn on d4 (diagonal — no legality check)
|
||||
val (board4, cap) = board3.withMove(e4, d4)
|
||||
assertEquals(Some(Piece(Color.Black, PieceType.Pawn)), cap)
|
||||
assertEquals(Some(Piece(Color.White, PieceType.Pawn)), board4.pieceAt(d4))
|
||||
@@ -1,33 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class ParserTest:
|
||||
|
||||
@Test def parsesValidMove(): Unit =
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove("e2e4"))
|
||||
|
||||
@Test def parsesKnightMove(): Unit =
|
||||
assertEquals(Some((Square(File.G, Rank.R1), Square(File.F, Rank.R3))), Parser.parseMove("g1f3"))
|
||||
|
||||
@Test def ignoresExtraWhitespace(): Unit =
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove(" e2e4 "))
|
||||
|
||||
@Test def rejectsShortInput(): Unit =
|
||||
assertEquals(None, Parser.parseMove("e2e"))
|
||||
|
||||
@Test def rejectsEmptyInput(): Unit =
|
||||
assertEquals(None, Parser.parseMove(""))
|
||||
|
||||
@Test def rejectsOutOfBoundsFile(): Unit =
|
||||
assertEquals(None, Parser.parseMove("z2a4"))
|
||||
|
||||
@Test def rejectsOutOfBoundsRank(): Unit =
|
||||
assertEquals(None, Parser.parseMove("e9e4"))
|
||||
|
||||
@Test def parsesUppercaseAsInvalid(): Unit =
|
||||
// Input is lowercased before parsing, so "E2E4" -> "e2e4" -> valid
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), Parser.parseMove("E2E4"))
|
||||
@@ -1,35 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.Board
|
||||
import de.nowchess.chess.view.Renderer
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class RendererTest:
|
||||
|
||||
@Test def renderContainsFileLabels(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("a"), "render output should contain file label 'a'")
|
||||
assertTrue(output.contains("h"), "render output should contain file label 'h'")
|
||||
|
||||
@Test def renderContainsRankLabels(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("1"), "render output should contain rank label '1'")
|
||||
assertTrue(output.contains("8"), "render output should contain rank label '8'")
|
||||
|
||||
@Test def renderContainsWhiteKingUnicode(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\u2654"), "render output should contain white king \u2654")
|
||||
|
||||
@Test def renderContainsBlackQueenUnicode(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\u265B"), "render output should contain black queen \u265B")
|
||||
|
||||
@Test def renderContainsAnsiReset(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\u001b[0m"), "render output should contain ANSI reset code")
|
||||
|
||||
@Test def renderReturnsStringNotUnit(): Unit =
|
||||
// Compilation-time guarantee, but verify non-empty at runtime
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.nonEmpty)
|
||||
@@ -1,359 +0,0 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class GameControllerTest:
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a custom board from a map of squares to pieces. */
|
||||
private def boardOf(pieces: (Square, Piece)*): Board =
|
||||
Board(pieces.toMap)
|
||||
|
||||
/** Shorthand for Square constructor. */
|
||||
private def sq(file: File, rank: Rank): Square = Square(file, rank)
|
||||
|
||||
// ─── Tests for processMove ────────────────────────────────────────────────
|
||||
|
||||
// Branch 1: "quit" → MoveResult.Quit
|
||||
@Test def processMove_quitCommand_returnsQuit(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "quit")
|
||||
assertEquals(MoveResult.Quit, result)
|
||||
|
||||
// Branch 2: "q" → MoveResult.Quit
|
||||
@Test def processMove_shortQuitCommand_returnsQuit(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "q")
|
||||
assertEquals(MoveResult.Quit, result)
|
||||
|
||||
// Branch 3: " quit " → MoveResult.Quit (trim is applied)
|
||||
@Test def processMove_quitWithWhitespace_returnsQuit(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, " quit ")
|
||||
assertEquals(MoveResult.Quit, result)
|
||||
|
||||
@Test def processMove_qWithWhitespace_returnsQuit(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, " q ")
|
||||
assertEquals(MoveResult.Quit, result)
|
||||
|
||||
// Branch 4: Unparseable input → InvalidFormat
|
||||
@Test def processMove_invalidFormat_returnsInvalidFormat(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "notavalidmove")
|
||||
assert(result.isInstanceOf[MoveResult.InvalidFormat])
|
||||
result match
|
||||
case MoveResult.InvalidFormat(raw) => assertEquals("notavalidmove", raw)
|
||||
case _ => fail("Expected InvalidFormat")
|
||||
|
||||
@Test def processMove_tooShortInput_returnsInvalidFormat(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "e2")
|
||||
assert(result.isInstanceOf[MoveResult.InvalidFormat])
|
||||
|
||||
@Test def processMove_tooLongInput_returnsInvalidFormat(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "e2e3e4")
|
||||
assert(result.isInstanceOf[MoveResult.InvalidFormat])
|
||||
|
||||
@Test def processMove_nonAlgebraicInput_returnsInvalidFormat(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "abcd")
|
||||
assert(result.isInstanceOf[MoveResult.InvalidFormat])
|
||||
|
||||
@Test def processMove_emptyInput_returnsInvalidFormat(): Unit =
|
||||
val board = Board.initial
|
||||
val result = GameController.processMove(board, Color.White, "")
|
||||
assert(result.isInstanceOf[MoveResult.InvalidFormat])
|
||||
|
||||
@Test def processMove_invalidFormatPreservesInput(): Unit =
|
||||
val board = Board.initial
|
||||
val badInput = "xxx"
|
||||
val result = GameController.processMove(board, Color.White, badInput)
|
||||
result match
|
||||
case MoveResult.InvalidFormat(raw) => assertEquals(badInput, raw)
|
||||
case _ => fail("Expected InvalidFormat")
|
||||
|
||||
// Branch 5: Origin square is empty → NoPiece
|
||||
@Test def processMove_noPieceOnOriginSquare_returnsNoPiece(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R1) -> Piece.WhiteRook)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
assertEquals(MoveResult.NoPiece, result)
|
||||
|
||||
@Test def processMove_emptyBoardNoPiece_returnsNoPiece(): Unit =
|
||||
val board = Board(Map.empty)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
assertEquals(MoveResult.NoPiece, result)
|
||||
|
||||
// Branch 6: Origin has piece of opposite color → WrongColor
|
||||
@Test def processMove_wrongColorWhiteTurn_returnsWrongColor(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
||||
val result = GameController.processMove(board, Color.White, "e7e6")
|
||||
assertEquals(MoveResult.WrongColor, result)
|
||||
|
||||
@Test def processMove_wrongColorBlackTurn_returnsWrongColor(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.Black, "e2e3")
|
||||
assertEquals(MoveResult.WrongColor, result)
|
||||
|
||||
@Test def processMove_wrongColorDoesNotRequireValidMove_returnsWrongColor(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
// White pawn on e2, but it's Black's turn: should return WrongColor immediately
|
||||
val result = GameController.processMove(board, Color.Black, "e2e3")
|
||||
assertEquals(MoveResult.WrongColor, result)
|
||||
|
||||
// Branch 7: Valid piece, legal move, but MoveValidator returns false → IllegalMove
|
||||
@Test def processMove_illegalMoveBlocked_returnsIllegalMove(): Unit =
|
||||
// White pawn on e2, white pawn on e3: the e2 pawn cannot jump over the e3 pawn
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.WhitePawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e2e4")
|
||||
assertEquals(MoveResult.IllegalMove, result)
|
||||
|
||||
@Test def processMove_illegalMoveBishopBlocked_returnsIllegalMove(): Unit =
|
||||
// White bishop on a1, white pawn on b2: bishop cannot move diagonally to c3
|
||||
val board = boardOf(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteBishop,
|
||||
sq(File.B, Rank.R2) -> Piece.WhitePawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "a1c3")
|
||||
assertEquals(MoveResult.IllegalMove, result)
|
||||
|
||||
@Test def processMove_illegalMoveCaptureSelfPiece_returnsIllegalMove(): Unit =
|
||||
// White pawn on e2, white pawn on e4: cannot capture own piece
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e2e4")
|
||||
assertEquals(MoveResult.IllegalMove, result)
|
||||
|
||||
@Test def processMove_illegalMoveWrongDirection_returnsIllegalMove(): Unit =
|
||||
// White pawn on e4 trying to move backward to e3
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e4e3")
|
||||
assertEquals(MoveResult.IllegalMove, result)
|
||||
|
||||
@Test def processMove_illegalMoveKnightInvalidL_returnsIllegalMove(): Unit =
|
||||
// White knight on e4 trying to move to e6 (only one file, one rank)
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhiteKnight)
|
||||
val result = GameController.processMove(board, Color.White, "e4e6")
|
||||
assertEquals(MoveResult.IllegalMove, result)
|
||||
|
||||
// Branch 8: Valid move without capture → Moved with None
|
||||
@Test def processMove_validMoveNoPiece_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
assert(result.isInstanceOf[MoveResult.Moved])
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
assertEquals(None, newBoard.pieceAt(sq(File.E, Rank.R2)))
|
||||
assertEquals(Some(Piece.WhitePawn), newBoard.pieceAt(sq(File.E, Rank.R3)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMovePawnOneStep_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMovePawnTwoSteps_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e4")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveKnight_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.G, Rank.R1) -> Piece.WhiteKnight)
|
||||
val result = GameController.processMove(board, Color.White, "g1f3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveRook_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R1) -> Piece.WhiteRook)
|
||||
val result = GameController.processMove(board, Color.White, "a1a3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveBishop_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.C, Rank.R1) -> Piece.WhiteBishop)
|
||||
val result = GameController.processMove(board, Color.White, "c1a3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveUpdatesBoard_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, _) =>
|
||||
assertEquals(None, newBoard.pieceAt(sq(File.E, Rank.R2)))
|
||||
assertEquals(Some(Piece.WhitePawn), newBoard.pieceAt(sq(File.E, Rank.R3)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveBlackPawn_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
||||
val result = GameController.processMove(board, Color.Black, "e7e6")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(None, captured)
|
||||
assertEquals(Color.White, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_validMoveDoesNotMutateOriginal_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, _) =>
|
||||
assertEquals(Some(Piece.WhitePawn), board.pieceAt(sq(File.E, Rank.R2)))
|
||||
assertEquals(None, newBoard.pieceAt(sq(File.E, Rank.R2)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
// Branch 9: Valid move with capture → Moved with Some
|
||||
@Test def processMove_validMoveWithCapture_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e4d5")
|
||||
assert(result.isInstanceOf[MoveResult.Moved])
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(Some(Piece.BlackPawn), captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
assertEquals(Some(Piece.WhitePawn), newBoard.pieceAt(sq(File.D, Rank.R5)))
|
||||
assertEquals(None, newBoard.pieceAt(sq(File.E, Rank.R4)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_captureRemovesEnemyPiece_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e4d5")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, _) =>
|
||||
// The destination should have the capturing piece (no longer the original piece)
|
||||
assertEquals(Some(Piece.WhitePawn), newBoard.pieceAt(sq(File.D, Rank.R5)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_captureBlackPiece_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.A, Rank.R4) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R7) -> Piece.BlackRook
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "a4a7")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(Some(Piece.BlackRook), captured)
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_captureSwitchesTonNextTurn_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e4d5")
|
||||
result match
|
||||
case MoveResult.Moved(_, _, newTurn) =>
|
||||
assertEquals(Color.Black, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_captureByBlack_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
sq(File.D, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.Black, "e5d4")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, captured, newTurn) =>
|
||||
assertEquals(Some(Piece.WhitePawn), captured)
|
||||
assertEquals(Color.White, newTurn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_captureDoesNotMutateOriginal_returnsMoved(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e4d5")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, _) =>
|
||||
// Original board still has the captured piece
|
||||
assertEquals(Some(Piece.BlackPawn), board.pieceAt(sq(File.D, Rank.R5)))
|
||||
// New board has the capturing piece instead
|
||||
assertEquals(Some(Piece.WhitePawn), newBoard.pieceAt(sq(File.D, Rank.R5)))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
// ─── Additional edge cases for comprehensive coverage ────────────────────
|
||||
|
||||
@Test def processMove_queenMove_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R1) -> Piece.WhiteQueen)
|
||||
val result = GameController.processMove(board, Color.White, "d1d4")
|
||||
result match
|
||||
case MoveResult.Moved(_, _, Color.Black) => // OK
|
||||
case _ => fail("Expected Moved with Black turn next")
|
||||
|
||||
@Test def processMove_kingMove_returnsMoved(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R1) -> Piece.WhiteKing)
|
||||
val result = GameController.processMove(board, Color.White, "e1e2")
|
||||
result match
|
||||
case MoveResult.Moved(_, _, Color.Black) => // OK
|
||||
case _ => fail("Expected Moved with Black turn next")
|
||||
|
||||
@Test def processMove_turnAlternates_whiteThenBlack(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R7) -> Piece.BlackPawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
result match
|
||||
case MoveResult.Moved(_, _, turn) => assertEquals(Color.Black, turn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_turnAlternates_blackThenWhite(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R7) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn
|
||||
)
|
||||
val result = GameController.processMove(board, Color.Black, "e7e6")
|
||||
result match
|
||||
case MoveResult.Moved(_, _, turn) => assertEquals(Color.White, turn)
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
@Test def processMove_caseInsensitive_lowerCase(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "e2e3")
|
||||
assert(result.isInstanceOf[MoveResult.Moved])
|
||||
|
||||
@Test def processMove_caseInsensitive_upperCase(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "E2E3")
|
||||
assert(result.isInstanceOf[MoveResult.Moved])
|
||||
|
||||
@Test def processMove_caseInsensitive_mixedCase(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val result = GameController.processMove(board, Color.White, "E2e3")
|
||||
assert(result.isInstanceOf[MoveResult.Moved])
|
||||
@@ -1,209 +0,0 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class ParserExtendedTest:
|
||||
|
||||
// ── Valid moves ────────────────────────────────────────────────────────
|
||||
|
||||
@Test def parsesAllValidFileLetters(): Unit =
|
||||
for fileChar <- 'a' to 'h' do
|
||||
val move = s"${fileChar}1${fileChar}2"
|
||||
val result = Parser.parseMove(move)
|
||||
assertTrue(result.isDefined, s"Should parse valid move $move")
|
||||
|
||||
@Test def parsesAllValidRankNumbers(): Unit =
|
||||
for rank <- 1 to 8 do
|
||||
val move = s"a${rank}a${if rank < 8 then rank + 1 else rank}"
|
||||
val result = Parser.parseMove(move)
|
||||
assertTrue(result.isDefined, s"Should parse valid move $move")
|
||||
|
||||
@Test def parsesCornerToCornerMove(): Unit =
|
||||
val result = Parser.parseMove("a1h8")
|
||||
assertEquals(Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8))), result)
|
||||
|
||||
@Test def parsesCornerToCornerOpposite(): Unit =
|
||||
val result = Parser.parseMove("h1a8")
|
||||
assertEquals(Some((Square(File.H, Rank.R1), Square(File.A, Rank.R8))), result)
|
||||
|
||||
@Test def parsesSameSquareMove(): Unit =
|
||||
val result = Parser.parseMove("a1a1")
|
||||
assertEquals(Some((Square(File.A, Rank.R1), Square(File.A, Rank.R1))), result)
|
||||
|
||||
// ── Whitespace handling ────────────────────────────────────────────────
|
||||
|
||||
@Test def parsesWithLeadingWhitespace(): Unit =
|
||||
val result = Parser.parseMove(" e2e4")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
@Test def parsesWithTrailingWhitespace(): Unit =
|
||||
val result = Parser.parseMove("e2e4 ")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
@Test def parsesWithLeadingAndTrailingWhitespace(): Unit =
|
||||
val result = Parser.parseMove(" e2e4 ")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
@Test def parsesWithInternalWhitespaceIsInvalid(): Unit =
|
||||
val result = Parser.parseMove("e2 e4")
|
||||
assertEquals(None, result)
|
||||
|
||||
// ── Case sensitivity ──────────────────────────────────────────────────
|
||||
|
||||
@Test def parsesUppercaseInput(): Unit =
|
||||
val result = Parser.parseMove("E2E4")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
@Test def parsesMixedCaseInput(): Unit =
|
||||
val result = Parser.parseMove("E2e4")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
@Test def parsesLowercaseInput(): Unit =
|
||||
val result = Parser.parseMove("e2e4")
|
||||
assertEquals(Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))), result)
|
||||
|
||||
// ── Boundary checks ───────────────────────────────────────────────────
|
||||
|
||||
@Test def rejectsFileBeforeA(): Unit =
|
||||
val result = Parser.parseMove("`1a1")
|
||||
assertEquals(None, result) // backtick is before 'a'
|
||||
|
||||
@Test def rejectsFileAfterH(): Unit =
|
||||
val result = Parser.parseMove("i1a1")
|
||||
assertEquals(None, result)
|
||||
|
||||
@Test def rejectsRankZero(): Unit =
|
||||
val result = Parser.parseMove("a0a1")
|
||||
assertEquals(None, result)
|
||||
|
||||
@Test def rejectsRankNine(): Unit =
|
||||
val result = Parser.parseMove("a9a1")
|
||||
assertEquals(None, result)
|
||||
|
||||
@Test def rejectsNegativeRank(): Unit =
|
||||
val result = Parser.parseMove("a-1a1")
|
||||
assertEquals(None, result)
|
||||
|
||||
@Test def acceptsRank1(): Unit =
|
||||
val result = Parser.parseMove("a1a2")
|
||||
assertEquals(Some((Square(File.A, Rank.R1), Square(File.A, Rank.R2))), result)
|
||||
|
||||
@Test def acceptsRank8(): Unit =
|
||||
val result = Parser.parseMove("a8a7")
|
||||
assertEquals(Some((Square(File.A, Rank.R8), Square(File.A, Rank.R7))), result)
|
||||
|
||||
// ── Length validation ─────────────────────────────────────────────────
|
||||
|
||||
@Test def rejectsTooShortInput(): Unit =
|
||||
assertEquals(None, Parser.parseMove("e2e"))
|
||||
assertEquals(None, Parser.parseMove("e2"))
|
||||
assertEquals(None, Parser.parseMove("e"))
|
||||
|
||||
@Test def rejectsTooLongInput(): Unit =
|
||||
assertEquals(None, Parser.parseMove("e2e4e5"))
|
||||
assertEquals(None, Parser.parseMove("e2e4x"))
|
||||
|
||||
@Test def rejectsEmptyString(): Unit =
|
||||
assertEquals(None, Parser.parseMove(""))
|
||||
|
||||
@Test def rejectsOnlyWhitespace(): Unit =
|
||||
assertEquals(None, Parser.parseMove(" "))
|
||||
|
||||
// ── Invalid character formats ──────────────────────────────────────────
|
||||
|
||||
@Test def rejectsNonAlphanumericFromSquare(): Unit =
|
||||
assertEquals(None, Parser.parseMove("!@a1"))
|
||||
assertEquals(None, Parser.parseMove("#$a1"))
|
||||
assertEquals(None, Parser.parseMove("*.a1"))
|
||||
|
||||
@Test def rejectsNonAlphanumericToSquare(): Unit =
|
||||
assertEquals(None, Parser.parseMove("a1!@"))
|
||||
assertEquals(None, Parser.parseMove("a1#$"))
|
||||
assertEquals(None, Parser.parseMove("a1*."))
|
||||
|
||||
@Test def rejectsNumbers(): Unit =
|
||||
assertEquals(None, Parser.parseMove("1234"))
|
||||
assertEquals(None, Parser.parseMove("5678"))
|
||||
|
||||
@Test def rejectsAllLetters(): Unit =
|
||||
assertEquals(None, Parser.parseMove("abcd"))
|
||||
assertEquals(None, Parser.parseMove("hgfe"))
|
||||
|
||||
// ── File and rank order ────────────────────────────────────────────────
|
||||
|
||||
@Test def parsesValidFromSquareToSquareFormat(): Unit =
|
||||
val result = Parser.parseMove("a1a2")
|
||||
assertTrue(result.isDefined)
|
||||
val (from, to) = result.get
|
||||
assertEquals(Square(File.A, Rank.R1), from)
|
||||
assertEquals(Square(File.A, Rank.R2), to)
|
||||
|
||||
@Test def parsesKnightMoveG1F3(): Unit =
|
||||
val result = Parser.parseMove("g1f3")
|
||||
assertEquals(Some((Square(File.G, Rank.R1), Square(File.F, Rank.R3))), result)
|
||||
|
||||
@Test def parsesCastlingRookMoveH1F1(): Unit =
|
||||
val result = Parser.parseMove("h1f1")
|
||||
assertEquals(Some((Square(File.H, Rank.R1), Square(File.F, Rank.R1))), result)
|
||||
|
||||
// ── Special inputs ──────────────────────────────────────────────────────
|
||||
|
||||
@Test def rejectsQuitString(): Unit =
|
||||
assertEquals(None, Parser.parseMove("quit"))
|
||||
|
||||
@Test def rejectsQString(): Unit =
|
||||
assertEquals(None, Parser.parseMove("q"))
|
||||
|
||||
@Test def rejectsRandomText(): Unit =
|
||||
assertEquals(None, Parser.parseMove("hello"))
|
||||
assertEquals(None, Parser.parseMove("board"))
|
||||
|
||||
@Test def rejectsSpecialChars(): Unit =
|
||||
assertEquals(None, Parser.parseMove("e2-e4"))
|
||||
assertEquals(None, Parser.parseMove("e2xe4"))
|
||||
assertEquals(None, Parser.parseMove("e2/e4"))
|
||||
|
||||
// ── Very long strings ──────────────────────────────────────────────────
|
||||
|
||||
@Test def rejectsVeryLongInput(): Unit =
|
||||
val longString = "a" * 1000 + "1a1"
|
||||
assertEquals(None, Parser.parseMove(longString))
|
||||
|
||||
@Test def rejectsVeryLongOnlyAfterTrimming(): Unit =
|
||||
val longString = " " + ("a" * 1000)
|
||||
assertEquals(None, Parser.parseMove(longString))
|
||||
|
||||
// ── Exact length boundary ──────────────────────────────────────────────
|
||||
|
||||
@Test def acceptsExactlyFourCharacters(): Unit =
|
||||
val result = Parser.parseMove("a1b2")
|
||||
assertTrue(result.isDefined)
|
||||
|
||||
@Test def rejectsThreeCharacters(): Unit =
|
||||
val result = Parser.parseMove("a1b")
|
||||
assertEquals(None, result)
|
||||
|
||||
@Test def rejectsFiveCharacters(): Unit =
|
||||
val result = Parser.parseMove("a1b2c")
|
||||
assertEquals(None, result)
|
||||
|
||||
// ── Consistent parsing ────────────────────────────────────────────────
|
||||
|
||||
@Test def parsesSameMoveMultipleTimes(): Unit =
|
||||
val move = "e2e4"
|
||||
val result1 = Parser.parseMove(move)
|
||||
val result2 = Parser.parseMove(move)
|
||||
assertEquals(result1, result2)
|
||||
|
||||
@Test def parsesComprehensiveSquareSet(): Unit =
|
||||
val squares = for
|
||||
f <- 'a' to 'h'
|
||||
r <- '1' to '8'
|
||||
yield s"${f}${r}${f}${r}"
|
||||
|
||||
for move <- squares do
|
||||
val result = Parser.parseMove(move)
|
||||
assertTrue(result.isDefined, s"Should parse $move")
|
||||
@@ -1,330 +0,0 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.chess.logic.MoveValidator
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class MoveValidatorExtendedTest:
|
||||
|
||||
private def sq(file: File, rank: Rank): Square = Square(file, rank)
|
||||
private def boardOf(pieces: (Square, Piece)*): Board = Board(pieces.toMap)
|
||||
|
||||
// ── Pawn edge cases ───────────────────────────────────────────────────
|
||||
|
||||
@Test def whitePawnCannotMoveTwoSquaresFromNonStartingRank(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R3), sq(File.E, Rank.R5)))
|
||||
|
||||
@Test def blackPawnCannotMoveTwoSquaresFromNonStartingRank(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R6) -> Piece.BlackPawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R6), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def whitePawnBlockedByPieceBeforeDoubleMove(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def whitePawnBlockedByPieceAfterDoubleMove(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R4) -> Piece.BlackPawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def blackPawnBlockedByPieceBeforeDoubleMove(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R7) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R6) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R7), sq(File.E, Rank.R5)))
|
||||
|
||||
@Test def blackPawnBlockedByPieceAfterDoubleMove(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R7) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R7), sq(File.E, Rank.R5)))
|
||||
|
||||
@Test def whitePawnForwardTwoAfterFirstMove(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R3) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R3), sq(File.D, Rank.R5)))
|
||||
|
||||
@Test def blackPawnBackwardIsIllegal(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R5), sq(File.E, Rank.R6)))
|
||||
|
||||
@Test def whitePawnBackwardIsIllegal(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R5) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R5), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def whitePawnSidewaysIsIllegal(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.D, Rank.R4)))
|
||||
|
||||
@Test def blackPawnSidewaysIsIllegal(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R5), sq(File.F, Rank.R5)))
|
||||
|
||||
@Test def whitePawnDiagonalTwoSquaresIsIllegal(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.G, Rank.R6)))
|
||||
|
||||
@Test def whitePawnCapturesLeftDiagonal(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.D, Rank.R5)))
|
||||
|
||||
@Test def whitePawnCapturesRightDiagonal(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.F, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.F, Rank.R5)))
|
||||
|
||||
@Test def blackPawnCapturesLeftDiagonal(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
sq(File.D, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R5), sq(File.D, Rank.R4)))
|
||||
|
||||
@Test def blackPawnCapturesRightDiagonal(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
sq(File.F, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R5), sq(File.F, Rank.R4)))
|
||||
|
||||
// ── Knight comprehensive tests ──────────────────────────────────────────
|
||||
|
||||
@Test def knightCanMoveToAllEightSquares(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
val expected = Set(
|
||||
sq(File.C, Rank.R6), sq(File.E, Rank.R6),
|
||||
sq(File.B, Rank.R5), sq(File.F, Rank.R5),
|
||||
sq(File.B, Rank.R3), sq(File.F, Rank.R3),
|
||||
sq(File.C, Rank.R2), sq(File.E, Rank.R2)
|
||||
)
|
||||
assertEquals(expected, targets)
|
||||
|
||||
@Test def knightInCornerHasFewerMoves(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R1) -> Piece.WhiteKnight)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.A, Rank.R1))
|
||||
assertEquals(2, targets.size)
|
||||
|
||||
@Test def knightNearEdgeHasFewerMoves(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R4) -> Piece.WhiteKnight)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.A, Rank.R4))
|
||||
assertEquals(4, targets.size)
|
||||
|
||||
@Test def knightCanJumpAllAroundBoard(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.B, Rank.R1) -> Piece.WhiteKnight,
|
||||
sq(File.C, Rank.R3) -> Piece.BlackRook
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.B, Rank.R1), sq(File.C, Rank.R3)))
|
||||
|
||||
@Test def knightCannotMoveToNonLShapeSquare(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.D, Rank.R5)))
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def knightAllTargetsExcludeOwnPieces(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
||||
sq(File.C, Rank.R6) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R6) -> Piece.WhiteRook
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertFalse(targets.contains(sq(File.C, Rank.R6)))
|
||||
assertFalse(targets.contains(sq(File.E, Rank.R6)))
|
||||
|
||||
@Test def knightAllTargetsIncludeEnemyPieces(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteKnight,
|
||||
sq(File.C, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R6) -> Piece.BlackRook
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertTrue(targets.contains(sq(File.C, Rank.R6)))
|
||||
assertTrue(targets.contains(sq(File.E, Rank.R6)))
|
||||
|
||||
// ── Bishop edge cases ──────────────────────────────────────────────────
|
||||
|
||||
@Test def bishopCannotMoveLikeRook(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R1) -> Piece.WhiteBishop)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R1), sq(File.D, Rank.R8)))
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R1), sq(File.H, Rank.R1)))
|
||||
|
||||
@Test def bishopAllDiagonalsBlocked(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteBishop,
|
||||
sq(File.C, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.C, Rank.R3) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertEquals(0, targets.size)
|
||||
|
||||
@Test def bishopAllDiagonalsFree(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteBishop)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertTrue(targets.size > 0)
|
||||
// All targets should be on diagonals
|
||||
for target <- targets do
|
||||
val fileDiff = math.abs(target.file.ordinal - sq(File.D, Rank.R4).file.ordinal)
|
||||
val rankDiff = math.abs(target.rank.ordinal - sq(File.D, Rank.R4).rank.ordinal)
|
||||
assertEquals(fileDiff, rankDiff)
|
||||
|
||||
@Test def bishopCapturesMultiplePiecesButNotBeyond(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.C, Rank.R1) -> Piece.WhiteBishop,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackRook,
|
||||
sq(File.F, Rank.R4) -> Piece.BlackBishop
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.C, Rank.R1))
|
||||
assertTrue(targets.contains(sq(File.E, Rank.R3)))
|
||||
assertFalse(targets.contains(sq(File.F, Rank.R4)))
|
||||
|
||||
// ── Rook edge cases ────────────────────────────────────────────────────
|
||||
|
||||
@Test def rookCannotMoveLikeBishop(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R1) -> Piece.WhiteRook)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R1), sq(File.H, Rank.R5)))
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R1), sq(File.A, Rank.R4)))
|
||||
|
||||
@Test def rookAllDirectionsBlocked(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R3) -> Piece.WhitePawn,
|
||||
sq(File.C, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertEquals(0, targets.size)
|
||||
|
||||
@Test def rookFullFileClear(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R4) -> Piece.WhiteRook)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.A, Rank.R4))
|
||||
assertEquals(14, targets.size) // 7 squares up and down, 7 left and right, minus itself
|
||||
|
||||
@Test def rookFullRankClear(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R1) -> Piece.WhiteRook)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R1))
|
||||
assertEquals(14, targets.size)
|
||||
|
||||
@Test def rookStoppedByOwnPieceOnFile(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R1))
|
||||
assertFalse(targets.contains(sq(File.D, Rank.R4)))
|
||||
assertFalse(targets.contains(sq(File.D, Rank.R8)))
|
||||
assertTrue(targets.contains(sq(File.D, Rank.R3)))
|
||||
|
||||
@Test def rookStoppedByOwnPieceOnRank(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.F, Rank.R1) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R1))
|
||||
assertFalse(targets.contains(sq(File.F, Rank.R1)))
|
||||
assertFalse(targets.contains(sq(File.H, Rank.R1)))
|
||||
assertTrue(targets.contains(sq(File.E, Rank.R1)))
|
||||
|
||||
// ── Queen comprehensive ────────────────────────────────────────────────
|
||||
|
||||
@Test def queenHasMoreMovesthanBishopOrRook(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteQueen,
|
||||
sq(File.E, Rank.R4) -> Piece.BlackPawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertTrue(targets.size > 8) // Should have plenty of moves
|
||||
|
||||
@Test def queenBlockedByOwnPieceBothWays(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteQueen,
|
||||
sq(File.D, Rank.R6) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertFalse(targets.contains(sq(File.D, Rank.R6)))
|
||||
assertFalse(targets.contains(sq(File.D, Rank.R8)))
|
||||
assertFalse(targets.contains(sq(File.E, Rank.R5)))
|
||||
assertFalse(targets.contains(sq(File.F, Rank.R6)))
|
||||
|
||||
@Test def queenCanCaptureButNotBeyond(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteQueen,
|
||||
sq(File.D, Rank.R7) -> Piece.BlackPawn,
|
||||
sq(File.D, Rank.R8) -> Piece.BlackPawn
|
||||
)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertTrue(targets.contains(sq(File.D, Rank.R7)))
|
||||
assertFalse(targets.contains(sq(File.D, Rank.R8)))
|
||||
|
||||
// ── King comprehensive ────────────────────────────────────────────────
|
||||
|
||||
@Test def kingMovesOnlyOneSquare(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKing)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
assertEquals(8, targets.size)
|
||||
|
||||
@Test def kingInCornerHasFewermoves(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R1) -> Piece.WhiteKing)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.A, Rank.R1))
|
||||
assertEquals(3, targets.size)
|
||||
|
||||
@Test def kingEdgeHasFewerMoves(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R4) -> Piece.WhiteKing)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.A, Rank.R4))
|
||||
assertEquals(5, targets.size)
|
||||
|
||||
@Test def kingCannotMoveMultipleSquares(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKing)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.D, Rank.R6)))
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.F, Rank.R4)))
|
||||
|
||||
// ── legalTargets returns Set ──────────────────────────────────────────
|
||||
|
||||
@Test def legalTargetsReturnsSet(): Unit =
|
||||
val board = Board.initial
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.E, Rank.R2))
|
||||
assertTrue(targets.isInstanceOf[Set[?]])
|
||||
|
||||
@Test def legalTargetsForWhitePawnE2On32(): Unit =
|
||||
val targets = MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R2))
|
||||
assertEquals(2, targets.size) // Can move to e3 or e4
|
||||
|
||||
@Test def legalTargetsForWhiteKnightG1(): Unit =
|
||||
val targets = MoveValidator.legalTargets(Board.initial, sq(File.G, Rank.R1))
|
||||
assertEquals(2, targets.size) // Can move to f3 or h3
|
||||
|
||||
// ── isLegal returns consistent results ──────────────────────────────
|
||||
|
||||
@Test def isLegalConsistentWithLegalTargets(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
for target <- targets do
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.D, Rank.R4), target))
|
||||
|
||||
@Test def isLegalReturnsFalseForNonTargetSquares(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteKnight)
|
||||
val targets = MoveValidator.legalTargets(board, sq(File.D, Rank.R4))
|
||||
val allSquares = for
|
||||
file <- File.values
|
||||
rank <- Rank.values
|
||||
yield Square(file, rank)
|
||||
for square <- allSquares do
|
||||
if !targets.contains(square) then
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), square))
|
||||
@@ -1,198 +0,0 @@
|
||||
package de.nowchess.chess.model
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.chess.logic.MoveValidator
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class MoveValidatorTest:
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private def sq(file: File, rank: Rank): Square = Square(file, rank)
|
||||
|
||||
/** Build a board with exactly the given pieces. */
|
||||
private def boardOf(pieces: (Square, Piece)*): Board =
|
||||
Board(pieces.toMap)
|
||||
|
||||
// ── Pawn ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def whitePawnForwardOneSquare(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R3)))
|
||||
|
||||
@Test def whitePawnDoublePushFromStartingRank(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def whitePawnBlockedDoublePush(): Unit =
|
||||
// Piece on e3 blocks the double push
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def whitePawnCannotPushForwardOntoOccupiedSquare(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R2), sq(File.E, Rank.R3)))
|
||||
|
||||
@Test def whitePawnDiagonalCaptureEnemy(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.D, Rank.R5)))
|
||||
|
||||
@Test def whitePawnCannotCaptureDiagonallyWithoutEnemy(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.D, Rank.R5)))
|
||||
|
||||
@Test def whitePawnCannotCaptureDiagonalOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.WhiteKnight
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R4), sq(File.D, Rank.R5)))
|
||||
|
||||
@Test def blackPawnForwardOneSquare(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R7), sq(File.E, Rank.R6)))
|
||||
|
||||
@Test def blackPawnDoublePushFromStartingRank(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R7) -> Piece.BlackPawn)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R7), sq(File.E, Rank.R5)))
|
||||
|
||||
// ── Knight ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def knightValidLShape(): Unit =
|
||||
val board = boardOf(sq(File.G, Rank.R1) -> Piece.WhiteKnight)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.F, Rank.R3)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.H, Rank.R3)))
|
||||
|
||||
@Test def knightJumpsOverPieces(): Unit =
|
||||
// Surround the knight with own pieces — it should still reach its L-targets
|
||||
val board = boardOf(
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteKnight,
|
||||
sq(File.G, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.F, Rank.R1) -> Piece.WhitePawn,
|
||||
sq(File.H, Rank.R1) -> Piece.WhitePawn,
|
||||
sq(File.F, Rank.R2) -> Piece.WhitePawn,
|
||||
sq(File.H, Rank.R2) -> Piece.WhitePawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.F, Rank.R3)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.H, Rank.R3)))
|
||||
|
||||
@Test def knightCannotLandOnOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteKnight,
|
||||
sq(File.F, Rank.R3) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.F, Rank.R3)))
|
||||
|
||||
@Test def knightCanCaptureEnemy(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.G, Rank.R1) -> Piece.WhiteKnight,
|
||||
sq(File.F, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.G, Rank.R1), sq(File.F, Rank.R3)))
|
||||
|
||||
// ── Bishop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def bishopDiagonalSlide(): Unit =
|
||||
val board = boardOf(sq(File.C, Rank.R1) -> Piece.WhiteBishop)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.C, Rank.R1), sq(File.F, Rank.R4)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.C, Rank.R1), sq(File.A, Rank.R3)))
|
||||
|
||||
@Test def bishopBlockedByOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.C, Rank.R1) -> Piece.WhiteBishop,
|
||||
sq(File.E, Rank.R3) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.C, Rank.R1), sq(File.F, Rank.R4)))
|
||||
|
||||
@Test def bishopCapturesFirstEnemy(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.C, Rank.R1) -> Piece.WhiteBishop,
|
||||
sq(File.E, Rank.R3) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.C, Rank.R1), sq(File.E, Rank.R3)))
|
||||
// Cannot reach beyond the captured piece
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.C, Rank.R1), sq(File.F, Rank.R4)))
|
||||
|
||||
// ── Rook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def rookOrthogonalSlide(): Unit =
|
||||
val board = boardOf(sq(File.A, Rank.R1) -> Piece.WhiteRook)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.A, Rank.R8)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.H, Rank.R1)))
|
||||
|
||||
@Test def rookBlockedByOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.A, Rank.R8)))
|
||||
// Can still reach squares before the blocker
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.A, Rank.R3)))
|
||||
|
||||
@Test def rookCapturesFirstEnemy(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R4) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.A, Rank.R4)))
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.A, Rank.R1), sq(File.A, Rank.R8)))
|
||||
|
||||
// ── Queen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def queenCombinesRookAndBishop(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
|
||||
// Orthogonal
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.D, Rank.R8)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.H, Rank.R4)))
|
||||
// Diagonal
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.G, Rank.R7)))
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.A, Rank.R1)))
|
||||
|
||||
@Test def queenBlockedByOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.D, Rank.R4) -> Piece.WhiteQueen,
|
||||
sq(File.D, Rank.R6) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.D, Rank.R4), sq(File.D, Rank.R8)))
|
||||
|
||||
// ── King ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test def kingMovesOneSquareInEachDirection(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhiteKing)
|
||||
val expected = Set(
|
||||
sq(File.E, Rank.R5), sq(File.E, Rank.R3),
|
||||
sq(File.D, Rank.R4), sq(File.F, Rank.R4),
|
||||
sq(File.D, Rank.R5), sq(File.F, Rank.R5),
|
||||
sq(File.D, Rank.R3), sq(File.F, Rank.R3)
|
||||
)
|
||||
assertEquals(expected, MoveValidator.legalTargets(board, sq(File.E, Rank.R4)))
|
||||
|
||||
@Test def kingCannotLandOnOwnPiece(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R2) -> Piece.WhitePawn
|
||||
)
|
||||
assertFalse(MoveValidator.isLegal(board, sq(File.E, Rank.R1), sq(File.E, Rank.R2)))
|
||||
|
||||
@Test def kingCanCaptureEnemy(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R2) -> Piece.BlackPawn
|
||||
)
|
||||
assertTrue(MoveValidator.isLegal(board, sq(File.E, Rank.R1), sq(File.E, Rank.R2)))
|
||||
|
||||
// ── Empty square ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test def noLegalTargetsForEmptySquare(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
assertEquals(Set.empty, MoveValidator.legalTargets(board, sq(File.A, Rank.R1)))
|
||||
@@ -1,115 +0,0 @@
|
||||
package de.nowchess.chess.view
|
||||
|
||||
import de.nowchess.api.board.{Color, Piece, PieceType}
|
||||
import de.nowchess.chess.view.unicode
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class PieceUnicodeTest:
|
||||
|
||||
// ── White pieces ───────────────────────────────────────────────────────
|
||||
|
||||
@Test def whiteKingUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.King)
|
||||
assertEquals("\u2654", piece.unicode)
|
||||
|
||||
@Test def whiteQueenUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.Queen)
|
||||
assertEquals("\u2655", piece.unicode)
|
||||
|
||||
@Test def whiteRookUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.Rook)
|
||||
assertEquals("\u2656", piece.unicode)
|
||||
|
||||
@Test def whiteBishopUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.Bishop)
|
||||
assertEquals("\u2657", piece.unicode)
|
||||
|
||||
@Test def whiteKnightUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.Knight)
|
||||
assertEquals("\u2658", piece.unicode)
|
||||
|
||||
@Test def whitePawnUnicode(): Unit =
|
||||
val piece = Piece(Color.White, PieceType.Pawn)
|
||||
assertEquals("\u2659", piece.unicode)
|
||||
|
||||
// ── Black pieces ───────────────────────────────────────────────────────
|
||||
|
||||
@Test def blackKingUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.King)
|
||||
assertEquals("\u265A", piece.unicode)
|
||||
|
||||
@Test def blackQueenUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.Queen)
|
||||
assertEquals("\u265B", piece.unicode)
|
||||
|
||||
@Test def blackRookUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.Rook)
|
||||
assertEquals("\u265C", piece.unicode)
|
||||
|
||||
@Test def blackBishopUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.Bishop)
|
||||
assertEquals("\u265D", piece.unicode)
|
||||
|
||||
@Test def blackKnightUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.Knight)
|
||||
assertEquals("\u265E", piece.unicode)
|
||||
|
||||
@Test def blackPawnUnicode(): Unit =
|
||||
val piece = Piece(Color.Black, PieceType.Pawn)
|
||||
assertEquals("\u265F", piece.unicode)
|
||||
|
||||
// ── Unicode lengths ───────────────────────────────────────────────────
|
||||
|
||||
@Test def eachUnicodeCharacterIsNonEmpty(): Unit =
|
||||
val pieces = Seq(
|
||||
Piece.WhiteKing, Piece.WhiteQueen, Piece.WhiteRook,
|
||||
Piece.WhiteBishop, Piece.WhiteKnight, Piece.WhitePawn,
|
||||
Piece.BlackKing, Piece.BlackQueen, Piece.BlackRook,
|
||||
Piece.BlackBishop, Piece.BlackKnight, Piece.BlackPawn
|
||||
)
|
||||
for piece <- pieces do
|
||||
assertFalse(piece.unicode.isEmpty)
|
||||
|
||||
@Test def unicodeCharactersAreDistinct(): Unit =
|
||||
val unicodes = Set(
|
||||
Piece.WhiteKing.unicode, Piece.WhiteQueen.unicode, Piece.WhiteRook.unicode,
|
||||
Piece.WhiteBishop.unicode, Piece.WhiteKnight.unicode, Piece.WhitePawn.unicode,
|
||||
Piece.BlackKing.unicode, Piece.BlackQueen.unicode, Piece.BlackRook.unicode,
|
||||
Piece.BlackBishop.unicode, Piece.BlackKnight.unicode, Piece.BlackPawn.unicode
|
||||
)
|
||||
assertEquals(12, unicodes.size)
|
||||
|
||||
// ── Convenience constructors ───────────────────────────────────────────
|
||||
|
||||
@Test def pieceConvenienceConstructorsReturnCorrectUnicode(): Unit =
|
||||
assertEquals("\u2654", Piece.WhiteKing.unicode)
|
||||
assertEquals("\u2655", Piece.WhiteQueen.unicode)
|
||||
assertEquals("\u2656", Piece.WhiteRook.unicode)
|
||||
assertEquals("\u2657", Piece.WhiteBishop.unicode)
|
||||
assertEquals("\u2658", Piece.WhiteKnight.unicode)
|
||||
assertEquals("\u2659", Piece.WhitePawn.unicode)
|
||||
assertEquals("\u265A", Piece.BlackKing.unicode)
|
||||
assertEquals("\u265B", Piece.BlackQueen.unicode)
|
||||
assertEquals("\u265C", Piece.BlackRook.unicode)
|
||||
assertEquals("\u265D", Piece.BlackBishop.unicode)
|
||||
assertEquals("\u265E", Piece.BlackKnight.unicode)
|
||||
assertEquals("\u265F", Piece.BlackPawn.unicode)
|
||||
|
||||
// ── Unicode roundtrip ──────────────────────────────────────────────────
|
||||
|
||||
@Test def createPieceAndGetUnicodeConsistently(): Unit =
|
||||
val whiteKing = Piece(Color.White, PieceType.King)
|
||||
val unicode1 = whiteKing.unicode
|
||||
val unicode2 = whiteKing.unicode
|
||||
assertEquals(unicode1, unicode2)
|
||||
|
||||
@Test def differentPiecesHaveDifferentUnicodes(): Unit =
|
||||
val king = Piece.WhiteKing
|
||||
val queen = Piece.WhiteQueen
|
||||
assertNotEquals(king.unicode, queen.unicode)
|
||||
|
||||
@Test def sameTypeDifferentColorHaveDifferentUnicodes(): Unit =
|
||||
val whitePawn = Piece.WhitePawn
|
||||
val blackPawn = Piece.BlackPawn
|
||||
assertNotEquals(whitePawn.unicode, blackPawn.unicode)
|
||||
@@ -1,187 +0,0 @@
|
||||
package de.nowchess.chess.view
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class RendererExtendedTest:
|
||||
|
||||
private def boardOf(pieces: (Square, Piece)*): Board = Board(pieces.toMap)
|
||||
private def sq(file: File, rank: Rank): Square = Square(file, rank)
|
||||
|
||||
// ── Empty board ────────────────────────────────────────────────────────
|
||||
|
||||
@Test def renderEmptyBoardContainsBoardFrame(): Unit =
|
||||
val emptyBoard = boardOf()
|
||||
val output = Renderer.render(emptyBoard)
|
||||
assertTrue(output.contains("a"), "Should contain file 'a'")
|
||||
assertTrue(output.contains("h"), "Should contain file 'h'")
|
||||
assertTrue(output.contains("1"), "Should contain rank '1'")
|
||||
assertTrue(output.contains("8"), "Should contain rank '8'")
|
||||
|
||||
@Test def renderEmptyBoardIsNotEmpty(): Unit =
|
||||
val emptyBoard = boardOf()
|
||||
val output = Renderer.render(emptyBoard)
|
||||
assertTrue(output.nonEmpty)
|
||||
|
||||
@Test def renderEmptyBoardContainsAnsiColors(): Unit =
|
||||
val emptyBoard = boardOf()
|
||||
val output = Renderer.render(emptyBoard)
|
||||
assertTrue(output.contains("\u001b[48;5;223m") || output.contains("\u001b[48;5;130m"))
|
||||
|
||||
// ── Single piece placement ─────────────────────────────────────────────
|
||||
|
||||
@Test def renderBoardWithSingleWhitePawn(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R2) -> Piece.WhitePawn)
|
||||
val output = Renderer.render(board)
|
||||
assertTrue(output.contains("\u2659"), "Should contain white pawn unicode")
|
||||
|
||||
@Test def renderBoardWithSingleBlackKing(): Unit =
|
||||
val board = boardOf(sq(File.E, Rank.R8) -> Piece.BlackKing)
|
||||
val output = Renderer.render(board)
|
||||
assertTrue(output.contains("\u265A"), "Should contain black king unicode")
|
||||
|
||||
@Test def renderBoardWithMultiplePieces(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.D, Rank.R8) -> Piece.BlackQueen,
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook
|
||||
)
|
||||
val output = Renderer.render(board)
|
||||
assertTrue(output.contains("\u2654")) // white king
|
||||
assertTrue(output.contains("\u265B")) // black queen
|
||||
assertTrue(output.contains("\u2656")) // white rook
|
||||
|
||||
// ── All pieces on board ────────────────────────────────────────────────
|
||||
|
||||
@Test def renderInitialBoardHasAllPieceTypes(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
// White pieces
|
||||
assertTrue(output.contains("\u2654"), "white king")
|
||||
assertTrue(output.contains("\u2655"), "white queen")
|
||||
assertTrue(output.contains("\u2656"), "white rook")
|
||||
assertTrue(output.contains("\u2657"), "white bishop")
|
||||
assertTrue(output.contains("\u2658"), "white knight")
|
||||
assertTrue(output.contains("\u2659"), "white pawn")
|
||||
// Black pieces
|
||||
assertTrue(output.contains("\u265A"), "black king")
|
||||
assertTrue(output.contains("\u265B"), "black queen")
|
||||
assertTrue(output.contains("\u265C"), "black rook")
|
||||
assertTrue(output.contains("\u265D"), "black bishop")
|
||||
assertTrue(output.contains("\u265E"), "black knight")
|
||||
assertTrue(output.contains("\u265F"), "black pawn")
|
||||
|
||||
// ── Board dimensions ──────────────────────────────────────────────────
|
||||
|
||||
@Test def renderIncludesAllFileLabels(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
for file <- Seq("a", "b", "c", "d", "e", "f", "g", "h") do
|
||||
assertTrue(output.contains(file))
|
||||
|
||||
@Test def renderIncludesAllRankLabels(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
for rank <- 1 to 8 do
|
||||
assertTrue(output.contains(rank.toString))
|
||||
|
||||
// ── Piece placement accuracy ───────────────────────────────────────────
|
||||
|
||||
@Test def renderCornerPiecesAreIncluded(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.H, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackRook,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackRook
|
||||
)
|
||||
val output = Renderer.render(board)
|
||||
val rookCount = output.count(_ == '\u2656') + output.count(_ == '\u265C')
|
||||
assertEquals(4, rookCount)
|
||||
|
||||
// ── ANSI codes ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test def renderContainsResetCode(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\u001b[0m"))
|
||||
|
||||
@Test def renderContainsBackgroundColors(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\u001b[48;5;223m") || output.contains("\u001b[48;5;130m"))
|
||||
|
||||
@Test def renderContainsForegroundColors(): Unit =
|
||||
val board = Board.initial
|
||||
val output = Renderer.render(board)
|
||||
// Should have some white text or black text for pieces
|
||||
assertTrue(output.contains("\u001b[97m") || output.contains("\u001b[30m"))
|
||||
|
||||
// ── Output consistency ─────────────────────────────────────────────────
|
||||
|
||||
@Test def renderingSameBoardProducesSameOutput(): Unit =
|
||||
val board = Board.initial
|
||||
val output1 = Renderer.render(board)
|
||||
val output2 = Renderer.render(board)
|
||||
assertEquals(output1, output2)
|
||||
|
||||
@Test def renderingDifferentBoardsProduceDifferentOutput(): Unit =
|
||||
val board1 = Board.initial
|
||||
val board2 = boardOf(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
val output1 = Renderer.render(board1)
|
||||
val output2 = Renderer.render(board2)
|
||||
assertNotEquals(output1, output2)
|
||||
|
||||
// ── Large outputs ──────────────────────────────────────────────────────
|
||||
|
||||
@Test def renderInitialBoardProducesReasonablyLargeOutput(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
// Should have multiple lines (8 ranks + labels)
|
||||
val lineCount = output.count(_ == '\n')
|
||||
assertTrue(lineCount > 8)
|
||||
|
||||
@Test def renderOutputContainsNewlines(): Unit =
|
||||
val output = Renderer.render(Board.initial)
|
||||
assertTrue(output.contains("\n"))
|
||||
|
||||
// ── Piece color in output ──────────────────────────────────────────────
|
||||
|
||||
@Test def renderBoardWithWhiteAndBlackPieces(): Unit =
|
||||
val board = boardOf(
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
)
|
||||
val output = Renderer.render(board)
|
||||
assertTrue(output.contains("\u2654")) // white king
|
||||
assertTrue(output.contains("\u265A")) // black king
|
||||
|
||||
// ── Pawn positions ────────────────────────────────────────────────────
|
||||
|
||||
@Test def renderBoardWithAllWhitePawns(): Unit =
|
||||
val pawns = (0 until 8).map(fileIdx =>
|
||||
sq(File.values(fileIdx), Rank.R2) -> Piece.WhitePawn
|
||||
)
|
||||
val board = boardOf(pawns*)
|
||||
val output = Renderer.render(board)
|
||||
val pawnCount = output.count(_ == '\u2659')
|
||||
assertEquals(8, pawnCount)
|
||||
|
||||
@Test def renderBoardWithAllBlackPawns(): Unit =
|
||||
val pawns = (0 until 8).map(fileIdx =>
|
||||
sq(File.values(fileIdx), Rank.R7) -> Piece.BlackPawn
|
||||
)
|
||||
val board = boardOf(pawns*)
|
||||
val output = Renderer.render(board)
|
||||
val pawnCount = output.count(_ == '\u265F')
|
||||
assertEquals(8, pawnCount)
|
||||
|
||||
// ── Center of board ───────────────────────────────────────────────────
|
||||
|
||||
@Test def renderBoardWithCenterPiece(): Unit =
|
||||
val board = boardOf(sq(File.D, Rank.R4) -> Piece.WhiteQueen)
|
||||
val output = Renderer.render(board)
|
||||
assertTrue(output.contains("\u2655"))
|
||||
|
||||
// ── Board doesn't mutate ──────────────────────────────────────────────
|
||||
|
||||
@Test def renderingBoardDoesNotMutateIt(): Unit =
|
||||
val board = Board.initial
|
||||
val pieceBefore = board.pieceAt(sq(File.E, Rank.R1))
|
||||
val _ = Renderer.render(board)
|
||||
val pieceAfter = board.pieceAt(sq(File.E, Rank.R1))
|
||||
assertEquals(pieceBefore, pieceAfter)
|
||||
Reference in New Issue
Block a user