build: migrate to ScalaTest and Scoverage, replacing JaCoCo across modules
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<example>\\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<commentary>\\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</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\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</commentary>\\n</example>\\n\\n<example>\\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<commentary>\\nEnd-to-end service creation with clear sequential dependencies is exactly the team-lead agent's remit.\\n</commentary>\\n</example>"
|
||||
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: <name>
|
||||
|
||||
### Requirement Summary
|
||||
<One paragraph restatement>
|
||||
|
||||
### Assumptions
|
||||
- <Any accepted unknowns>
|
||||
|
||||
### Acceptance Criteria
|
||||
1. <Testable criterion>
|
||||
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/<service>.yaml
|
||||
- docs/adr/ADR-XXX-<title>.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`.
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user