From 51b210e9ebce8934b64985495f8b7ab385448672 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 22 Mar 2026 11:47:20 +0100 Subject: [PATCH] chore: Update documentation and improve test writing guidelines --- .claude/CLAUDE.MD | 13 +- .claude/agent-memory/architect/MEMORY.md | 5 - .../architect/project_api_module.md | 20 - .../agent-memory/scala-implementer/MEMORY.md | 4 - .../scala-implementer/project_chess_tui.md | 25 -- .../test-writer/scala-quarkus-coverage.md | 40 -- .../agent-memory/test-writer/test-coverage.md | 90 ---- .claude/agents/architect.md | 1 - .claude/agents/code-reviewer.md | 8 +- .claude/agents/gradle-builder.md | 1 - .claude/agents/scala-implementer.md | 1 - .claude/agents/test-writer.md | 20 +- .idea/gradle.xml | 2 +- CLAUDE.md | 5 +- jacoco-reporter/jacoco_coverage_gaps.py | 411 ++++++++++++++++++ modules/core/build.gradle.kts | 21 +- .../main/scala/de/nowchess/chess/Main.scala | 8 +- .../scala/de/nowchess/chess/MainTest.scala | 23 - .../scala/de/nowchess/chess/ModelTest.scala | 59 --- .../scala/de/nowchess/chess/ParserTest.scala | 33 -- .../de/nowchess/chess/RendererTest.scala | 35 -- .../chess/controller/GameControllerTest.scala | 359 --------------- .../chess/controller/ParserExtendedTest.scala | 209 --------- .../logic/MoveValidatorExtendedTest.scala | 330 -------------- .../chess/model/MoveValidatorTest.scala | 198 --------- .../chess/view/PieceUnicodeTest.scala | 115 ----- .../chess/view/RendererExtendedTest.scala | 187 -------- 27 files changed, 461 insertions(+), 1762 deletions(-) delete mode 100644 .claude/agent-memory/architect/MEMORY.md delete mode 100644 .claude/agent-memory/architect/project_api_module.md delete mode 100644 .claude/agent-memory/scala-implementer/MEMORY.md delete mode 100644 .claude/agent-memory/scala-implementer/project_chess_tui.md delete mode 100644 .claude/agent-memory/test-writer/scala-quarkus-coverage.md delete mode 100644 .claude/agent-memory/test-writer/test-coverage.md create mode 100644 jacoco-reporter/jacoco_coverage_gaps.py delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/MainTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/controller/ParserExtendedTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorExtendedTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/model/MoveValidatorTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/view/PieceUnicodeTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/view/RendererExtendedTest.scala diff --git a/.claude/CLAUDE.MD b/.claude/CLAUDE.MD index cefe0fd..4555141 100644 --- a/.claude/CLAUDE.MD +++ b/.claude/CLAUDE.MD @@ -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:")`. - Architecture decisions go in `docs/adr/` as numbered Markdown files (`ADR-001-.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 diff --git a/.claude/agent-memory/architect/MEMORY.md b/.claude/agent-memory/architect/MEMORY.md deleted file mode 100644 index 5d02269..0000000 --- a/.claude/agent-memory/architect/MEMORY.md +++ /dev/null @@ -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 diff --git a/.claude/agent-memory/architect/project_api_module.md b/.claude/agent-memory/architect/project_api_module.md deleted file mode 100644 index d174b32..0000000 --- a/.claude/agent-memory/architect/project_api_module.md +++ /dev/null @@ -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` diff --git a/.claude/agent-memory/scala-implementer/MEMORY.md b/.claude/agent-memory/scala-implementer/MEMORY.md deleted file mode 100644 index d7f5b56..0000000 --- a/.claude/agent-memory/scala-implementer/MEMORY.md +++ /dev/null @@ -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 diff --git a/.claude/agent-memory/scala-implementer/project_chess_tui.md b/.claude/agent-memory/scala-implementer/project_chess_tui.md deleted file mode 100644 index 045e148..0000000 --- a/.claude/agent-memory/scala-implementer/project_chess_tui.md +++ /dev/null @@ -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) diff --git a/.claude/agent-memory/test-writer/scala-quarkus-coverage.md b/.claude/agent-memory/test-writer/scala-quarkus-coverage.md deleted file mode 100644 index bebd7f3..0000000 --- a/.claude/agent-memory/test-writer/scala-quarkus-coverage.md +++ /dev/null @@ -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. diff --git a/.claude/agent-memory/test-writer/test-coverage.md b/.claude/agent-memory/test-writer/test-coverage.md deleted file mode 100644 index f1806bb..0000000 --- a/.claude/agent-memory/test-writer/test-coverage.md +++ /dev/null @@ -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) diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md index f48ab4d..798f621 100644 --- a/.claude/agents/architect.md +++ b/.claude/agents/architect.md @@ -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. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index c784e1a..37696e8 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -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 diff --git a/.claude/agents/gradle-builder.md b/.claude/agents/gradle-builder.md index d6bd77b..f634c34 100644 --- a/.claude/agents/gradle-builder.md +++ b/.claude/agents/gradle-builder.md @@ -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. diff --git a/.claude/agents/scala-implementer.md b/.claude/agents/scala-implementer.md index 7958675..845c9e7 100644 --- a/.claude/agents/scala-implementer.md +++ b/.claude/agents/scala-implementer.md @@ -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. diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index b4bd474..3015a87 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -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. diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 4719957..4f4edba 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -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$" /> diff --git a/CLAUDE.md b/CLAUDE.md index ce7b422..85b9e46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/jacoco-reporter/jacoco_coverage_gaps.py b/jacoco-reporter/jacoco_coverage_gaps.py new file mode 100644 index 0000000..950711f --- /dev/null +++ b/jacoco-reporter/jacoco_coverage_gaps.py @@ -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() \ No newline at end of file diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index 162ead6..0d60783 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -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") + } + } + } } \ No newline at end of file diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala index b1d63db..eee7624 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/Main.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/Main.scala @@ -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) +} diff --git a/modules/core/src/test/scala/de/nowchess/chess/MainTest.scala b/modules/core/src/test/scala/de/nowchess/chess/MainTest.scala deleted file mode 100644 index 02ea792..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/MainTest.scala +++ /dev/null @@ -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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala deleted file mode 100644 index c9bc06b..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/ModelTest.scala +++ /dev/null @@ -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)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala deleted file mode 100644 index 5650c68..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/ParserTest.scala +++ /dev/null @@ -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")) diff --git a/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala b/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala deleted file mode 100644 index d15cf49..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/RendererTest.scala +++ /dev/null @@ -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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala deleted file mode 100644 index 36de6a7..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ /dev/null @@ -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]) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/ParserExtendedTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/ParserExtendedTest.scala deleted file mode 100644 index 714ab2a..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/ParserExtendedTest.scala +++ /dev/null @@ -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") diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorExtendedTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorExtendedTest.scala deleted file mode 100644 index d9396e9..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorExtendedTest.scala +++ /dev/null @@ -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)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/model/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/model/MoveValidatorTest.scala deleted file mode 100644 index 56c455a..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/model/MoveValidatorTest.scala +++ /dev/null @@ -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))) diff --git a/modules/core/src/test/scala/de/nowchess/chess/view/PieceUnicodeTest.scala b/modules/core/src/test/scala/de/nowchess/chess/view/PieceUnicodeTest.scala deleted file mode 100644 index 036a50e..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/view/PieceUnicodeTest.scala +++ /dev/null @@ -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) diff --git a/modules/core/src/test/scala/de/nowchess/chess/view/RendererExtendedTest.scala b/modules/core/src/test/scala/de/nowchess/chess/view/RendererExtendedTest.scala deleted file mode 100644 index 9345f56..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/view/RendererExtendedTest.scala +++ /dev/null @@ -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)