chore: Update documentation and improve test writing guidelines

This commit is contained in:
2026-03-22 11:47:20 +01:00
parent a8abd69e0e
commit 51b210e9eb
27 changed files with 461 additions and 1762 deletions
+9 -4
View File
@@ -1,6 +1,6 @@
# Claude Code Working Agreement # Claude Code Working Agreement
## Workflow: Plan → Implement → Verify ## Workflow: Plan → Write Tests → Implement → Verify
### 1. Plan First ### 1. Plan First
Before writing any code, produce an explicit plan: 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. - Identify risks or unknowns upfront.
- Wait for confirmation **only** when the plan reveals an ambiguity that cannot be resolved from context. Otherwise proceed immediately. - 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. 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: 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. - 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). - 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>")`. - `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`). - Architecture decisions go in `docs/adr/` as numbered Markdown files (`ADR-001-<title>.md`).
- API contracts live in `/docs/api/`. - 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 - Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts
## Agent Routing Rules ## Agent Routing Rules
-5
View File
@@ -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)
-1
View File
@@ -4,7 +4,6 @@ description: "Designs service boundaries, API contracts, and writes ADRs. Invoke
tools: Read, Write, Glob, Edit, NotebookEdit, Grep, WebFetch, WebSearch tools: Read, Write, Glob, Edit, NotebookEdit, Grep, WebFetch, WebSearch
model: sonnet model: sonnet
color: red color: red
memory: project
--- ---
You don't have permission to write any code. You don't have permission to write any code.
You are a software architect specialising in microservice design. You are a software architect specialising in microservice design.
+6 -2
View File
@@ -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 tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
model: haiku model: haiku
color: purple color: purple
memory: project
--- ---
You don't have any permission to write any codes / tests. 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 — 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 - Jakarta annotations only, not javax
- Reactive types (Uni, Multi) for I/O operations - Reactive types (Uni, Multi) for I/O operations
- No blocking calls on the event loop - 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 ### Code quality
- No functions over 30 lines - No functions over 30 lines
-1
View File
@@ -4,7 +4,6 @@ description: "Manages the multi-module Gradle build, dependencies, and resolves
tools: Read, Write, Edit, Bash tools: Read, Write, Edit, Bash
model: haiku model: haiku
color: yellow color: yellow
memory: project
--- ---
You manage a Gradle multi-module Scala 3 + Quarkus project. You manage a Gradle multi-module Scala 3 + Quarkus project.
-1
View File
@@ -4,7 +4,6 @@ description: "Implements Scala 3 + Quarkus REST services, domain logic, and pers
tools: Read, Write, Edit, Bash, Glob tools: Read, Write, Edit, Bash, Glob
model: sonnet model: sonnet
color: pink color: pink
memory: project
--- ---
You do not have permissions to write tests, just source code. You do not have permissions to write tests, just source code.
You are a Scala 3 expert specialising in Quarkus microservices. You are a Scala 3 expert specialising in Quarkus microservices.
+14 -6
View File
@@ -2,14 +2,22 @@
name: test-writer name: test-writer
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished." 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 tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
model: haiku model: sonnet
color: purple color: purple
memory: project
--- ---
You do not have permissions to modify the source code, just write tests. You do not have permissions to modify the source code, just write tests.
You write tests for Scala 3 + Quarkus services. 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. 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.
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="" /> <option name="gradleJvm" value="corretto-21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+3 -2
View File
@@ -45,12 +45,13 @@ The only current module is `core` (`modules/core`).
- Jakarta annotations only (`jakarta.*`), never `javax.*`. - Jakarta annotations only (`jakarta.*`), never `javax.*`.
- Use reactive types (`Uni`, `Multi`) for I/O; no blocking calls on the event loop. - 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. - **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) ### Agent Workflow (for new services)
1. **architect** → writes OpenAPI contract to `docs/api/{service}.yaml` and ADR to `docs/adr/`. 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}/`. 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. 4. **gradle-builder** → resolves any build/dependency issues.
5. **code-reviewer** → reviews; reports findings back without self-fixing. 5. **code-reviewer** → reviews; reports findings back without self-fixing.
+411
View File
@@ -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()
+12 -9
View File
@@ -31,13 +31,6 @@ tasks.named<JavaExec>("run") {
standardInput = System.`in` standardInput = System.`in`
} }
tasks.withType<Test> {
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = true
}
}
tasks.test { tasks.test {
finalizedBy(tasks.jacocoTestReport) finalizedBy(tasks.jacocoTestReport)
} }
@@ -72,8 +65,18 @@ dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") 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 { tasks {
useJUnitPlatform() 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.api.board.{Board, Color}
import de.nowchess.chess.controller.GameController import de.nowchess.chess.controller.GameController
@main def chessMain(): Unit = object Main {
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.") def main(args: Array[String]): Unit =
GameController.gameLoop(Board.initial, Color.White) 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)