diff --git a/.claude/CLAUDE.MD b/.claude/CLAUDE.MD index 4555141..1823cb4 100644 --- a/.claude/CLAUDE.MD +++ b/.claude/CLAUDE.MD @@ -89,6 +89,13 @@ Create the file if it does not exist. Never delete existing entries. - Integration tests use `@QuarkusTest` with JUnit 5 — `@Test` methods must be explicitly typed `: Unit` - Always exclude scala-library from Quarkus deps to avoid Scala 2 conflicts +## Coverage Conventions +- Branch coverage must be at least 90% - unless there is a good reason not to. +- Line coverage must be at least 95% - unless there is a good reason not to. +- Method coverage must be at least 90% - unless there is a good reason not to. +- To check coverage use jacoco-reporter/scoverage_coverage_gaps.py modules/{service}/build/reports/scoverageTest/scoverage.xml +- IMPORTANT: modules/{service}/build/reports/scoverage/scoverage.xml is not used for coverage TEST calculation. Do not use it. + ## Agent Routing Rules ### Use agents in PARALLEL when: diff --git a/.claude/agents/team-lead.md b/.claude/agents/team-lead.md deleted file mode 100644 index 3885ae9..0000000 --- a/.claude/agents/team-lead.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: team-lead -description: "Use this agent when the user wants to build a new feature, service, or capability from scratch and needs end-to-end coordination across the full development lifecycle — from ideation through architecture, implementation, testing, and review. This agent orchestrates all specialist agents (architect, scala-implementer, test-writer, gradle-builder, code-reviewer) and ensures the project's working agreement (Plan → Implement → Verify) is followed rigorously.\\n\\n\\nContext: The user wants to build a new chess rating service.\\nuser: \"I want to add a rating service that calculates Elo ratings for players after each game.\"\\nassistant: \"Let me use the team-lead agent to analyse the requirement, identify gaps, create a plan, and coordinate the specialist agents.\"\\n\\nThe user has a new feature idea that spans architecture, implementation, testing, and review. The team-lead agent should be launched to orchestrate the full workflow.\\n\\n\\n\\n\\nContext: The user has a vague idea and needs help fleshing it out before any code is written.\\nuser: \"We need some kind of tournament management feature.\"\\nassistant: \"I'll launch the team-lead agent to interview you about requirements, surface gaps, and then drive the build pipeline once we have a solid plan.\"\\n\\nThe request is intentionally vague. The team-lead agent is the right entry point because it will probe for missing requirements before dispatching any specialist agents.\\n\\n\\n\\n\\nContext: The user wants a complete new microservice built end-to-end.\\nuser: \"Please build the game-history service — it should store finished games and expose an API to query them.\"\\nassistant: \"I'm launching the team-lead agent to plan this service, coordinate architect → scala-implementer → test-writer in sequence, and run a final code-review pass.\"\\n\\nEnd-to-end service creation with clear sequential dependencies is exactly the team-lead agent's remit.\\n\\n" -model: sonnet -color: orange -memory: project ---- - -You are the **Team Lead** for the NowChessSystems chess platform. You are the single point of coordination for all specialist agents: **architect**, **scala-implementer**, **test-writer**, **gradle-builder**, and **code-reviewer**. Your job is to take a user's idea all the way from fuzzy requirement to green build, test-driven, while faithfully following the project's working agreement. - ---- - -## Your Mandate - -1. **Understand before building** — Never start implementation until requirements are clear enough to write a plan with no unresolved ambiguities. -2. **Test-driven by default** — Tests are specified alongside (or before) implementation. A feature is not done until automated tests are green. -3. **Orchestrate, don't implement** — You delegate all coding, testing, and build work to specialist agents. You plan, route, verify, and report. -4. **Follow the working agreement** — Plan → Implement → Verify. Document unresolved items in `docs/unresolved.md`. - ---- - -## Phase 1 — Requirement Discovery - -When the user brings you a new idea: - -1. **Restate** the idea in your own words and confirm understanding with the user. -2. **Gap analysis** — Identify and list every ambiguity, missing constraint, or dependency that must be resolved before a plan can be written. Ask focused, numbered questions; do not bombard the user with more than 5 at a time. -3. **Inputs to clarify** (use as a checklist): - - Scope: what is explicitly IN and OUT of this feature? - - API surface: REST, event, internal only? - - Persistence: new entity, extend existing, read-only? - - Auth / security requirements? - - Performance / SLA expectations? - - Integration points with existing modules? - - Acceptance criteria — how will we know it works? -4. **Do not proceed to Phase 2** until all blockers are resolved or explicitly accepted as assumptions. - ---- - -## Phase 2 — Plan Creation - -Produce a structured plan: - -``` -## Feature Plan: - -### Requirement Summary - - -### Assumptions -- - -### Acceptance Criteria -1. -2. … - -### Agent Workflow -| Step | Agent | Input | Output | Parallel? | -|------|-------|-------|--------|-----------| -| 1 | architect | requirements | OpenAPI YAML + ADR | no | -| 2 | test-writer | OpenAPI contract | failing test suite | no | -| 3 | scala-implementer | contract + failing tests | implementation | no | -| 4 | gradle-builder | module build files | green build | no | -| 5 | code-reviewer | all changed files | review report | no | - -### Files to Create / Modify -- docs/api/.yaml -- docs/adr/ADR-XXX-.md -- modules/<service>/build.gradle.kts -- modules/<service>/src/… -- docs/unresolved.md (if needed) - -### Risks -- <Risk and mitigation> -``` - -Present the plan to the user and wait for explicit approval before dispatching any agents. - ---- - -## Phase 3 — Agent Dispatch - -### Routing rules (from the project working agreement) - -**Sequential** when tasks have dependencies: -- architect → test-writer → scala-implementer → gradle-builder → code-reviewer -- Any step that consumes an artifact produced by a prior step. - -**Parallel** when tasks are fully independent: -- Multiple independent microservices with no shared contracts. -- Disjoint file sets and no shared state. - -### Dispatch checklist before calling any agent -- [ ] Plan is approved by the user. -- [ ] The agent's required inputs are available (e.g., OpenAPI contract exists before scala-implementer runs). -- [ ] The agent's output artifact is clearly defined. - -### How to call agents -Use the Agent tool for every specialist invocation. Provide: -- The agent identifier. -- A concise, complete brief including: task description, relevant file paths, acceptance criteria, and any constraints from the project stack (Scala 3, Quarkus, Jakarta, reactive types, unit tests use `AnyFunSuite with Matchers with JUnitSuiteLike`, integration tests use `@QuarkusTest` with `: Unit` on `@Test` methods, exclude `scala-library` from Quarkus BOM). - ---- - -## Phase 4 — Verification & Sign-off - -After all agents complete: - -1. **Verify each acceptance criterion** one by one — explicitly state PASS or FAIL. -2. **Confirm the build is green**: `./gradlew :modules:<service>:build` (or root build). -3. **Review the code-reviewer's report** — if blockers are found, dispatch fixes via scala-implementer or gradle-builder and re-run the reviewer. -4. **Log unresolved items** in `docs/unresolved.md` using the standard template if any criterion cannot be met. -5. **Report to the user**: summary of what was built, tests written, open items. - ---- - -## Project Stack Constraints (enforce in every agent brief) - -- Language: **Scala 3.5.x** — use `given`/`using`, `Option`/`Either`/`Try`, never `null` or `.get`, no Scala 2 idioms. -- Framework: **Quarkus** with `quarkus-scala3` extension. -- Reactive I/O: **`Uni` / `Multi`** — no blocking calls on the event loop. -- Annotations: **`jakarta.*`** only, never `javax.*`. -- Unit tests: **`AnyFunSuite with Matchers with JUnitSuiteLike`** — use ScalaTest `test("name") { ... }` DSL, no `@Test` annotation. -- Integration tests: **`@QuarkusTest` with JUnit 5** — `@Test` methods must have explicit `: Unit` return type. -- Build: **Gradle multi-module** — always exclude `org.scala-lang:scala-library` from Quarkus BOM dependencies. -- Module location: `modules/{service-name}` — never place service code in the root. -- API contracts: `docs/api/{service}.yaml` (OpenAPI). -- ADRs: `docs/adr/ADR-XXX-<title>.md`. - ---- - -## Behavioural Rules - -- **Never write production code yourself.** Delegate to specialist agents. -- **Never skip the planning phase** even for 'small' requests — scope creep starts with assumptions. -- **Never mark a task done without a green build** and all acceptance criteria verified. -- **Proactively surface risks** — if a dispatch step reveals a new unknown, pause, inform the user, and update the plan. -- **Be concise in status updates** — use structured markdown; avoid walls of prose. -- If the same build or test failure persists after three automated fix attempts, stop and log it in `docs/unresolved.md`. diff --git a/build.gradle.kts b/build.gradle.kts index 2e8b1b4..7938926 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ val versions = mapOf( "SCALA3" to "3.5.1", "SCALA_LIBRARY" to "2.13.18", "SCALATEST" to "3.2.19", - "SCALATESTPLUS_JUNIT5" to "3.2.19.0", + "SCALATEST_JUNIT" to "0.1.11", "SCOVERAGE" to "2.1.1" ) extra["VERSIONS"] = versions diff --git a/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md b/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md new file mode 100644 index 0000000..d6d98f9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-scalatest-scoverage.md @@ -0,0 +1,244 @@ +# ScalaTest + Scoverage Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace JaCoCo with Scoverage and add ScalaTest (with its JUnit 5 bridge) as the test library across all modules. + +**Architecture:** Three build files are modified — the root for shared dependency versions, and each module for plugins, dependencies, and task wiring. No source files are created. The Scoverage Gradle plugin is applied per-module with its version hardcoded inline (Gradle resolves `plugins {}` before `rootProject.extra` is available). + +**Tech Stack:** Scala 3, Gradle (Kotlin DSL), ScalaTest 3.2.19, scalatestplus-junit-5-11 3.2.19.1, Scoverage Gradle plugin 8.1. + +--- + +## File Map + +| File | Change | +|---|---| +| `build.gradle.kts` (root) | Add `SCALATEST` and `SCALATESTPLUS_JUNIT5` version entries | +| `modules/core/build.gradle.kts` | Replace `jacoco` with `org.scoverage`; swap JUnit deps for ScalaTest; merge two `tasks.test {}` blocks | +| `modules/api/build.gradle.kts` | Same as core; also add missing `useJUnitPlatform()` | + +--- + +### Task 1: Add ScalaTest version entries to root build + +**Files:** +- Modify: `build.gradle.kts` (root) + +- [ ] **Step 1: Add version entries** + +Open `build.gradle.kts` at the root. The `versions` map currently looks like: + +```kotlin +val versions = mapOf( + "QUARKUS_SCALA3" to "1.0.0", + "SCALA3" to "3.5.1", + "SCALA_LIBRARY" to "2.13.18" +) +``` + +Add two entries so it becomes: + +```kotlin +val versions = mapOf( + "QUARKUS_SCALA3" to "1.0.0", + "SCALA3" to "3.5.1", + "SCALA_LIBRARY" to "2.13.18", + "SCALATEST" to "3.2.19", + "SCALATESTPLUS_JUNIT5" to "3.2.19.1" +) +``` + +- [ ] **Step 2: Verify the root build file parses** + +```bash +./gradlew help --quiet +``` + +Expected: exits 0 with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add build.gradle.kts +git commit -m "build: add ScalaTest version entries to root versions map" +``` + +--- + +### Task 2: Migrate `modules/core` to ScalaTest + Scoverage + +**Files:** +- Modify: `modules/core/build.gradle.kts` + +- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`** + +In the `plugins {}` block, replace: +```kotlin +jacoco +``` +with: +```kotlin +id("org.scoverage") version "8.1" +``` + +The full plugins block should be: +```kotlin +plugins { + id("scala") + id("org.scoverage") version "8.1" + application +} +``` + +- [ ] **Step 2: Swap JUnit dependencies for ScalaTest** + +In the `dependencies {}` block, remove: +```kotlin +testImplementation(platform("org.junit:junit-bom:5.10.0")) +testImplementation("org.junit.jupiter:junit-jupiter") +testRuntimeOnly("org.junit.platform:junit-platform-launcher") +``` + +Add in their place: +```kotlin +testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") +testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") +``` + +- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring** + +The file currently has two separate `tasks.test {}` blocks and a `tasks.jacocoTestReport {}` block. Delete all three. Add the following single merged block **after** the `dependencies {}` block: + +```kotlin +tasks.test { + useJUnitPlatform() + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +./gradlew :modules:core:test +``` + +Expected: BUILD SUCCESSFUL. (Zero tests is fine — there are no test files yet. The build must not fail with dependency resolution or plugin errors.) + +- [ ] **Step 5: Run the coverage report** + +```bash +./gradlew :modules:core:reportScoverage +``` + +Expected: BUILD SUCCESSFUL. A report is generated under `modules/core/build/reports/scoverage/`. + +- [ ] **Step 6: Commit** + +```bash +git add modules/core/build.gradle.kts +git commit -m "build(core): replace JaCoCo with Scoverage, add ScalaTest dependencies" +``` + +--- + +### Task 3: Migrate `modules/api` to ScalaTest + Scoverage + +**Files:** +- Modify: `modules/api/build.gradle.kts` + +- [ ] **Step 1: Replace the `jacoco` plugin with `org.scoverage`** + +In the `plugins {}` block, replace: +```kotlin +jacoco +``` +with: +```kotlin +id("org.scoverage") version "8.1" +``` + +The full plugins block should be: +```kotlin +plugins { + id("scala") + id("org.scoverage") version "8.1" +} +``` + +- [ ] **Step 2: Swap JUnit dependencies for ScalaTest** + +In the `dependencies {}` block, remove: +```kotlin +testImplementation(platform("org.junit:junit-bom:5.10.0")) +testImplementation("org.junit.jupiter:junit-jupiter") +testRuntimeOnly("org.junit.platform:junit-platform-launcher") +``` + +Add in their place: +```kotlin +testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") +testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") +``` + +- [ ] **Step 3: Merge the two `tasks.test {}` blocks and replace jacoco wiring** + +The `modules/api` file also has two `tasks.test {}` blocks and a `jacocoTestReport` block. Delete all three. Add the following merged block **after** the `dependencies {}` block: + +```kotlin +tasks.test { + useJUnitPlatform() + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} +``` + +> Note: `modules/api` did not previously have `useJUnitPlatform()` — it is being **added** here, not preserved. + +- [ ] **Step 4: Run the tests** + +```bash +./gradlew :modules:api:test +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Run the coverage report** + +```bash +./gradlew :modules:api:reportScoverage +``` + +Expected: BUILD SUCCESSFUL. A report is generated under `modules/api/build/reports/scoverage/`. + +- [ ] **Step 6: Commit** + +```bash +git add modules/api/build.gradle.kts +git commit -m "build(api): replace JaCoCo with Scoverage, add ScalaTest dependencies" +``` + +--- + +### Task 4: Full build verification + +- [ ] **Step 1: Run the full build** + +```bash +./gradlew build +``` + +Expected: BUILD SUCCESSFUL with no errors across all modules. + +- [ ] **Step 2: Confirm no JaCoCo references remain** + +```bash +grep -r "jacoco\|jacocoTestReport" --include="*.kts" . +``` + +Expected: no output (zero matches). diff --git a/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md b/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md new file mode 100644 index 0000000..53db28e --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-scalatest-scoverage-design.md @@ -0,0 +1,85 @@ +# Design: Add ScalaTest + Replace JaCoCo with Scoverage + +**Date:** 2026-03-22 +**Status:** Approved + +## Summary + +Replace the current JUnit-only test setup and JaCoCo coverage with ScalaTest (via its JUnit 5 bridge) and Scoverage across both `modules/core` and `modules/api`. + +## Motivation + +- The CLAUDE.md working agreement prescribes `AnyFunSuite with Matchers with JUnitSuiteLike` as the unit test style, which requires ScalaTest. +- Scoverage is the standard Scala code coverage tool and understands Scala semantics; JaCoCo's JVM bytecode instrumentation is less accurate for Scala code. + +## Scope + +Two modules are affected: `modules/core` and `modules/api`. The root `build.gradle.kts` is updated for shared dependency versions only. + +## Changes + +### Root `build.gradle.kts` + +Add to the `versions` map (dependency versions only — plugin version is hardcoded per module, see note below): +- `SCALATEST` → `3.2.19` +- `SCALATESTPLUS_JUNIT5` → `3.2.19.1` + +> **Note on plugin versioning:** Gradle resolves the `plugins {}` block before `rootProject.extra` is available, so the Scoverage plugin version (`8.1`) must be declared inline in each module's `plugins {}` block. It cannot be read from the root versions map. + +### `modules/core/build.gradle.kts` and `modules/api/build.gradle.kts` + +Both modules require the same set of changes. Both currently have **two separate `tasks.test {}` blocks** that must be merged into one. + +**Plugins block:** +- Remove `jacoco` +- Add `id("org.scoverage") version "8.1"` + +**Dependencies block:** +- Remove `testImplementation(platform("org.junit:junit-bom:5.10.0"))` +- Remove `testImplementation("org.junit.jupiter:junit-jupiter")` +- Remove `testRuntimeOnly("org.junit.platform:junit-platform-launcher")` +- Add `testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")` +- Add `testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}")` + +**Task wiring — merge both `tasks.test {}` blocks into one and replace jacoco wiring:** + +Both `modules/core` and `modules/api` currently have two `tasks.test {}` blocks. Delete both and replace with the following single merged block placed **after** the `dependencies {}` block (conventional position): + +```kotlin +tasks.test { + useJUnitPlatform() // required — scalatestplus JUnit 5 bridge relies on this + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} +``` + +> Note: `modules/api` does not currently have `useJUnitPlatform()` — it must be **added** (not just kept) in the merged block. + +Remove the `jacocoTestReport` task block entirely from both modules. + +**Task name confirmation:** The Scoverage Gradle plugin 8.1 registers `reportScoverage` as the HTML report task. + +## Versions + +| Artifact | Version | Notes | +|---|---|---| +| `org.scalatest:scalatest_3` | 3.2.19 | Core ScalaTest for Scala 3 | +| `org.scalatestplus:junit-5-11_3` | 3.2.19.1 | JUnit 5.11 runner bridge; `.1` = build 1 | +| Scoverage Gradle plugin | 8.1 | Hardcoded inline in `plugins {}` block | + +## Testing the Change + +After applying: +1. `./gradlew :modules:core:test` and `./gradlew :modules:api:test` must pass (green, even with zero test files). +2. `./gradlew :modules:core:reportScoverage` must produce a coverage report. +3. `./gradlew build` must be fully green. + +## Files Modified + +- `build.gradle.kts` (root) — add two version entries +- `modules/core/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, replace jacoco wiring +- `modules/api/build.gradle.kts` — plugin, deps, merge two `tasks.test` blocks, add `useJUnitPlatform()`, replace jacoco wiring + +No new source files are created. diff --git a/jacoco-reporter/scoverage_coverage_gaps.py b/jacoco-reporter/scoverage_coverage_gaps.py new file mode 100644 index 0000000..6e9b790 --- /dev/null +++ b/jacoco-reporter/scoverage_coverage_gaps.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +""" +scoverage Coverage Gap Reporter +Parses a scoverage XML report (scoverage.xml) and outputs missing statement +and branch (conditional) coverage in a structured format that Claude Code +agents can act on directly. + +scoverage tracks coverage at the *statement* level (not bytecode instruction +level like JaCoCo). Each <statement> element has: + - line : source line number + - branch="true|false" : whether this statement is a branch point + - invocation-count : how many times it was executed (0 = not covered) + - ignored : if true, excluded from coverage metrics + +Usage: + python scoverage_coverage_gaps.py <scoverage.xml> + python scoverage_coverage_gaps.py <scoverage.xml> --output json + python scoverage_coverage_gaps.py <scoverage.xml> --output markdown + python scoverage_coverage_gaps.py <scoverage.xml> --output agent (default) + python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller + python scoverage_coverage_gaps.py <scoverage.xml> --min-coverage 80 +""" + +import xml.etree.ElementTree as ET +import sys +import argparse +import json +import re +from pathlib import Path, PureWindowsPath +from dataclasses import dataclass, field +from typing import Optional + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class Statement: + line: int + is_branch: bool + invocation_count: int + ignored: bool + method: str + + @property + def is_covered(self) -> bool: + return self.invocation_count > 0 + + @property + def is_uncovered(self) -> bool: + return not self.is_covered and not self.ignored + + +@dataclass +class MethodGap: + name: str + uncovered_lines: list[int] + uncovered_branch_lines: list[int] + total_statements: int + covered_statements: int + total_branches: int + covered_branches: int + + @property + def short_name(self) -> str: + """Strip the package prefix from the full method path.""" + return self.name.split("/")[-1] if "/" in self.name else self.name + + @property + def stmt_coverage_pct(self) -> float: + return 100.0 * self.covered_statements / self.total_statements if self.total_statements else 100.0 + + @property + def branch_coverage_pct(self) -> float: + return 100.0 * self.covered_branches / self.total_branches if self.total_branches else 100.0 + + @property + def missed_branches(self) -> int: + return self.total_branches - self.covered_branches + + @property + def has_gaps(self) -> bool: + return bool(self.uncovered_lines or self.uncovered_branch_lines) + + +@dataclass +class ClassGap: + class_name: str # e.g. de.nowchess.chess.controller.GameController + source_path: str # normalised relative source path + raw_source: str # original source attribute from XML + # Authoritative values read directly from <class> XML attributes + xml_total_statements: int = 0 + xml_covered_statements: int = 0 + xml_stmt_rate: float = 0.0 + xml_branch_rate: float = 0.0 + statements: list[Statement] = field(default_factory=list) + + # ---- aggregated views (populated after parse) ---- + method_gaps: list[MethodGap] = field(default_factory=list) + + @property + def all_uncovered_lines(self) -> list[int]: + seen: set[int] = set() + result = [] + for s in self.statements: + if s.is_uncovered and s.line not in seen: + seen.add(s.line) + result.append(s.line) + return sorted(result) + + @property + def uncovered_branch_lines(self) -> list[int]: + """Lines that are branch points and have at least one uncovered branch statement.""" + # Group branch statements by line; a line is "partial" if some covered, some not + from collections import defaultdict + by_line: dict[int, list[Statement]] = defaultdict(list) + for s in self.statements: + if s.is_branch and not s.ignored: + by_line[s.line].append(s) + partial = [] + for line, stmts in by_line.items(): + has_covered = any(s.is_covered for s in stmts) + has_uncovered = any(s.is_uncovered for s in stmts) + # Report line if any branch arm is uncovered + if has_uncovered: + partial.append(line) + return sorted(partial) + + @property + def total_statements(self) -> int: + return self.xml_total_statements + + @property + def covered_statements(self) -> int: + return self.xml_covered_statements + + @property + def missed_statements(self) -> int: + return self.xml_total_statements - self.xml_covered_statements + + @property + def total_branches(self) -> int: + return sum(1 for s in self.statements if s.is_branch and not s.ignored) + + @property + def covered_branches(self) -> int: + return sum(1 for s in self.statements if s.is_branch and s.is_covered and not s.ignored) + + @property + def missed_branches(self) -> int: + return self.total_branches - self.covered_branches + + @property + def stmt_coverage_pct(self) -> float: + return self.xml_stmt_rate + + @property + def branch_coverage_pct(self) -> float: + return self.xml_branch_rate + + @property + def has_gaps(self) -> bool: + return self.missed_statements > 0 or self.missed_branches > 0 + + +# --------------------------------------------------------------------------- +# Source path normalisation +# --------------------------------------------------------------------------- + +def _normalise_source(raw: str) -> str: + """ + Convert an absolute Windows or Unix source path from the XML into a + relative src/main/scala/… path for agent consumption. + + Strategy: + 1. Replace Windows backslashes. + 2. Find the 'src/' anchor and take everything from there. + 3. Fall back to the package-derived path if no anchor found. + """ + normalised = raw.replace("\\", "/") + match = re.search(r"(src/(?:main|test)/scala/.+)", normalised) + if match: + return match.group(1) + # Fallback: just the filename portion + return normalised.split("/")[-1] + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + +def parse_scoverage_xml(xml_path: str) -> list[ClassGap]: + tree = ET.parse(xml_path) + root = tree.getroot() + + # ── Authoritative project-level totals from <scoverage> root element ────── + project_stats = { + "total_statements": int(root.get("statement-count", 0)), + "covered_statements": int(root.get("statements-invoked", 0)), + "stmt_coverage_pct": float(root.get("statement-rate", 0.0)), + "branch_coverage_pct": float(root.get("branch-rate", 0.0)), + } + project_stats["missed_statements"] = ( + project_stats["total_statements"] - project_stats["covered_statements"] + ) + + class_map: dict[str, ClassGap] = {} # full-class-name → ClassGap + + for package in root.findall("packages/package"): + for cls_elem in package.findall("classes/class"): + class_name = cls_elem.get("name", "") + filename = cls_elem.get("filename", "") + + # Authoritative per-class totals from <class> attributes + cls_total = int(cls_elem.get("statement-count", 0)) + cls_invoked = int(cls_elem.get("statements-invoked", 0)) + cls_stmt_rate = float(cls_elem.get("statement-rate", 0.0)) + cls_br_rate = float(cls_elem.get("branch-rate", 0.0)) + + for method_elem in cls_elem.findall("methods/method"): + method_name = method_elem.get("name", "") + + # Authoritative per-method totals from <method> attributes + m_total = int(method_elem.get("statement-count", 0)) + m_invoked = int(method_elem.get("statements-invoked", 0)) + m_stmt_rate = float(method_elem.get("statement-rate", 0.0)) + m_br_rate = float(method_elem.get("branch-rate", 0.0)) + + for stmt_elem in method_elem.findall("statements/statement"): + raw_source = stmt_elem.get("source", filename) + full_class = stmt_elem.get("full-class-name", class_name) + + if full_class not in class_map: + class_map[full_class] = ClassGap( + class_name=full_class, + source_path=_normalise_source(raw_source), + raw_source=raw_source, + xml_total_statements=cls_total, + xml_covered_statements=cls_invoked, + xml_stmt_rate=cls_stmt_rate, + xml_branch_rate=cls_br_rate, + ) + + cg = class_map[full_class] + + line = int(stmt_elem.get("line", 0)) + is_branch = stmt_elem.get("branch", "false").lower() == "true" + inv = int(stmt_elem.get("invocation-count", 0)) + ignored = stmt_elem.get("ignored", "false").lower() == "true" + + cg.statements.append(Statement( + line=line, + is_branch=is_branch, + invocation_count=inv, + ignored=ignored, + method=method_name, + )) + + # Register method-level gap using authoritative XML stats + cg = next( + (v for v in class_map.values() if v.class_name == class_name), + None, + ) + if cg is None: + continue + active = [s for s in cg.statements if s.method == method_name and not s.ignored] + uncov_lines = sorted({s.line for s in active if s.is_uncovered}) + uncov_branch_lines = sorted({s.line for s in active if s.is_branch and s.is_uncovered}) + if uncov_lines or uncov_branch_lines: + # Count branches from statement-level data (not in method XML attrs) + total_b = sum(1 for s in active if s.is_branch) + cov_b = sum(1 for s in active if s.is_branch and s.is_covered) + mg = MethodGap( + name=method_name, + uncovered_lines=uncov_lines, + uncovered_branch_lines=uncov_branch_lines, + total_statements=m_total, + covered_statements=m_invoked, + total_branches=total_b, + covered_branches=cov_b, + ) + cg.method_gaps.append(mg) + + # ── Project stats injected so formatters never recount from statements ──── + return project_stats, [cg for cg in class_map.values() if cg.has_gaps] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _compact_ranges(numbers: list[int]) -> str: + """[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) + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + +def _pct_bar(pct: float, width: int = 20) -> str: + """Render a compact ASCII progress bar, e.g. [████░░░░░░░░░░░░░░░░] 23.5%""" + filled = round(pct / 100 * width) + bar = "█" * filled + "░" * (width - filled) + return f"[{bar}] {pct:.1f}%" + + +def format_agent(project_stats: dict, classes: list[ClassGap]) -> str: + lines: list[str] = [] + lines.append("# scoverage Coverage Gaps — Agent Action Report") + lines.append("") + + # ---- Project-level totals (authoritative from <scoverage> root element) ---- + total_stmts = project_stats["total_statements"] + covered_stmts = project_stats["covered_statements"] + missed_stmts = project_stats["missed_statements"] + overall_stmt_pct = project_stats["stmt_coverage_pct"] + overall_branch_pct = project_stats["branch_coverage_pct"] + total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes) + # Branch totals: count from statement data (scoverage root has no branch count attr) + total_branches = sum(c.total_branches for c in classes) + covered_branches = sum(c.covered_branches for c in classes) + missed_branches = sum(c.missed_branches for c in classes) + + lines.append("## Project Coverage Summary") + lines.append("") + lines.append(f"| Metric | Covered | Total | Missed | Coverage |") + lines.append(f"|-------------------|---------|-------|--------|----------|") + lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |") + lines.append(f"| Branch paths | {covered_branches:>7} | {total_branches:>5} | {missed_branches:>6} | {_pct_bar(overall_branch_pct)} |") + lines.append(f"| Files with gaps | {'—':>7} | {len(classes):>5} | {'—':>6} | {'—'} |") + lines.append(f"| Lines w/ br. gaps | {'—':>7} | {total_branch_lines:>5} | {'—':>6} | {'—'} |") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## Files Requiring Tests") + lines.append("") + lines.append("> Each entry lists the SOURCE FILE PATH, uncovered LINE NUMBERS,") + lines.append("> and the METHODS that contain those gaps.") + lines.append("> Write or extend unit/integration tests to exercise these paths.") + lines.append("") + + sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches)) + + for cls in sorted_classes: + lines.append(f"### `{cls.source_path}`") + lines.append(f"**Class**: `{cls.class_name}`") + lines.append("") + lines.append(f"| Metric | Covered | Total | Missed | Coverage |") + lines.append(f"|--------------|---------|-------|--------|----------|") + lines.append(f"| Statements | {cls.covered_statements:>7} | {cls.total_statements:>5} | {cls.missed_statements:>6} | {_pct_bar(cls.stmt_coverage_pct)} |") + if cls.total_branches: + lines.append(f"| Branch paths | {cls.covered_branches:>7} | {cls.total_branches:>5} | {cls.missed_branches:>6} | {_pct_bar(cls.branch_coverage_pct)} |") + lines.append("") + + uncov = cls.all_uncovered_lines + if uncov: + lines.append("#### ❌ Uncovered Statements") + lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`") + lines.append("") + + branch_lines = cls.uncovered_branch_lines + if branch_lines: + lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)") + lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`") + lines.append("") + + if cls.method_gaps: + lines.append("#### Methods with Gaps") + lines.append("") + lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |") + lines.append("|--------|--------------|-----------------|-----------------|------------------|") + for mg in cls.method_gaps: + stmt_cell = f"{_pct_bar(mg.stmt_coverage_pct, 10)} ({mg.total_statements - mg.covered_statements}/{mg.total_statements} missed)" + branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a" + uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "—" + br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "—" + lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |") + lines.append("") + + lines.append("**Action**: Add tests that exercise the lines/branches listed above.") + lines.append("") + lines.append("---") + lines.append("") + + lines.append("## Quick Reference: All Uncovered Locations") + lines.append("") + lines.append("Copy-paste friendly list for IDE navigation or grep:") + lines.append("") + lines.append("```") + for cls in sorted_classes: + for ln in cls.all_uncovered_lines: + lines.append(f"{cls.source_path}:{ln} # uncovered statement") + for ln in cls.uncovered_branch_lines: + if ln not in cls.all_uncovered_lines: + lines.append(f"{cls.source_path}:{ln} # partial branch") + lines.append("```") + + return "\n".join(lines) + + +def format_json(project_stats: dict, classes: list[ClassGap]) -> str: + total_branches = sum(c.total_branches for c in classes) + covered_branches = sum(c.covered_branches for c in classes) + out = { + "project": { + "total_statements": project_stats["total_statements"], + "covered_statements": project_stats["covered_statements"], + "missed_statements": project_stats["missed_statements"], + "stmt_coverage_pct": project_stats["stmt_coverage_pct"], + "branch_coverage_pct": project_stats["branch_coverage_pct"], + "total_branches": total_branches, + "covered_branches": covered_branches, + "missed_branches": total_branches - covered_branches, + "files_with_gaps": len(classes), + }, + "classes": [], + } + for cls in sorted(classes, key=lambda c: c.class_name): + out["classes"].append({ + "class": cls.class_name, + "source_path": cls.source_path, + "total_statements": cls.total_statements, + "covered_statements": cls.covered_statements, + "missed_statements": cls.missed_statements, + "stmt_coverage_pct": round(cls.stmt_coverage_pct, 1), + "total_branches": cls.total_branches, + "covered_branches": cls.covered_branches, + "missed_branches": cls.missed_branches, + "branch_coverage_pct": round(cls.branch_coverage_pct, 1), + "uncovered_lines": cls.all_uncovered_lines, + "uncovered_branch_lines": cls.uncovered_branch_lines, + "methods": [ + { + "name": mg.short_name, + "full_name": mg.name, + "total_statements": mg.total_statements, + "covered_statements": mg.covered_statements, + "missed_statements": mg.total_statements - mg.covered_statements, + "stmt_coverage_pct": round(mg.stmt_coverage_pct, 1), + "total_branches": mg.total_branches, + "covered_branches": mg.covered_branches, + "missed_branches": mg.missed_branches, + "branch_coverage_pct": round(mg.branch_coverage_pct, 1), + "uncovered_lines": mg.uncovered_lines, + "uncovered_branch_lines": mg.uncovered_branch_lines, + } + for mg in cls.method_gaps + ], + }) + return json.dumps(out, indent=2) + + +def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str: + lines: list[str] = [] + lines.append("# scoverage Missing Coverage Report") + lines.append("") + + overall_stmt_pct = project_stats["stmt_coverage_pct"] + overall_branch_pct = project_stats["branch_coverage_pct"] + covered_stmts = project_stats["covered_statements"] + total_stmts = project_stats["total_statements"] + total_branches = sum(c.total_branches for c in classes) + covered_branches = sum(c.covered_branches for c in classes) + + lines.append("## Project Totals") + lines.append("") + lines.append("| Metric | Covered | Total | Missed | % |") + lines.append("|--------------|---------|-------|--------|---|") + lines.append(f"| Statements | {covered_stmts} | {total_stmts} | {total_stmts - covered_stmts} | {overall_stmt_pct:.1f}% |") + lines.append(f"| Branch paths | {covered_branches} | {total_branches} | {total_branches - covered_branches} | {overall_branch_pct:.1f}% |") + lines.append("") + + for cls in sorted(classes, key=lambda c: c.class_name): + lines.append(f"## `{cls.class_name}`") + lines.append(f"**File**: `{cls.source_path}`") + lines.append("") + lines.append("| Metric | Covered | Total | Missed | % |") + lines.append("|--------------|---------|-------|--------|---|") + lines.append(f"| Statements | {cls.covered_statements} | {cls.total_statements} | {cls.missed_statements} | {cls.stmt_coverage_pct:.1f}% |") + if cls.total_branches: + lines.append(f"| Branch paths | {cls.covered_branches} | {cls.total_branches} | {cls.missed_branches} | {cls.branch_coverage_pct:.1f}% |") + lines.append("") + if cls.all_uncovered_lines: + lines.append(f"**Uncovered lines**: `{_compact_ranges(cls.all_uncovered_lines)}`") + lines.append("") + if cls.uncovered_branch_lines: + lines.append(f"**Branch gaps at lines**: `{_compact_ranges(cls.uncovered_branch_lines)}`") + lines.append("") + lines.append("| Method | Stmt Cov | Stmt Missed | Branch Cov | Branch Missed |") + lines.append("|--------|----------|-------------|------------|---------------|") + for mg in cls.method_gaps: + lines.append( + f"| `{mg.short_name}` | {mg.stmt_coverage_pct:.1f}% | " + f"{mg.total_statements - mg.covered_statements}/{mg.total_statements} | " + f"{mg.branch_coverage_pct:.1f}% | {mg.missed_branches}/{mg.total_branches} |" + ) + lines.append("") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Report missing statement & branch coverage from a scoverage XML report." + ) + parser.add_argument("xml_file", help="Path to scoverage.xml report file") + parser.add_argument( + "--output", "-o", + choices=["agent", "json", "markdown"], + default="agent", + help="Output format (default: agent)", + ) + parser.add_argument( + "--min-coverage", + type=float, + default=0.0, + help="Only report classes below this %% statement coverage (0 = report all gaps)", + ) + parser.add_argument( + "--package-filter", "-p", + default=None, + help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)", + ) + 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) + + project_stats, classes = parse_scoverage_xml(str(xml_path)) + + if args.package_filter: + classes = [c for c in classes if c.class_name.startswith(args.package_filter)] + + if args.min_coverage > 0: + classes = [c for c in classes if c.stmt_coverage_pct < args.min_coverage] + + if not classes: + print("✅ No coverage gaps found matching the given filters.") + return + + if args.output == "agent": + print(format_agent(project_stats, classes)) + elif args.output == "json": + print(format_json(project_stats, classes)) + elif args.output == "markdown": + print(format_markdown(project_stats, classes)) + + +if __name__ == "__main__": + main() diff --git a/modules/api/build.gradle.kts b/modules/api/build.gradle.kts index 0d1c07b..0ad7d9d 100644 --- a/modules/api/build.gradle.kts +++ b/modules/api/build.gradle.kts @@ -51,7 +51,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") - testImplementation("org.scalatestplus:junit-5-13_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") } tasks.test { diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index aea5f64..29f9282 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -50,12 +50,20 @@ dependencies { implementation(project(":modules:api")) + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") - testImplementation("org.scalatestplus:junit-5-11_3:${versions["SCALATESTPLUS_JUNIT5"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") } tasks.test { - useJUnitPlatform() + useJUnitPlatform { + includeEngines("scalatest") + testLogging { + events("passed", "skipped", "failed") + } + } finalizedBy(tasks.reportScoverage) } tasks.reportScoverage { 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 new file mode 100644 index 0000000..f2916ce --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala @@ -0,0 +1,103 @@ +package de.nowchess.chess.controller + +import de.nowchess.api.board.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.io.ByteArrayInputStream + +class GameControllerTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + private val initial = Board.initial + + // ──── processMove ──────────────────────────────────────────────────── + + test("processMove: 'quit' input returns Quit"): + GameController.processMove(initial, Color.White, "quit") shouldBe MoveResult.Quit + + test("processMove: 'q' input returns Quit"): + GameController.processMove(initial, Color.White, "q") shouldBe MoveResult.Quit + + test("processMove: quit with surrounding whitespace returns Quit"): + GameController.processMove(initial, Color.White, " quit ") shouldBe MoveResult.Quit + + test("processMove: unparseable input returns InvalidFormat"): + GameController.processMove(initial, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz") + + test("processMove: valid format but empty square returns NoPiece"): + // E3 is empty in the initial position + GameController.processMove(initial, Color.White, "e3e4") shouldBe MoveResult.NoPiece + + test("processMove: piece of wrong color returns WrongColor"): + // E7 has a Black pawn; it is White's turn + GameController.processMove(initial, Color.White, "e7e6") shouldBe MoveResult.WrongColor + + test("processMove: geometrically illegal move returns IllegalMove"): + // White pawn at E2 cannot jump three squares to E5 + GameController.processMove(initial, Color.White, "e2e5") shouldBe MoveResult.IllegalMove + + test("processMove: legal pawn move returns Moved with updated board and flipped turn"): + GameController.processMove(initial, Color.White, "e2e4") match + case MoveResult.Moved(newBoard, captured, newTurn) => + newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) + newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None + captured shouldBe None + newTurn shouldBe Color.Black + case other => fail(s"Expected Moved, got $other") + + test("processMove: legal capture returns Moved with the captured piece"): + val captureBoard = Board(Map( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R6) -> Piece.BlackPawn + )) + GameController.processMove(captureBoard, Color.White, "e5d6") match + case MoveResult.Moved(newBoard, captured, newTurn) => + captured shouldBe Some(Piece.BlackPawn) + newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) + newTurn shouldBe Color.Black + case other => fail(s"Expected Moved, got $other") + + // ──── gameLoop ─────────────────────────────────────────────────────── + + private def withInput(input: String)(block: => Unit): Unit = + val stream = ByteArrayInputStream(input.getBytes("UTF-8")) + scala.Console.withIn(stream)(scala.Console.withOut(System.out)(block)) + + test("gameLoop: 'quit' exits cleanly without exception"): + withInput("quit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: EOF (null readLine) exits via quit fallback"): + withInput(""): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: invalid format prints message and recurses until quit"): + withInput("badmove\nquit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: NoPiece prints message and recurses until quit"): + // E3 is empty in the initial position + withInput("e3e4\nquit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: WrongColor prints message and recurses until quit"): + // E7 has a Black pawn; it is White's turn + withInput("e7e6\nquit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: IllegalMove prints message and recurses until quit"): + withInput("e2e5\nquit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: legal non-capture move recurses with new board then quits"): + withInput("e2e4\nquit\n"): + GameController.gameLoop(Board.initial, Color.White) + + test("gameLoop: capture move prints capture message then recurses and quits"): + val captureBoard = Board(Map( + sq(File.E, Rank.R5) -> Piece.WhitePawn, + sq(File.D, Rank.R6) -> Piece.BlackPawn + )) + withInput("e5d6\nquit\n"): + GameController.gameLoop(captureBoard, Color.White) diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala new file mode 100644 index 0000000..a602a4c --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/controller/ParserTest.scala @@ -0,0 +1,43 @@ +package de.nowchess.chess.controller + +import de.nowchess.api.board.{File, Rank, Square} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class ParserTest extends AnyFunSuite with Matchers: + + test("parseMove parses valid 'e2e4'"): + Parser.parseMove("e2e4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + + test("parseMove is case-insensitive"): + Parser.parseMove("E2E4") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + + test("parseMove trims leading and trailing whitespace"): + Parser.parseMove(" e2e4 ") shouldBe Some((Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + + test("parseMove handles corner squares a1h8"): + Parser.parseMove("a1h8") shouldBe Some((Square(File.A, Rank.R1), Square(File.H, Rank.R8))) + + test("parseMove handles corner squares h8a1"): + Parser.parseMove("h8a1") shouldBe Some((Square(File.H, Rank.R8), Square(File.A, Rank.R1))) + + test("parseMove returns None for empty string"): + Parser.parseMove("") shouldBe None + + test("parseMove returns None for input shorter than 4 chars"): + Parser.parseMove("e2e") shouldBe None + + test("parseMove returns None for input longer than 4 chars"): + Parser.parseMove("e2e44") shouldBe None + + test("parseMove returns None when from-file is out of range"): + Parser.parseMove("z2e4") shouldBe None + + test("parseMove returns None when from-rank is out of range"): + Parser.parseMove("e9e4") shouldBe None + + test("parseMove returns None when to-file is out of range"): + Parser.parseMove("e2z4") shouldBe None + + test("parseMove returns None when to-rank is out of range"): + Parser.parseMove("e2e9") shouldBe None diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala new file mode 100644 index 0000000..878ce22 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala @@ -0,0 +1,211 @@ +package de.nowchess.chess.logic + +import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class MoveValidatorTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) + + // ──── Empty square ─────────────────────────────────────────────────── + + test("legalTargets returns empty set when no piece at from square"): + MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty + + // ──── isLegal delegates to legalTargets ────────────────────────────── + + test("isLegal returns true for a valid pawn move"): + MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true + + test("isLegal returns false for an invalid move"): + MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false + + // ──── Pawn – White ─────────────────────────────────────────────────── + + test("white pawn on starting rank can move forward one square"): + val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) + MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3)) + + test("white pawn on starting rank can move forward two squares"): + val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) + MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4)) + + test("white pawn not on starting rank cannot move two squares"): + val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) + MoveValidator.legalTargets(b, sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5) + + test("white pawn is blocked by piece directly in front, and cannot jump over it"): + val b = board( + sq(File.E, Rank.R2) -> Piece.WhitePawn, + sq(File.E, Rank.R3) -> Piece.BlackPawn + ) + val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) + targets should not contain sq(File.E, Rank.R3) + targets should not contain sq(File.E, Rank.R4) + + test("white pawn on starting rank cannot move two squares if destination square is occupied"): + val b = board( + sq(File.E, Rank.R2) -> Piece.WhitePawn, + sq(File.E, Rank.R4) -> Piece.BlackPawn + ) + val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) + targets should contain(sq(File.E, Rank.R3)) + targets should not contain sq(File.E, Rank.R4) + + test("white pawn can capture diagonally when enemy piece is present"): + val b = board( + sq(File.E, Rank.R2) -> Piece.WhitePawn, + sq(File.D, Rank.R3) -> Piece.BlackPawn + ) + MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3)) + + test("white pawn cannot capture diagonally when no enemy piece is present"): + val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) + MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3) + + test("white pawn at A-file does not generate diagonal to the left off the board"): + val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn) + val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2)) + targets should contain(sq(File.A, Rank.R3)) + targets should contain(sq(File.A, Rank.R4)) + targets.size shouldBe 2 + + // ──── Pawn – Black ─────────────────────────────────────────────────── + + test("black pawn on starting rank can move forward one and two squares"): + val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn) + val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) + targets should contain(sq(File.E, Rank.R6)) + targets should contain(sq(File.E, Rank.R5)) + + test("black pawn not on starting rank cannot move two squares"): + val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn) + MoveValidator.legalTargets(b, sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4) + + test("black pawn can capture diagonally when enemy piece is present"): + val b = board( + sq(File.E, Rank.R7) -> Piece.BlackPawn, + sq(File.F, Rank.R6) -> Piece.WhitePawn + ) + MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6)) + + // ──── Knight ───────────────────────────────────────────────────────── + + test("knight in center has 8 possible moves"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8 + + test("knight in corner has only 2 possible moves"): + val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight) + MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2 + + test("knight cannot land on own piece"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteKnight, + sq(File.F, Rank.R5) -> Piece.WhiteRook + ) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5) + + test("knight can capture enemy piece"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteKnight, + sq(File.F, Rank.R5) -> Piece.BlackRook + ) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5)) + + // ──── Bishop ───────────────────────────────────────────────────────── + + test("bishop slides diagonally across an empty board"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop) + val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + targets should contain(sq(File.E, Rank.R5)) + targets should contain(sq(File.H, Rank.R8)) + targets should contain(sq(File.C, Rank.R3)) + targets should contain(sq(File.A, Rank.R1)) + + test("bishop is blocked by own piece and squares beyond are unreachable"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteBishop, + sq(File.F, Rank.R6) -> Piece.WhiteRook + ) + val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + targets should contain(sq(File.E, Rank.R5)) + targets should not contain sq(File.F, Rank.R6) + targets should not contain sq(File.G, Rank.R7) + + test("bishop captures enemy piece and cannot slide further"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteBishop, + sq(File.F, Rank.R6) -> Piece.BlackRook + ) + val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + targets should contain(sq(File.E, Rank.R5)) + targets should contain(sq(File.F, Rank.R6)) + targets should not contain sq(File.G, Rank.R7) + + // ──── Rook ─────────────────────────────────────────────────────────── + + test("rook slides orthogonally across an empty board"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) + val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + targets should contain(sq(File.D, Rank.R8)) + targets should contain(sq(File.D, Rank.R1)) + targets should contain(sq(File.A, Rank.R4)) + targets should contain(sq(File.H, Rank.R4)) + + test("rook is blocked by own piece and squares beyond are unreachable"): + val b = board( + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.C, Rank.R1) -> Piece.WhitePawn + ) + val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1)) + targets should contain(sq(File.B, Rank.R1)) + targets should not contain sq(File.C, Rank.R1) + targets should not contain sq(File.D, Rank.R1) + + test("rook captures enemy piece and cannot slide further"): + val b = board( + sq(File.A, Rank.R1) -> Piece.WhiteRook, + sq(File.C, Rank.R1) -> Piece.BlackPawn + ) + val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1)) + targets should contain(sq(File.B, Rank.R1)) + targets should contain(sq(File.C, Rank.R1)) + targets should not contain sq(File.D, Rank.R1) + + // ──── Queen ────────────────────────────────────────────────────────── + + test("queen combines rook and bishop movement for 27 squares from d4"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen) + val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) + targets should contain(sq(File.D, Rank.R8)) + targets should contain(sq(File.H, Rank.R4)) + targets should contain(sq(File.H, Rank.R8)) + targets should contain(sq(File.A, Rank.R1)) + targets.size shouldBe 27 + + // ──── King ─────────────────────────────────────────────────────────── + + test("king moves one step in all 8 directions from center"): + val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8 + + test("king at corner has only 3 reachable squares"): + val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing) + MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3 + + test("king cannot capture own piece"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteKing, + sq(File.E, Rank.R4) -> Piece.WhiteRook + ) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4) + + test("king can capture enemy piece"): + val b = board( + sq(File.D, Rank.R4) -> Piece.WhiteKing, + sq(File.E, Rank.R4) -> Piece.BlackRook + ) + MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) 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 new file mode 100644 index 0000000..9cd29ee --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/view/PieceUnicodeTest.scala @@ -0,0 +1,43 @@ +package de.nowchess.chess.view + +import de.nowchess.api.board.Piece +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PieceUnicodeTest extends AnyFunSuite with Matchers: + + test("White King maps to ♔"): + Piece.WhiteKing.unicode shouldBe "\u2654" + + test("White Queen maps to ♕"): + Piece.WhiteQueen.unicode shouldBe "\u2655" + + test("White Rook maps to ♖"): + Piece.WhiteRook.unicode shouldBe "\u2656" + + test("White Bishop maps to ♗"): + Piece.WhiteBishop.unicode shouldBe "\u2657" + + test("White Knight maps to ♘"): + Piece.WhiteKnight.unicode shouldBe "\u2658" + + test("White Pawn maps to ♙"): + Piece.WhitePawn.unicode shouldBe "\u2659" + + test("Black King maps to ♚"): + Piece.BlackKing.unicode shouldBe "\u265A" + + test("Black Queen maps to ♛"): + Piece.BlackQueen.unicode shouldBe "\u265B" + + test("Black Rook maps to ♜"): + Piece.BlackRook.unicode shouldBe "\u265C" + + test("Black Bishop maps to ♝"): + Piece.BlackBishop.unicode shouldBe "\u265D" + + test("Black Knight maps to ♞"): + Piece.BlackKnight.unicode shouldBe "\u265E" + + test("Black Pawn maps to ♟"): + Piece.BlackPawn.unicode shouldBe "\u265F" diff --git a/modules/core/src/test/scala/de/nowchess/chess/view/RendererTest.scala b/modules/core/src/test/scala/de/nowchess/chess/view/RendererTest.scala new file mode 100644 index 0000000..58bea70 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/view/RendererTest.scala @@ -0,0 +1,41 @@ +package de.nowchess.chess.view + +import de.nowchess.api.board.{Board, File, Piece, Rank, Square} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class RendererTest extends AnyFunSuite with Matchers: + + test("render contains column header with all file labels"): + Renderer.render(Board.initial) should include("a b c d e f g h") + + test("render output begins with the column header"): + Renderer.render(Board.initial) should startWith(" a b c d e f g h") + + test("render contains rank labels 1 through 8"): + val output = Renderer.render(Board.initial) + for rank <- 1 to 8 do output should include(s"$rank ") + + test("render shows white king unicode symbol for initial board"): + Renderer.render(Board.initial) should include("\u2654") + + test("render shows black king unicode symbol for initial board"): + Renderer.render(Board.initial) should include("\u265A") + + test("render contains ANSI light-square background code"): + Renderer.render(Board.initial) should include("\u001b[48;5;223m") + + test("render contains ANSI dark-square background code"): + Renderer.render(Board.initial) should include("\u001b[48;5;130m") + + test("render uses white-piece foreground color for white pieces"): + Renderer.render(Board.initial) should include("\u001b[97m") + + test("render uses black-piece foreground color for black pieces"): + Renderer.render(Board.initial) should include("\u001b[30m") + + test("render of empty board contains no piece unicode"): + val output = Renderer.render(Board(Map.empty)) + output should include("a b c d e f g h") + output should not include "\u2654" + output should not include "\u265A"