Compare commits

...

19 Commits

Author SHA1 Message Date
Janis 8f56a82104 refactor: NCS-22 NCS-23 reworked modules and tests (#17)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #17
2026-04-06 09:07:39 +02:00
TeamCity 51ffd7aac9 ci: bump version with Build-28 2026-04-03 09:09:16 +00:00
Janis 1b9eb471de fix: set PYTHONUTF8 environment variable for coverage scripts (#16)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #16
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-03 11:03:02 +02:00
TeamCity 45013c87a9 ci: bump version with Build-27 2026-04-02 19:15:54 +00:00
Janis 80518719d5 feat: NCS-21 Write Scripts to automate certain tasks (#15)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #15
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-02 21:11:21 +02:00
TeamCity 2d6ead7e47 ci: bump version with Build-26 2026-04-01 20:53:08 +00:00
Janis 3ff80318b4 feat: NCS-17 Implement basic ScalaFX UI (#14)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Reviewed-on: #14
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-01 22:48:30 +02:00
TeamCity 9fb743d135 ci: bump version with Build-25 2026-04-01 08:40:41 +00:00
lq64 412ed986a9 feat: NCS-11 50-move rule (#9)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Implements the FIDE 50-move draw rule: a player may claim a draw if no pawn move or capture has occurred in the last
   50 full moves (100 half-moves)
  - Draw is not automatic — the eligible player must claim it via a TUI menu shown at the start of their turn
  - halfMoveClock: Int is threaded through processMove and gameLoop; resets on pawn move, capture, or en passant;
  increments on all other moves

  Changes

  - GameController.scala: extended MoveResult.Moved and MoveResult.MovedInCheck with newHalfMoveClock: Int; added
  MoveResult.DrawClaimed; added halfMoveClock parameter to processMove and gameLoop; TUI menu shown when clock ≥ 100
  - Main.scala: initial gameLoop call passes halfMoveClock = 0
  - GameControllerTest.scala: updated all existing pattern matches; added 10 new tests covering clock reset, clock
  increment, draw claim, and TUI menu behaviour

  Test plan

  - processMove: 'draw' with halfMoveClock = 100 → DrawClaimed
  - processMove: 'draw' with halfMoveClock = 99 → InvalidFormat
  - Pawn move / capture / en passant → clock resets to 0
  - Quiet piece move → clock increments by 1
  - MovedInCheck carries updated clock
  - TUI menu appears when clock ≥ 100; option 1 claims draw, option 2 continues
  - No TUI menu when clock < 100
  - All 197 tests passing

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-01 10:36:24 +02:00
TeamCity 8bbeead702 ci: bump version with Build-24 2026-04-01 07:17:44 +00:00
Janis e5e20c566e fix: update move validation to check for king safety (#13)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #13
2026-04-01 09:07:06 +02:00
Janis 13bfc16cfe feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #12
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
2026-03-31 22:18:14 +02:00
TeamCity 85cbf95c18 ci: bump version with Build-22 2026-03-31 08:35:28 +00:00
shosho996 1361dfc895 feat: NCS-16 Core Separation via Patterns (#10)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: Janis <janis-e@gmx.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #10
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Shahd Lala <shosho996@blackhole.local>
Co-committed-by: Shahd Lala <shosho996@blackhole.local>
2026-03-31 10:31:02 +02:00
TeamCity 707c4826a4 ci: bump version with Build-21 2026-03-29 15:10:35 +00:00
lq64 919beb3b4b feat: NCS-9 En passant implementation (#8)
Build & Test (NowChessSystems) TeamCity build finished
- Add EnPassantCalculator to derive the en passant target square from GameHistory, detect en passant captures, and
  compute the captured pawn's square
  - Extend MoveValidator.legalTargets to include the en passant diagonal square in pawn legal targets
  - Extend GameController.processMove to remove the captured pawn from the board when an en passant capture is played

  Details

  En passant is derived purely from the last HistoryMove — no new state is introduced. If the last move was a double
  pawn push, the target square is the square the pawn passed through. The board mutation follows the same pattern as
  castling: board.withMove moves the capturing pawn, then board.removed removes the captured pawn from its actual square
   (which differs from the destination square).

  Test Plan

  - EnPassantCalculatorTest — 14 unit tests covering target derivation, captured square calculation, and capture
  detection for both colors
  - MoveValidatorTest — 5 new tests: ep target included/excluded based on history, adjacency filter, both colors, case _
   branch coverage
  - GameControllerTest — 2 integration tests: white and black en passant capture removes pawn from board and returns
  correct captured piece
  - 100% scoverage (line/branch/method) confirmed

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #8
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 17:06:50 +02:00
TeamCity ee79dc5b98 ci: bump version with Build-20 2026-03-29 12:06:38 +00:00
Janis f28e69dc18 feat: NCS-6 Implementing FEN & PGN (#7)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #7
Reviewed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 14:02:25 +02:00
TeamCity 5f485fed9b ci: bump version with Build-19 2026-03-28 17:12:54 +00:00
159 changed files with 5951 additions and 1880 deletions
+2 -1
View File
@@ -2,9 +2,10 @@
name: scala-implementer
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
tools: Read, Write, Edit, Bash, Glob
model: sonnet
model: inherit
color: pink
---
You do not have permissions to write tests, just source code.
You are a Scala 3 expert specialising in Quarkus microservices.
Always read the relevant /docs/api/ file before implementing.
+3 -3
View File
@@ -2,9 +2,10 @@
name: test-writer
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
model: sonnet
model: haiku
color: purple
---
You do not have permissions to modify the source code, just write tests.
You write tests for Scala 3 + Quarkus services.
@@ -12,12 +13,11 @@ You write tests for Scala 3 + Quarkus services.
- Unit tests: `extends AnyFunSuite with Matchers` — use `test("description") { ... }` DSL, no `@Test` annotation, no `: Unit` return type needed.
- Integration tests: `@QuarkusTest` with JUnit 5 — `@Test` methods MUST be explicitly typed `: Unit`.
Target 95%+ conditional coverage.
Target 100% conditional coverage if possible.
When invoked BEFORE scala-implementer (no implementation exists yet):
Use the contract-first-test-writing skill — write failing tests from docs/api/{service}.yaml.
When invoked AFTER scala-implementer (implementation exists):
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
To regenerate the report run the tests first.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true,
"ui-ux-pro-max@ui-ux-pro-max-skill": true
"superpowers@claude-plugins-official": false,
"ui-ux-pro-max@ui-ux-pro-max-skill": false
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+3
View File
@@ -12,6 +12,9 @@
<option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/ui" />
</set>
</option>
</GradleProjectSettings>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test">
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.main,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
<parameters>
Generated
+10
View File
@@ -6,6 +6,16 @@
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="IssueNavigationConfiguration">
<option name="links">
<list>
<IssueNavigationLink>
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
</IssueNavigationLink>
</list>
</option>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
+7
View File
@@ -0,0 +1,7 @@
YOU CAN:
- Edit and use the asset in any commercial or non commercial project
- Use the asset in any commercial or non commercial project
YOU CAN'T:
- Resell or distribute the asset to others
- Edit and resell the asset to others - - Credits required using This link: https://fatman200.itch.io/
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

+1 -1
View File
@@ -36,7 +36,7 @@ Versions in root `extra["VERSIONS"]`; modules read via `rootProject.extra["VERSI
- Integration tests: `@QuarkusTest` + JUnit 5 — `@Test` methods need explicit `: Unit`
## Coverage
Line ≥ 95% · Branch ≥ 90% · Method ≥ 90% (document exceptions)
Line = 100% · Branch = 100% · Method = 100% · Regression tests · document exceptions
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
+5 -1
View File
@@ -1,5 +1,6 @@
plugins {
id("org.sonarqube") version "7.2.3.7755"
id("org.scoverage") version "8.1" apply false
}
group = "de.nowchess"
@@ -28,7 +29,10 @@ val versions = mapOf(
"SCALA_LIBRARY" to "2.13.18",
"SCALATEST" to "3.2.19",
"SCALATEST_JUNIT" to "0.1.11",
"SCOVERAGE" to "2.1.1"
"SCOVERAGE" to "2.1.1",
"SCALAFX" to "21.0.0-R32",
"JAVAFX" to "21.0.1",
"JUNIT_BOM" to "5.13.4"
)
extra["VERSIONS"] = versions
+4
View File
@@ -0,0 +1,4 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew clean
Executable
+4
View File
@@ -0,0 +1,4 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew classes
Executable
+10
View File
@@ -0,0 +1,10 @@
#! /usr/bin/env bash
set -euo pipefail
./gradlew test
if [ "$#" -eq 0 ]; then
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py
else
PYTHONUTF8=1 python3 jacoco-reporter/scoverage_coverage_gaps.py "modules/$1/build/reports/scoverageTest/scoverage.xml"
fi
+20
View File
@@ -0,0 +1,20 @@
## [2026-03-31] Unreachable code blocking 100% statement coverage
**Requirement/Bug:** Reach 100% statement coverage in core module.
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
**Attempted Fixes:**
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
+159 -111
View File
@@ -19,6 +19,9 @@ Usage:
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
python scoverage_coverage_gaps.py (default: scans ./modules)
python scoverage_coverage_gaps.py --modules-dir ./services
python scoverage_coverage_gaps.py <scoverage.xml>
"""
import xml.etree.ElementTree as ET
@@ -26,7 +29,8 @@ import sys
import argparse
import json
import re
from pathlib import Path, PureWindowsPath
import glob
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
@@ -112,7 +116,6 @@ class ClassGap:
@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:
@@ -120,10 +123,7 @@ class ClassGap:
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:
if any(s.is_uncovered for s in stmts):
partial.append(line)
return sorted(partial)
@@ -169,20 +169,10 @@ class ClassGap:
# ---------------------------------------------------------------------------
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]
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
# Parser
# ---------------------------------------------------------------------------
def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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)),
@@ -202,17 +191,16 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
"branch_coverage_pct": float(root.get("branch-rate", 0.0)),
}
project_stats["missed_statements"] = (
project_stats["total_statements"] - project_stats["covered_statements"]
project_stats["total_statements"] - project_stats["covered_statements"]
)
class_map: dict[str, ClassGap] = {} # full-class-name → ClassGap
class_map: dict[str, 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))
@@ -221,11 +209,8 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
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))
m_total = int(method_elem.get("statement-count", 0))
m_invoked = int(method_elem.get("statements-invoked", 0))
for stmt_elem in method_elem.findall("statements/statement"):
raw_source = stmt_elem.get("source", filename)
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
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,
@@ -268,7 +252,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
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(
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
)
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]
@@ -310,103 +292,60 @@ def _compact_ranges(numbers: list[int]) -> str:
# 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:
"""
Compact agent format — optimised for low token count.
Emits only actionable gaps: file path, uncovered lines, branch-gap lines,
and a per-method breakdown. No ASCII bars, no redundant tables.
"""
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"]
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)
total_branches = sum(c.total_branches for c in classes)
covered_branches = sum(c.covered_branches for c in classes)
missed_branches = total_branches - covered_branches
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("# scoverage Coverage Gaps")
lines.append(
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
f"files with gaps: {len(classes)}"
)
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("")
uncov = cls.all_uncovered_lines
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("")
lines.append(f"## {cls.source_path}")
lines.append(
f"stmt: {cls.stmt_coverage_pct:.1f}% ({cls.missed_statements} missed)"
+ (f" | branches: {cls.branch_coverage_pct:.1f}% ({cls.missed_branches} missed)"
if cls.total_branches else "")
)
if uncov:
lines.append(f"uncovered lines: {_compact_ranges(uncov)}")
only_branch = [l for l in branch_lines if l not in cls.all_uncovered_lines]
if only_branch:
lines.append(f"partial branches: {_compact_ranges(only_branch)}")
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("|--------|--------------|-----------------|-----------------|------------------|")
lines.append("methods:")
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("")
parts = [f" {mg.short_name}"]
if mg.uncovered_lines:
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
if mg.uncovered_branch_lines:
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
lines.append(" ".join(parts))
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)
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Scan-modules mode
# ---------------------------------------------------------------------------
# Candidate sub-paths within a module directory where scoverage.xml may live.
_SCOVERAGE_SUBPATHS = [
# Gradle / default layout
"build/reports/scoverageTest/scoverage.xml",
# sbt default (scala version wildcard resolved via glob)
"target/scala-*/scoverage-report/scoverage.xml",
# Maven / flat layout
"target/scoverage-report/scoverage.xml",
# Already at root of module
"scoverage.xml",
]
def _find_scoverage_xml(module_dir: Path) -> Optional[Path]:
"""Return the first scoverage.xml found inside *module_dir*, or None."""
for pattern in _SCOVERAGE_SUBPATHS:
hits = sorted(module_dir.glob(pattern))
if hits:
return hits[0]
return None
def format_module_gaps(module_name: str, classes: list[ClassGap], stmt_pct: float) -> str:
"""
One summary line per module. If coverage is not 100%, append an agent hint.
"""
if not classes:
return f"[{module_name}] stmt: {stmt_pct:.1f}% ✅"
line = f"[{module_name}] stmt: {stmt_pct:.1f}% files_with_gaps: {len(classes)}"
if stmt_pct < 100.0:
line += f" # hint: run ./coverage {module_name} for details"
return line
def run_scan_modules(modules_dir: str, package_filter: Optional[str], min_coverage: float) -> None:
base = Path(modules_dir)
if not base.is_dir():
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
sys.exit(1)
module_dirs = sorted(p for p in base.iterdir() if p.is_dir())
if not module_dirs:
print(f"No sub-directories found in {base}", file=sys.stderr)
sys.exit(1)
results: list[str] = []
missing: list[str] = []
for mod_dir in module_dirs:
if mod_dir.name.startswith("build"):
continue
xml_path = _find_scoverage_xml(mod_dir)
if xml_path is None:
missing.append(mod_dir.name)
continue
project_stats, classes = parse_scoverage_xml(str(xml_path))
if package_filter:
classes = [c for c in classes if c.class_name.startswith(package_filter)]
if min_coverage > 0:
classes = [c for c in classes if c.stmt_coverage_pct < min_coverage]
results.append(
format_module_gaps(mod_dir.name, classes, project_stats["stmt_coverage_pct"])
)
print("\n".join(results))
if missing:
print(
f"\n# Modules without scoverage.xml: {', '.join(missing)}",
file=sys.stderr,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
@@ -519,7 +539,13 @@ 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")
# Positional xml_file is optional when --scan-modules is used
parser.add_argument(
"xml_file",
nargs="?",
help="Path to scoverage.xml report file (not required with --scan-modules)",
)
parser.add_argument(
"--output", "-o",
choices=["agent", "json", "markdown"],
@@ -537,8 +563,30 @@ def main() -> None:
default=None,
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)",
)
# ── Scan-modules mode ──────────────────────────────────────────────────
parser.add_argument(
"--scan-modules",
action="store_true",
help=(
"Scan every sub-directory of --modules-dir for a scoverage.xml "
"and print a compact coverage-gaps summary per module."
),
)
parser.add_argument(
"--modules-dir",
default="./modules",
help="Root directory that contains one sub-directory per module (default: ./modules)",
)
args = parser.parse_args()
# ── Scan-modules path (explicit flag, or default when no xml_file given) ──
if args.scan_modules or not args.xml_file:
run_scan_modules(args.modules_dir, args.package_filter, args.min_coverage)
return
# ── Single-file path ──────────────────────────────────────────────────
xml_path = Path(args.xml_file)
if not xml_path.exists():
print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
@@ -565,4 +613,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
+12
View File
@@ -0,0 +1,12 @@
import glob,re
mods=['api','core','io','rule','ui']
tot=0
for m in mods:
s=0
for f in glob.glob(f'modules/{m}/build/test-results/test/TEST-*.xml'):
txt=open(f,encoding='utf-8').read(300)
m2=re.search(r'tests="(\d+)"',txt)
if m2:s+=int(m2.group(1))
print(f'{m}: {s}')
tot+=s
print('overall:',tot)
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""
Test Gap Reporter
Scans JUnit XML test results under modules/*/build/test-results/*.xml and
outputs a minimal summary optimised for agent consumption.
Usage:
python test_gaps.py # scan all modules (default)
python test_gaps.py --module chess # single module
python test_gaps.py --module all # explicit all
python test_gaps.py --modules-dir ./modules
python test_gaps.py --results-subdir build/test-results
"""
import xml.etree.ElementTree as ET
import sys
import argparse
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class TestCase:
classname: str
name: str
time: float
failure: Optional[str] = None # message if failed
error: Optional[str] = None # message if errored
skipped: bool = False
@property
def short_class(self) -> str:
return self.classname.split(".")[-1]
@property
def status(self) -> str:
if self.failure is not None:
return "FAIL"
if self.error is not None:
return "ERROR"
if self.skipped:
return "SKIP"
return "OK"
@dataclass
class SuiteResult:
name: str
total: int
failures: int
errors: int
skipped: int
time: float
cases: list[TestCase] = field(default_factory=list)
@property
def passed(self) -> int:
return self.total - self.failures - self.errors - self.skipped
@property
def is_clean(self) -> bool:
return self.failures == 0 and self.errors == 0
@property
def bad_cases(self) -> list[TestCase]:
return [c for c in self.cases if c.status in ("FAIL", "ERROR")]
@property
def skipped_cases(self) -> list[TestCase]:
return [c for c in self.cases if c.skipped]
@dataclass
class ModuleResult:
name: str
suites: list[SuiteResult] = field(default_factory=list)
@property
def total(self) -> int: return sum(s.total for s in self.suites)
@property
def failures(self) -> int: return sum(s.failures for s in self.suites)
@property
def errors(self) -> int: return sum(s.errors for s in self.suites)
@property
def skipped(self) -> int: return sum(s.skipped for s in self.suites)
@property
def passed(self) -> int: return sum(s.passed for s in self.suites)
@property
def is_clean(self) -> bool: return self.failures == 0 and self.errors == 0
@property
def bad_cases(self) -> list[TestCase]:
return [c for s in self.suites for c in s.bad_cases]
@property
def skipped_cases(self) -> list[TestCase]:
return [c for s in self.suites for c in s.skipped_cases]
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def parse_suite_xml(xml_path: Path) -> SuiteResult:
tree = ET.parse(xml_path)
root = tree.getroot()
# Handle both <testsuite> root and <testsuites> wrapper
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
# Merge multiple suites from one file into a single SuiteResult
total = failures = errors = skipped = 0
elapsed = 0.0
name = xml_path.stem
cases: list[TestCase] = []
for suite in suites:
total += int(suite.get("tests", 0))
failures += int(suite.get("failures", 0))
errors += int(suite.get("errors", 0))
skipped += int(suite.get("skipped", 0))
elapsed += float(suite.get("time", 0.0))
if suite.get("name"):
name = suite.get("name")
for tc in suite.findall("testcase"):
fail_el = tc.find("failure")
err_el = tc.find("error")
skip_el = tc.find("skipped")
cases.append(TestCase(
classname=tc.get("classname", ""),
name=tc.get("name", ""),
time=float(tc.get("time", 0.0)),
failure=fail_el.get("message", fail_el.text or "") if fail_el is not None else None,
error=err_el.get("message", err_el.text or "") if err_el is not None else None,
skipped=skip_el is not None,
))
return SuiteResult(
name=name, total=total, failures=failures,
errors=errors, skipped=skipped, time=elapsed, cases=cases,
)
def load_module(module_dir: Path, results_subdir: str) -> Optional[ModuleResult]:
results_dir = module_dir / results_subdir
if not results_dir.is_dir():
return None
xml_files = sorted(results_dir.glob("*.xml"))
if not xml_files:
return None
mod = ModuleResult(name=module_dir.name)
for xml_path in xml_files:
try:
mod.suites.append(parse_suite_xml(xml_path))
except ET.ParseError:
pass # skip malformed files silently
return mod if mod.suites else None
# ---------------------------------------------------------------------------
# Formatter
# ---------------------------------------------------------------------------
def _truncate(text: str, max_len: int = 120) -> str:
text = " ".join(text.split()) # collapse whitespace
return text[:max_len] + "" if len(text) > max_len else text
def format_module(mod: ModuleResult) -> str:
parts = [f"[{mod.name}]"]
if mod.is_clean and mod.skipped == 0:
parts.append(f"tests: {mod.total}")
return " ".join(parts)
parts.append(f"tests: {mod.total}")
if mod.failures: parts.append(f"failed: {mod.failures}")
if mod.errors: parts.append(f"errors: {mod.errors}")
if mod.skipped: parts.append(f"skipped: {mod.skipped}")
# Agent hint only when there are actual failures/errors
if not mod.is_clean:
parts.append(f" # hint: run ./test {mod.name} for details")
lines = [" ".join(parts)]
# List each failed/errored test — this IS the actionable info
for tc in mod.bad_cases:
msg = tc.failure if tc.failure is not None else tc.error
label = f" {tc.status}: {tc.short_class} > {tc.name}"
if msg:
label += f" [{_truncate(msg, 80)}]"
lines.append(label)
# Skipped: compact, one line total
if mod.skipped_cases:
skipped_names = ", ".join(
f"{c.short_class}.{c.name}" for c in mod.skipped_cases[:5]
)
if len(mod.skipped_cases) > 5:
skipped_names += f" (+{len(mod.skipped_cases) - 5} more)"
lines.append(f" SKIP: {skipped_names}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def run(modules_dir: str, results_subdir: str, module_filter: Optional[str]) -> None:
base = Path(modules_dir)
if not base.is_dir():
print(f"ERROR: modules directory not found: {base}", file=sys.stderr)
sys.exit(1)
# Resolve which module dirs to scan
if module_filter and module_filter != "all":
mod_dir = base / module_filter
if not mod_dir.is_dir():
print(f"ERROR: module not found: {mod_dir}", file=sys.stderr)
sys.exit(1)
candidates = [mod_dir]
else:
candidates = sorted(p for p in base.iterdir() if p.is_dir())
results: list[str] = []
missing: list[str] = []
for mod_dir in candidates:
if mod_dir.name.startswith("build"):
continue
mod = load_module(mod_dir, results_subdir)
if mod is None:
missing.append(mod_dir.name)
continue
results.append(format_module(mod))
print("\n".join(results))
if missing:
print(
f"\n# Modules without test results: {', '.join(missing)}",
file=sys.stderr,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Minimal test-gap reporter for JUnit XML results across modules."
)
parser.add_argument(
"--module", "-m",
nargs="?",
const="all",
default="all",
help="Module name to scan, or 'all' (default: all)",
)
parser.add_argument(
"--modules-dir",
default="./modules",
help="Root directory containing one sub-directory per module (default: ./modules)",
)
parser.add_argument(
"--results-subdir",
default="build/test-results/test",
help="Sub-path inside each module dir where *.xml files live (default: build/test-results/test)",
)
args = parser.parse_args()
filter_ = None if args.module == "all" else args.module
run(args.modules_dir, args.results_subdir, filter_)
if __name__ == "__main__":
main()
+16
View File
@@ -1,2 +1,18 @@
## (2026-03-27)
## (2026-03-28)
## (2026-03-28)
## (2026-03-29)
## (2026-03-31)
## (2026-04-01)
## (2026-04-01)
## (2026-04-01)
## (2026-04-02)
### Features
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
## (2026-04-03)
### Features
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
+1 -1
View File
@@ -59,7 +59,7 @@ tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("passed", "skipped", "failed")
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -14,6 +14,9 @@ object Board:
val captured = b.get(to)
val updatedBoard = b.removed(from).updated(to, b(from))
(updatedBoard, captured)
def applyMove(move: de.nowchess.api.move.Move): Board =
val (updatedBoard, _) = b.withMove(move.from, move.to)
updatedBoard
def pieces: Map[Square, Piece] = b
val initial: Board =
@@ -0,0 +1,70 @@
package de.nowchess.api.board
/**
* Unified castling rights tracker for all four sides.
* Tracks whether castling is still available for each side and direction.
*
* @param whiteKingSide White's king-side castling (0-0) still legally available
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
* @param blackKingSide Black's king-side castling (0-0) still legally available
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
*/
final case class CastlingRights(
whiteKingSide: Boolean,
whiteQueenSide: Boolean,
blackKingSide: Boolean,
blackQueenSide: Boolean
):
/**
* Check if either side has any castling rights remaining.
*/
def hasAnyRights: Boolean =
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
/**
* Check if a specific color has any castling rights remaining.
*/
def hasRights(color: Color): Boolean = color match
case Color.White => whiteKingSide || whiteQueenSide
case Color.Black => blackKingSide || blackQueenSide
/**
* Revoke all castling rights for a specific color.
*/
def revokeColor(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
/**
* Revoke a specific castling right.
*/
def revokeKingSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false)
case Color.Black => copy(blackKingSide = false)
/**
* Revoke a specific castling right.
*/
def revokeQueenSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteQueenSide = false)
case Color.Black => copy(blackQueenSide = false)
object CastlingRights:
/** No castling rights for any side. */
val None: CastlingRights = CastlingRights(
whiteKingSide = false,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = false
)
/** All castling rights available. */
val All: CastlingRights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = true,
blackKingSide = true,
blackQueenSide = true
)
/** Standard starting position castling rights (both sides can castle both ways). */
val Initial: CastlingRights = All
@@ -39,3 +39,19 @@ object Square:
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
)
for f <- fileOpt; r <- rankOpt yield Square(f, r)
val all: IndexedSeq[Square] =
for
r <- Rank.values.toIndexedSeq
f <- File.values.toIndexedSeq
yield Square(f, r)
/** Compute a target square by offsetting file and rank.
* Returns None if the resulting square is outside the board (0-7 range). */
extension (sq: Square)
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
val newFileOrd = sq.file.ordinal + fileDelta
val newRankOrd = sq.rank.ordinal + rankDelta
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
else None
@@ -0,0 +1,44 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
import de.nowchess.api.move.Move
/** Immutable bundle of complete game state.
* All state changes produce new GameContext instances.
*/
case class GameContext(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
halfMoveClock: Int,
moves: List[Move]
):
/** Create new context with updated board. */
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
/** Create new context with updated turn. */
def withTurn(newTurn: Color): GameContext = copy(turn = newTurn)
/** Create new context with updated castling rights. */
def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights)
/** Create new context with updated en passant square. */
def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq)
/** Create new context with updated half-move clock. */
def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock)
/** Create new context with move appended to history. */
def withMove(move: Move): GameContext = copy(moves = moves :+ move)
object GameContext:
/** Initial position: white to move, all castling rights, no en passant. */
def initial: GameContext = GameContext(
board = Board.initial,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
)
@@ -1,67 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Color, Square}
/**
* Castling availability flags for one side.
*
* @param kingSide king-side castling still legally available
* @param queenSide queen-side castling still legally available
*/
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
object CastlingRights:
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
/** Outcome of a finished game. */
enum GameResult:
case WhiteWins
case BlackWins
case Draw
/** Lifecycle state of a game. */
enum GameStatus:
case NotStarted
case InProgress
case Finished(result: GameResult)
/**
* A FEN-compatible snapshot of board and game state.
*
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
* separated by '/'). All other fields mirror standard FEN fields.
*
* @param piecePlacement FEN piece-placement field, e.g.
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
* @param activeColor side to move
* @param castlingWhite castling rights for White
* @param castlingBlack castling rights for Black
* @param enPassantTarget square behind the double-pushed pawn, if any
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
* @param fullMoveNumber increments after Black's move, starts at 1
* @param status current lifecycle status of the game
*/
final case class GameState(
piecePlacement: String,
activeColor: Color,
castlingWhite: CastlingRights,
castlingBlack: CastlingRights,
enPassantTarget: Option[Square],
halfMoveClock: Int,
fullMoveNumber: Int,
status: GameStatus
)
object GameState:
/** Standard starting position. */
val initial: GameState = GameState(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
@@ -9,7 +9,7 @@ enum PromotionPiece:
/** Classifies special move semantics beyond a plain quiet move or capture. */
enum MoveType:
/** A normal move or capture with no special rule. */
case Normal
case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */
case CastleKingside
/** Queenside castling (O-O-O). */
@@ -29,5 +29,5 @@ enum MoveType:
final case class Move(
from: Square,
to: Square,
moveType: MoveType = MoveType.Normal
moveType: MoveType = MoveType.Normal()
)
@@ -1,5 +1,6 @@
package de.nowchess.api.board
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -7,13 +8,9 @@ class BoardTest extends AnyFunSuite with Matchers:
private val e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4)
private val d7 = Square(File.D, Rank.R7)
test("pieceAt returns Some for occupied square") {
test("pieceAt resolves occupied and empty squares") {
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
}
test("pieceAt returns None for empty square") {
Board.initial.pieceAt(e4) shouldBe None
}
@@ -34,38 +31,20 @@ class BoardTest extends AnyFunSuite with Matchers:
board.pieceAt(from) shouldBe None
}
test("pieces returns the underlying map") {
val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map)
b.pieces shouldBe map
}
test("Board.apply constructs board from map") {
test("Board.apply and pieces expose the wrapped map") {
val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map)
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
b.pieces shouldBe map
}
test("initial board has 32 pieces") {
test("initial board has expected material and pawn placement") {
Board.initial.pieces should have size 32
}
test("initial board has 16 white pieces") {
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16
}
test("initial board has 16 black pieces") {
Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
}
test("initial board white pawns on rank 2") {
File.values.foreach { file =>
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn)
}
}
test("initial board black pawns on rank 7") {
File.values.foreach { file =>
Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn)
}
}
@@ -101,17 +80,14 @@ class BoardTest extends AnyFunSuite with Matchers:
Board.initial.pieceAt(Square(file, rank)) shouldBe None
}
test("updated adds or replaces piece at square") {
test("updated adds and replaces piece at squares") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val updated = b.updated(e4, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
}
val added = b.updated(e4, Piece.WhiteKnight)
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
test("updated replaces existing piece") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val updated = b.updated(e2, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
val replaced = b.updated(e2, Piece.WhiteKnight)
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
}
test("removed deletes piece from board") {
@@ -120,3 +96,13 @@ class BoardTest extends AnyFunSuite with Matchers:
removed.pieceAt(e2) shouldBe None
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
}
test("applyMove uses move.from and move.to to relocate a piece") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val moved = b.applyMove(Move(e2, e4))
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
moved.pieceAt(e2) shouldBe None
}
@@ -0,0 +1,57 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CastlingRightsTest extends AnyFunSuite with Matchers:
test("hasAnyRights and hasRights reflect current flags"):
val rights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
)
rights.hasAnyRights shouldBe true
rights.hasRights(Color.White) shouldBe true
rights.hasRights(Color.Black) shouldBe true
CastlingRights.None.hasAnyRights shouldBe false
CastlingRights.None.hasRights(Color.White) shouldBe false
CastlingRights.None.hasRights(Color.Black) shouldBe false
test("revokeColor clears both castling sides for selected color"):
val all = CastlingRights.All
val whiteRevoked = all.revokeColor(Color.White)
whiteRevoked.whiteKingSide shouldBe false
whiteRevoked.whiteQueenSide shouldBe false
whiteRevoked.blackKingSide shouldBe true
whiteRevoked.blackQueenSide shouldBe true
val blackRevoked = all.revokeColor(Color.Black)
blackRevoked.whiteKingSide shouldBe true
blackRevoked.whiteQueenSide shouldBe true
blackRevoked.blackKingSide shouldBe false
blackRevoked.blackQueenSide shouldBe false
test("revokeKingSide and revokeQueenSide disable only requested side"):
val all = CastlingRights.All
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
whiteKingSideRevoked.whiteKingSide shouldBe false
whiteKingSideRevoked.whiteQueenSide shouldBe true
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
whiteQueenSideRevoked.whiteKingSide shouldBe true
whiteQueenSideRevoked.whiteQueenSide shouldBe false
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
blackKingSideRevoked.blackKingSide shouldBe false
blackKingSideRevoked.blackQueenSide shouldBe true
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
blackQueenSideRevoked.blackKingSide shouldBe true
blackQueenSideRevoked.blackQueenSide shouldBe false
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
class ColorTest extends AnyFunSuite with Matchers:
test("White.opposite returns Black") {
Color.White.opposite shouldBe Color.Black
}
test("Color values expose opposite and label consistently"):
val cases = List(
(Color.White, Color.Black, "White"),
(Color.Black, Color.White, "Black")
)
test("Black.opposite returns White") {
Color.Black.opposite shouldBe Color.White
}
test("White.label returns 'White'") {
Color.White.label shouldBe "White"
}
test("Black.label returns 'Black'") {
Color.Black.label shouldBe "Black"
}
cases.foreach { (color, opposite, label) =>
color.opposite shouldBe opposite
color.label shouldBe label
}
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
p.pieceType shouldBe PieceType.Queen
}
test("WhitePawn convenience constant") {
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
}
test("all convenience constants map to expected color and piece type") {
val expected = List(
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
)
test("WhiteKnight convenience constant") {
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight)
}
test("WhiteBishop convenience constant") {
Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop)
}
test("WhiteRook convenience constant") {
Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook)
}
test("WhiteQueen convenience constant") {
Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen)
}
test("WhiteKing convenience constant") {
Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King)
}
test("BlackPawn convenience constant") {
Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn)
}
test("BlackKnight convenience constant") {
Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight)
}
test("BlackBishop convenience constant") {
Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop)
}
test("BlackRook convenience constant") {
Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook)
}
test("BlackQueen convenience constant") {
Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen)
}
test("BlackKing convenience constant") {
Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King)
expected.foreach { case (actual, wanted) =>
actual shouldBe wanted
}
}
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
class PieceTypeTest extends AnyFunSuite with Matchers:
test("Pawn.label returns 'Pawn'") {
PieceType.Pawn.label shouldBe "Pawn"
}
test("PieceType values expose the expected labels"):
val expectedLabels = List(
PieceType.Pawn -> "Pawn",
PieceType.Knight -> "Knight",
PieceType.Bishop -> "Bishop",
PieceType.Rook -> "Rook",
PieceType.Queen -> "Queen",
PieceType.King -> "King"
)
test("Knight.label returns 'Knight'") {
PieceType.Knight.label shouldBe "Knight"
}
test("Bishop.label returns 'Bishop'") {
PieceType.Bishop.label shouldBe "Bishop"
}
test("Rook.label returns 'Rook'") {
PieceType.Rook.label shouldBe "Rook"
}
test("Queen.label returns 'Queen'") {
PieceType.Queen.label shouldBe "Queen"
}
test("King.label returns 'King'") {
PieceType.King.label shouldBe "King"
}
expectedLabels.foreach { (pieceType, expectedLabel) =>
pieceType.label shouldBe expectedLabel
}
@@ -5,58 +5,33 @@ import org.scalatest.matchers.should.Matchers
class SquareTest extends AnyFunSuite with Matchers:
test("Square.toString produces lowercase file and rank number") {
Square(File.E, Rank.R4).toString shouldBe "e4"
}
test("Square.toString for a1") {
test("toString renders algebraic notation for edge and middle squares") {
Square(File.A, Rank.R1).toString shouldBe "a1"
}
test("Square.toString for h8") {
Square(File.E, Rank.R4).toString shouldBe "e4"
Square(File.H, Rank.R8).toString shouldBe "h8"
}
test("fromAlgebraic parses valid square e4") {
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4))
test("fromAlgebraic parses valid coordinates including case-insensitive files") {
val expected = List(
"a1" -> Square(File.A, Rank.R1),
"e4" -> Square(File.E, Rank.R4),
"h8" -> Square(File.H, Rank.R8),
"E4" -> Square(File.E, Rank.R4)
)
expected.foreach { case (raw, sq) =>
Square.fromAlgebraic(raw) shouldBe Some(sq)
}
}
test("fromAlgebraic parses valid square a1") {
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
test("fromAlgebraic rejects malformed coordinates") {
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
Square.fromAlgebraic(raw) shouldBe None
}
}
test("fromAlgebraic parses valid square h8") {
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
test("offset returns Some in-bounds and None out-of-bounds") {
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
}
test("fromAlgebraic is case-insensitive for file") {
Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4))
}
test("fromAlgebraic returns None for empty string") {
Square.fromAlgebraic("") shouldBe None
}
test("fromAlgebraic returns None for string too short") {
Square.fromAlgebraic("e") shouldBe None
}
test("fromAlgebraic returns None for string too long") {
Square.fromAlgebraic("e42") shouldBe None
}
test("fromAlgebraic returns None for invalid file character") {
Square.fromAlgebraic("z4") shouldBe None
}
test("fromAlgebraic returns None for non-digit rank") {
Square.fromAlgebraic("ex") shouldBe None
}
test("fromAlgebraic returns None for rank 0") {
Square.fromAlgebraic("e0") shouldBe None
}
test("fromAlgebraic returns None for rank 9") {
Square.fromAlgebraic("e9") shouldBe None
}
@@ -0,0 +1,60 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameContextTest extends AnyFunSuite with Matchers:
test("GameContext.initial exposes expected default state"):
val initial = GameContext.initial
initial.board shouldBe Board.initial
initial.turn shouldBe Color.White
initial.castlingRights shouldBe CastlingRights.Initial
initial.enPassantSquare shouldBe None
initial.halfMoveClock shouldBe 0
initial.moves shouldBe List.empty
test("withBoard updates only board"):
val square = Square(File.E, Rank.R4)
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
val updated = GameContext.initial.withBoard(updatedBoard)
updated.board shouldBe updatedBoard
updated.turn shouldBe GameContext.initial.turn
updated.castlingRights shouldBe GameContext.initial.castlingRights
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
updated.moves shouldBe GameContext.initial.moves
test("withers update only targeted fields"):
val initial = GameContext.initial
val rights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
)
val square = Some(Square(File.E, Rank.R3))
val updatedTurn = initial.withTurn(Color.Black)
val updatedRights = initial.withCastlingRights(rights)
val updatedEp = initial.withEnPassantSquare(square)
val updatedClock = initial.withHalfMoveClock(17)
updatedTurn.turn shouldBe Color.Black
updatedTurn.board shouldBe initial.board
updatedRights.castlingRights shouldBe rights
updatedRights.turn shouldBe initial.turn
updatedEp.enPassantSquare shouldBe square
updatedEp.castlingRights shouldBe initial.castlingRights
updatedClock.halfMoveClock shouldBe 17
updatedClock.moves shouldBe initial.moves
test("withMove appends move to history"):
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
GameContext.initial.withMove(move).moves shouldBe List(move)
@@ -1,77 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.board.Color
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameStateTest extends AnyFunSuite with Matchers:
test("CastlingRights.None has both flags false") {
CastlingRights.None.kingSide shouldBe false
CastlingRights.None.queenSide shouldBe false
}
test("CastlingRights.Both has both flags true") {
CastlingRights.Both.kingSide shouldBe true
CastlingRights.Both.queenSide shouldBe true
}
test("CastlingRights constructor sets fields") {
val cr = CastlingRights(kingSide = true, queenSide = false)
cr.kingSide shouldBe true
cr.queenSide shouldBe false
}
test("GameResult cases exist") {
GameResult.WhiteWins shouldBe GameResult.WhiteWins
GameResult.BlackWins shouldBe GameResult.BlackWins
GameResult.Draw shouldBe GameResult.Draw
}
test("GameStatus.NotStarted") {
GameStatus.NotStarted shouldBe GameStatus.NotStarted
}
test("GameStatus.InProgress") {
GameStatus.InProgress shouldBe GameStatus.InProgress
}
test("GameStatus.Finished carries result") {
val status = GameStatus.Finished(GameResult.Draw)
status shouldBe GameStatus.Finished(GameResult.Draw)
status match
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
case _ => fail("expected Finished")
}
test("GameState.initial has standard FEN piece placement") {
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
}
test("GameState.initial active color is White") {
GameState.initial.activeColor shouldBe Color.White
}
test("GameState.initial white has full castling rights") {
GameState.initial.castlingWhite shouldBe CastlingRights.Both
}
test("GameState.initial black has full castling rights") {
GameState.initial.castlingBlack shouldBe CastlingRights.Both
}
test("GameState.initial en-passant target is None") {
GameState.initial.enPassantTarget shouldBe None
}
test("GameState.initial half-move clock is 0") {
GameState.initial.halfMoveClock shouldBe 0
}
test("GameState.initial full-move number is 1") {
GameState.initial.fullMoveNumber shouldBe 1
}
test("GameState.initial status is InProgress") {
GameState.initial.status shouldBe GameStatus.InProgress
}
@@ -9,48 +9,26 @@ class MoveTest extends AnyFunSuite with Matchers:
private val e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4)
test("Move defaults moveType to Normal") {
val m = Move(e2, e4)
m.moveType shouldBe MoveType.Normal
}
test("Move stores from and to squares") {
test("Move defaults to Normal and keeps from/to squares") {
val m = Move(e2, e4)
m.from shouldBe e2
m.to shouldBe e4
m.to shouldBe e4
m.moveType shouldBe MoveType.Normal()
}
test("Move with CastleKingside moveType") {
val m = Move(e2, e4, MoveType.CastleKingside)
m.moveType shouldBe MoveType.CastleKingside
}
test("Move accepts all supported move types") {
val moveTypes = List(
MoveType.Normal(isCapture = true),
MoveType.CastleKingside,
MoveType.CastleQueenside,
MoveType.EnPassant,
MoveType.Promotion(PromotionPiece.Queen),
MoveType.Promotion(PromotionPiece.Rook),
MoveType.Promotion(PromotionPiece.Bishop),
MoveType.Promotion(PromotionPiece.Knight)
)
test("Move with CastleQueenside moveType") {
val m = Move(e2, e4, MoveType.CastleQueenside)
m.moveType shouldBe MoveType.CastleQueenside
}
test("Move with EnPassant moveType") {
val m = Move(e2, e4, MoveType.EnPassant)
m.moveType shouldBe MoveType.EnPassant
}
test("Move with Promotion to Queen") {
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
}
test("Move with Promotion to Knight") {
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
}
test("Move with Promotion to Bishop") {
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
}
test("Move with Promotion to Rook") {
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
moveTypes.foreach { moveType =>
Move(e2, e4, moveType).moveType shouldBe moveType
}
}
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
class PlayerInfoTest extends AnyFunSuite with Matchers:
test("PlayerId.apply wraps a string") {
val id = PlayerId("player-123")
id.value shouldBe "player-123"
}
test("PlayerId and PlayerInfo preserve constructor values") {
val raw = "player-123"
val id = PlayerId(raw)
test("PlayerId.value unwraps to original string") {
val raw = "abc-456"
PlayerId(raw).value shouldBe raw
}
id.value shouldBe raw
test("PlayerInfo holds id and displayName") {
val id = PlayerId("p1")
val info = PlayerInfo(id, "Magnus")
val playerId = PlayerId("p1")
val info = PlayerInfo(playerId, "Magnus")
info.id.value shouldBe "p1"
info.displayName shouldBe "Magnus"
}
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
class ApiResponseTest extends AnyFunSuite with Matchers:
test("ApiResponse.Success carries data") {
test("ApiResponse factories and payload wrappers keep values") {
val r = ApiResponse.Success(42)
r.data shouldBe 42
}
test("ApiResponse.Failure carries error list") {
val err = ApiError("CODE", "msg")
val r = ApiResponse.Failure(List(err))
r.errors shouldBe List(err)
}
ApiResponse.Failure(List(err)).errors shouldBe List(err)
ApiResponse.error(err) shouldBe ApiResponse.Failure(List(err))
test("ApiResponse.error creates single-error Failure") {
val err = ApiError("NOT_FOUND", "not found")
val f = ApiResponse.error(err)
f shouldBe ApiResponse.Failure(List(err))
}
test("ApiError holds code and message") {
val e = ApiError("CODE", "message")
e.code shouldBe "CODE"
e.message shouldBe "message"
e.field shouldBe None
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
}
test("ApiError holds optional field") {
val e = ApiError("INVALID", "bad value", Some("email"))
e.field shouldBe Some("email")
}
test("Pagination.totalPages with exact division") {
test("Pagination.totalPages handles normal and guarded inputs") {
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3
}
test("Pagination.totalPages rounds up") {
Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3
}
test("Pagination.totalPages is 0 when totalItems is 0") {
Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0
}
test("Pagination.totalPages is 0 when pageSize is 0") {
Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0
}
test("Pagination.totalPages is 0 when pageSize is negative") {
Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
}
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=0
PATCH=2
MINOR=2
PATCH=0
+160
View File
@@ -28,3 +28,163 @@
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-31)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-04-01)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-01)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-01)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-02)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
## (2026-04-03)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
+3 -11
View File
@@ -1,7 +1,6 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
application
}
group = "de.nowchess"
@@ -22,19 +21,10 @@ scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
application {
mainClass.set("de.nowchess.chess.Main")
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
tasks.named<JavaExec>("run") {
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
standardInput = System.`in`
}
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
@@ -49,6 +39,8 @@ dependencies {
}
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -62,7 +54,7 @@ tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("passed", "skipped", "failed")
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -1,12 +0,0 @@
package de.nowchess.chess
import de.nowchess.api.board.Board
import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameHistory
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
}
@@ -0,0 +1,61 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Piece}
import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
*/
trait Command:
/** Execute the command and return true if successful, false otherwise. */
def execute(): Boolean
/** Undo the command and return true if successful, false otherwise. */
def undo(): Boolean
/** A human-readable description of this command. */
def description: String
/** Command to move a piece from one square to another.
* Stores the move result so undo can restore previous state.
*/
case class MoveCommand(
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None,
notation: String = ""
) extends Command:
override def execute(): Boolean =
moveResult.isDefined
override def undo(): Boolean =
previousContext.isDefined
override def description: String = s"Move from $from to $to"
// Sealed hierarchy of move outcomes (for tracking state changes)
sealed trait MoveResult
object MoveResult:
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult
/** Command to quit the game. */
case class QuitCommand() extends Command:
override def execute(): Boolean = true
override def undo(): Boolean = false
override def description: String = "Quit game"
/** Command to reset the board to initial position. */
case class ResetCommand(
previousContext: Option[GameContext] = None
) extends Command:
override def execute(): Boolean = true
override def undo(): Boolean =
previousContext.isDefined
override def description: String = "Reset board"
@@ -0,0 +1,73 @@
package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */
class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
private var currentIndex = -1
/** Execute a command and add it to history.
* Discards any redo history if not at the end of the stack.
*/
def execute(command: Command): Boolean = synchronized {
if command.execute() then
// Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do
executedCommands.remove(executedCommands.size - 1)
executedCommands += command
currentIndex += 1
true
else
false
}
/** Undo the last executed command if possible. */
def undo(): Boolean = synchronized {
if currentIndex >= 0 && currentIndex < executedCommands.size then
val command = executedCommands(currentIndex)
if command.undo() then
currentIndex -= 1
true
else
false
else
false
}
/** Redo the next command in history if available. */
def redo(): Boolean = synchronized {
if currentIndex + 1 < executedCommands.size then
val command = executedCommands(currentIndex + 1)
if command.execute() then
currentIndex += 1
true
else
false
else
false
}
/** Get the history of all executed commands. */
def history: List[Command] = synchronized {
executedCommands.toList
}
/** Get the current position in command history. */
def getCurrentIndex: Int = synchronized {
currentIndex
}
/** Clear all command history. */
def clear(): Unit = synchronized {
executedCommands.clear()
currentIndex = -1
}
/** Check if undo is available. */
def canUndo: Boolean = synchronized {
currentIndex >= 0
}
/** Check if redo is available. */
def canRedo: Boolean = synchronized {
currentIndex + 1 < executedCommands.size
}
@@ -1,105 +0,0 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
// ---------------------------------------------------------------------------
sealed trait MoveResult
object MoveResult:
case object Quit extends MoveResult
case class InvalidFormat(raw: String) extends MoveResult
case object NoPiece extends MoveResult
case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult
case object Stalemate extends MoveResult
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
object GameController:
/** Pure function: interprets one raw input line against the current game context.
* Has no I/O side effects all output must be handled by the caller.
*/
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
raw.trim match
case "quit" | "q" =>
MoveResult.Quit
case trimmed =>
Parser.parseMove(trimmed) match
case None =>
MoveResult.InvalidFormat(trimmed)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None => board.withMove(from, to)
val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
println()
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, history, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, history, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, history, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, history, turn)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
@@ -0,0 +1,318 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextImport, GameContextExport}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet.
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules
) extends Observable:
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
// Synchronized accessors for current state
def board: Board = synchronized { currentContext.board }
def turn: Color = synchronized { currentContext.turn }
def context: GameContext = synchronized { currentContext }
/** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo }
/** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo }
/** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
/** Process a raw move input string and update game state if valid.
* Notifies all observers of the outcome via GameEvent.
*/
def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase
trimmed match
case "quit" | "q" =>
()
case "undo" =>
performUndo()
case "redo" =>
performRedo()
case "draw" =>
if currentContext.halfMoveClock >= 100 then
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
else
notifyObservers(InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
case moveInput =>
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
case Some((from, to)) =>
handleParsedMove(from, to)
}
private def handleParsedMove(from: Square, to: Square): Unit =
currentContext.board.pieceAt(from) match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
case Some(piece) if piece.color != currentContext.turn =>
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
case Some(piece) =>
val legal = ruleSet.legalMoves(currentContext, from)
// Find all legal moves going to `to`
val candidates = legal.filter(_.to == to)
candidates match
case Nil =>
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
case moves if isPromotionMove(piece, to) =>
// Multiple moves (one per promotion piece) — ask user to choose
val contextBefore = currentContext
pendingPromotion = Some(PendingPromotion(from, to, contextBefore))
notifyObservers(PromotionRequiredEvent(currentContext, from, to))
case move :: _ =>
executeMove(move)
private def isPromotionMove(piece: Piece, to: Square): Boolean =
piece.pieceType == PieceType.Pawn && {
val promoRank = if piece.color == Color.White then 7 else 0
to.rank.ordinal == promoRank
}
/** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true.
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
case Some(pending) =>
pendingPromotion = None
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext, pending.from)
if legal.contains(move) then
executeMove(move)
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Undo the last move. */
def undo(): Unit = synchronized { performUndo() }
/** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() }
/** Load a game using the provided importer.
* If the imported context has moves, they are replayed through the command system.
* Otherwise, the position is set directly.
* Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
notifyObservers(PgnLoadedEvent(currentContext))
}
}
private def replayGame(ctx: GameContext): Either[String, Unit] =
val savedContext = currentContext
currentContext = GameContext.initial
pendingPromotion = None
invoker.clear()
if ctx.moves.isEmpty then
currentContext = ctx
Right(())
else
replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None
moves.foreach: move =>
if error.isEmpty then
handleParsedMove(move.from, move.to)
move.moveType match {
case MoveType.Promotion(pp) =>
if pendingPromotion.isDefined then
completePromotion(pp)
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
}
error match
case Some(err) =>
currentContext = savedContext
Left(err)
case None =>
Right(())
/** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized {
exporter.exportGameContext(currentContext)
}
/** Load an arbitrary board position, clearing all history and undo/redo state. */
def loadPosition(newContext: GameContext): Unit = synchronized {
currentContext = newContext
pendingPromotion = None
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
/** Reset the board to initial position. */
def reset(): Unit = synchronized {
currentContext = GameContext.initial
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
// ──── Private helpers ────
private def executeMove(move: Move): Unit =
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext, move)
val captured = computeCaptured(currentContext, move)
val cmd = MoveCommand(
from = move.from,
to = move.to,
moveResult = Some(MoveResult.Successful(nextContext, captured)),
previousContext = Some(contextBefore),
notation = translateMoveToNotation(move, contextBefore.board)
)
invoker.execute(cmd)
currentContext = nextContext
notifyObservers(MoveExecutedEvent(
currentContext,
move.from.toString,
move.to.toString,
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
))
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isStalemate(currentContext) then
notifyObservers(StalemateEvent(currentContext))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isCheck(currentContext) then
notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
private def enPassantNotation(move: Move): String =
s"${move.from.file.toString.toLowerCase}x${move.to}"
private def promotionNotation(move: Move, piece: PromotionPiece): String =
val ppChar = piece match
case PromotionPiece.Queen => "Q"
case PromotionPiece.Rook => "R"
case PromotionPiece.Bishop => "B"
case PromotionPiece.Knight => "N"
s"${move.to}=$ppChar"
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
boardBefore.pieceAt(move.from).map(_.pieceType) match
case Some(PieceType.Pawn) =>
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
else move.to.toString
case Some(pt) =>
val letter = pieceNotation(pt)
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
case None => move.to.toString
private[engine] def pieceNotation(pieceType: PieceType): String =
pieceType match
case PieceType.Knight => "N"
case PieceType.Bishop => "B"
case PieceType.Rook => "R"
case PieceType.Queen => "Q"
case PieceType.King => "K"
case _ => ""
private def computeCaptured(context: GameContext, move: Move): Option[Piece] =
move.moveType match
case MoveType.EnPassant =>
// Captured pawn is on the same rank as the moving pawn, same file as destination
val capturedSquare = Square(move.to.file, move.from.rank)
context.board.pieceAt(capturedSquare)
case MoveType.CastleKingside | MoveType.CastleQueenside =>
None
case _ =>
context.board.pieceAt(move.to)
private def performUndo(): Unit =
if invoker.canUndo then
val cmd = invoker.history(invoker.getCurrentIndex)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
val cmd = invoker.history(invoker.getCurrentIndex + 1)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
currentContext = nextCtx
invoker.redo()
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveRedoneEvent(
currentContext,
moveCmd.notation,
moveCmd.from.toString,
moveCmd.to.toString,
capturedDesc
))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -1,23 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
enum CastleSide:
case Kingside, Queenside
extension (b: Board)
def withCastle(color: Color, side: CastleSide): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingFrom = Square(File.E, rank)
val (kingTo, rookFrom, rookTo) = side match
case CastleSide.Kingside =>
(Square(File.G, rank), Square(File.H, rank), Square(File.F, rank))
case CastleSide.Queenside =>
(Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = b.pieceAt(kingFrom).get
val rook = b.pieceAt(rookFrom).get
b.removed(kingFrom).removed(rookFrom)
.updated(kingTo, king)
.updated(rookTo, rook)
@@ -1,31 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.{Color, File, Rank, Square}
import de.nowchess.api.game.CastlingRights
/** Derives castling rights from move history. */
object CastlingRightsCalculator:
def deriveCastlingRights(history: GameHistory, color: Color): CastlingRights =
val (kingRow, kingsideRookFile, queensideRookFile) = color match
case Color.White => (Rank.R1, File.H, File.A)
case Color.Black => (Rank.R8, File.H, File.A)
// Check if king has moved
val kingHasMoved = history.moves.exists: move =>
move.from == Square(File.E, kingRow) || move.castleSide.isDefined
if kingHasMoved then
CastlingRights.None
else
// Check if kingside rook has moved or was captured
val kingsideLost = history.moves.exists: move =>
move.from == Square(kingsideRookFile, kingRow) ||
move.to == Square(kingsideRookFile, kingRow)
// Check if queenside rook has moved or was captured
val queensideLost = history.moves.exists: move =>
move.from == Square(queensideRookFile, kingRow) ||
move.to == Square(queensideRookFile, kingRow)
CastlingRights(kingSide = !kingsideLost, queenSide = !queensideLost)
@@ -1,24 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove(
from: Square,
to: Square,
castleSide: Option[CastleSide]
)
/** Complete game history: ordered list of moves. */
case class GameHistory(moves: List[HistoryMove] = List.empty):
def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move)
def addMove(from: Square, to: Square): GameHistory =
addMove(HistoryMove(from, to, None))
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory =
addMove(HistoryMove(from, to, castleSide))
object GameHistory:
val empty: GameHistory = GameHistory()
@@ -1,47 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.GameHistory
enum PositionStatus:
case Normal, InCheck, Mated, Drawn
object GameRules:
/** True if `color`'s king is under attack on this board. */
def isInCheck(board: Board, color: Color): Boolean =
board.pieces
.collectFirst { case (sq, p) if p.color == color && p.pieceType == PieceType.King => sq }
.exists { kingSq =>
board.pieces.exists { case (sq, piece) =>
piece.color != color &&
MoveValidator.legalTargets(board, sq).contains(kingSq)
}
}
/** All (from, to) moves for `color` that do not leave their own king in check. */
def legalMoves(board: Board, history: GameHistory, color: Color): Set[(Square, Square)] =
board.pieces
.collect { case (from, piece) if piece.color == color => from }
.flatMap { from =>
MoveValidator.legalTargets(board, history, from) // context-aware: includes castling
.filter { to =>
val newBoard =
if MoveValidator.isCastle(board, from, to) then
board.withCastle(color, MoveValidator.castleSide(from, to))
else
board.withMove(from, to)._1
!isInCheck(newBoard, color)
}
.map(to => from -> to)
}
.toSet
/** Position status for the side whose turn it is (`color`). */
def gameStatus(board: Board, history: GameHistory, color: Color): PositionStatus =
val moves = legalMoves(board, history, color)
val inCheck = isInCheck(board, color)
if moves.isEmpty && inCheck then PositionStatus.Mated
else if moves.isEmpty then PositionStatus.Drawn
else if inCheck then PositionStatus.InCheck
else PositionStatus.Normal
@@ -1,162 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory}
object MoveValidator:
/** Returns true if the move is geometrically legal for the piece on `from`,
* ignoring check/pin but respecting:
* - correct movement pattern for the piece type
* - cannot capture own pieces
* - sliding pieces (bishop, rook, queen) are blocked by intervening pieces
*/
def isLegal(board: Board, from: Square, to: Square): Boolean =
legalTargets(board, from).contains(to)
/** All squares a piece on `from` can legally move to (same rules as isLegal). */
def legalTargets(board: Board, from: Square): Set[Square] =
board.pieceAt(from) match
case None => Set.empty
case Some(piece) =>
piece.pieceType match
case PieceType.Pawn => pawnTargets(board, from, piece.color)
case PieceType.Knight => knightTargets(board, from, piece.color)
case PieceType.Bishop => slide(board, from, piece.color, diagonalDeltas)
case PieceType.Rook => slide(board, from, piece.color, orthogonalDeltas)
case PieceType.Queen => slide(board, from, piece.color, diagonalDeltas ++ orthogonalDeltas)
case PieceType.King => kingTargets(board, from, piece.color)
// ── helpers ────────────────────────────────────────────────────────────────
private val diagonalDeltas: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
private val orthogonalDeltas: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
private val knightDeltas: List[(Int, Int)] =
List((1, 2), (1, -2), (-1, 2), (-1, -2), (2, 1), (2, -1), (-2, 1), (-2, -1))
/** Try to construct a Square from integer file/rank indices (0-based). */
private def squareAt(fileIdx: Int, rankIdx: Int): Option[Square] =
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx))
)
/** True when `sq` is occupied by a piece of `color`. */
private def isOwnPiece(board: Board, sq: Square, color: Color): Boolean =
board.pieceAt(sq).exists(_.color == color)
/** True when `sq` is occupied by a piece of the opposite color. */
private def isEnemyPiece(board: Board, sq: Square, color: Color): Boolean =
board.pieceAt(sq).exists(_.color != color)
/** Sliding move generation along a list of direction deltas.
* Each direction continues until the board edge, an own piece, or the first
* enemy piece (which is included as a capture target).
*/
private def slide(board: Board, from: Square, color: Color, deltas: List[(Int, Int)]): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
deltas.flatMap: (df, dr) =>
Iterator
.iterate((fi + df, ri + dr)) { case (f, r) => (f + df, r + dr) }
.takeWhile { case (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 }
.map { case (f, r) => Square(File.values(f), Rank.values(r)) }
.foldLeft((List.empty[Square], false)):
case ((acc, stopped), sq) =>
if stopped then (acc, true)
else if isOwnPiece(board, sq, color) then (acc, true) // blocked — stop, no capture
else if isEnemyPiece(board, sq, color) then (acc :+ sq, true) // capture — stop after
else (acc :+ sq, false) // empty — continue
._1
.toSet
private def pawnTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6
val oneStep = squareAt(fi, ri + dir)
// Forward one square (only if empty)
val forward1: Set[Square] = oneStep match
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
case _ => Set.empty
// Forward two squares from starting rank (only if both intermediate squares are empty)
val forward2: Set[Square] =
if ri == startRank && forward1.nonEmpty then
squareAt(fi, ri + 2 * dir) match
case Some(sq) if board.pieceAt(sq).isEmpty => Set(sq)
case _ => Set.empty
else Set.empty
// Diagonal captures (only if enemy piece present)
val captures: Set[Square] =
List(-1, 1).flatMap: df =>
squareAt(fi + df, ri + dir).filter(sq => isEnemyPiece(board, sq, color))
.toSet
forward1 ++ forward2 ++ captures
private def knightTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
knightDeltas.flatMap: (df, dr) =>
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
.toSet
private def kingTargets(board: Board, from: Square, color: Color): Set[Square] =
val fi = from.file.ordinal
val ri = from.rank.ordinal
(diagonalDeltas ++ orthogonalDeltas).flatMap: (df, dr) =>
squareAt(fi + df, ri + dr).filterNot(sq => isOwnPiece(board, sq, color))
.toSet
// ── Castling helpers ────────────────────────────────────────────────────────
private def isAttackedBy(board: Board, sq: Square, attackerColor: Color): Boolean =
board.pieces.exists { case (from, piece) =>
piece.color == attackerColor && legalTargets(board, from).contains(sq)
}
def isCastle(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.King) &&
math.abs(to.file.ordinal - from.file.ordinal) == 2
def castleSide(from: Square, to: Square): CastleSide =
if to.file.ordinal > from.file.ordinal then CastleSide.Kingside else CastleSide.Queenside
def castlingTargets(board: Board, history: GameHistory, color: Color): Set[Square] =
val rights = CastlingRightsCalculator.deriveCastlingRights(history, color)
val rank = if color == Color.White then Rank.R1 else Rank.R8
val kingSq = Square(File.E, rank)
val enemy = color.opposite
if !board.pieceAt(kingSq).contains(Piece(color, PieceType.King)) ||
GameRules.isInCheck(board, color) then Set.empty
else
val kingsideSq = Option.when(
rights.kingSide &&
board.pieceAt(Square(File.H, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.F, rank), Square(File.G, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.F, rank), Square(File.G, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.G, rank))
val queensideSq = Option.when(
rights.queenSide &&
board.pieceAt(Square(File.A, rank)).contains(Piece(color, PieceType.Rook)) &&
List(Square(File.B, rank), Square(File.C, rank), Square(File.D, rank)).forall(s => board.pieceAt(s).isEmpty) &&
!List(Square(File.D, rank), Square(File.C, rank)).exists(s => isAttackedBy(board, s, enemy))
)(Square(File.C, rank))
kingsideSq.toSet ++ queensideSq.toSet
def legalTargets(board: Board, history: GameHistory, from: Square): Set[Square] =
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case _ =>
legalTargets(board, from)
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
@@ -0,0 +1,110 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.GameContext
/** Base trait for all game state events.
* Events are immutable snapshots of game state changes.
*/
sealed trait GameEvent:
def context: GameContext
/** Fired when a move is successfully executed. */
case class MoveExecutedEvent(
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
) extends GameEvent
/** Fired when the current player is in check. */
case class CheckDetectedEvent(
context: GameContext
) extends GameEvent
/** Fired when the game reaches checkmate. */
case class CheckmateEvent(
context: GameContext,
winner: Color
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
context: GameContext
) extends GameEvent
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
context: GameContext,
reason: String
) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent(
context: GameContext,
from: Square,
to: Square
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
context: GameContext
) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
context: GameContext
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
context: GameContext
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent(
context: GameContext,
pgnNotation: String
) extends GameEvent
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
case class MoveRedoneEvent(
context: GameContext,
pgnNotation: String,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
) extends GameEvent
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent(
context: GameContext
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
/** Observable trait: manages observers and notifies them of events. */
trait Observable:
private val observers = scala.collection.mutable.Set[Observer]()
/** Register an observer to receive game events. */
def subscribe(observer: Observer): Unit = synchronized {
observers += observer
}
/** Unregister an observer. */
def unsubscribe(observer: Observer): Unit = synchronized {
observers -= observer
}
/** Notify all observers of a game event. */
protected def notifyObservers(event: GameEvent): Unit = synchronized {
observers.foreach(_.onGameEvent(event))
}
/** Return current list of observers (for testing). */
def observerCount: Int = synchronized {
observers.size
}
@@ -0,0 +1,148 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private case class FailingCommand() extends Command:
override def execute(): Boolean = false
override def undo(): Boolean = false
override def description: String = "Failing command"
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo
override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousContext = Some(GameContext.initial)
)
test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker()
val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
val failingCmd = FailingCommand()
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(failingCmd) shouldBe false
invoker.history.size shouldBe 0
invoker.execute(successCmd) shouldBe true
invoker.history.size shouldBe 1
invoker.history.head shouldBe successCmd
test("undo redo and history trimming cover all command state transitions"):
{
val invoker = new CommandInvoker()
invoker.undo() shouldBe false
invoker.canUndo shouldBe false
invoker.undo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.undo()
invoker.undo() shouldBe false
}
{
val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true
invoker.undo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val successUndoCmd = ConditionalFailCommand()
invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
}
{
val invoker = new CommandInvoker()
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd1)
invoker.execute(redoFailCmd)
invoker.undo()
invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.undo() shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.execute(cmd3)
invoker.execute(cmd4)
invoker.undo()
invoker.undo()
invoker.canRedo shouldBe true
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(newCmd)
invoker.history.size shouldBe 3
invoker.canRedo shouldBe false
}
@@ -0,0 +1,67 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial)
)
test("execute appends commands and updates index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1
test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd)
invoker.canUndo shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
test("clear removes full history and resets index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.clear()
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
test("execute after undo discards redo history"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.getCurrentIndex shouldBe 0
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history.head shouldBe cmd1
invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1
@@ -0,0 +1,24 @@
package de.nowchess.chess.command
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand properties and behavior"):
val cmd = QuitCommand()
cmd shouldNot be(null)
cmd.execute() shouldBe true
cmd.undo() shouldBe false
cmd.description shouldBe "Quit game"
test("ResetCommand behavior depends on previousContext"):
val noState = ResetCommand()
noState.execute() shouldBe true
noState.undo() shouldBe false
noState.description shouldBe "Reset board"
val withState = ResetCommand(previousContext = Some(GameContext.initial))
withState.execute() shouldBe true
withState.undo() shouldBe true
@@ -0,0 +1,70 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("MoveCommand defaults to empty optional state and false execute/undo"):
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
cmd.moveResult shouldBe None
cmd.previousContext shouldBe None
cmd.execute() shouldBe false
cmd.undo() shouldBe false
cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute/undo succeed when state is present"):
val executable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
)
executable.execute() shouldBe true
val undoable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial)
)
undoable.undo() shouldBe true
test("MoveCommand is immutable and preserves equality/hash semantics"):
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
val result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousContext = Some(GameContext.initial)
)
cmd1.moveResult shouldBe None
cmd1.previousContext shouldBe None
cmd2.moveResult shouldBe Some(result)
cmd2.previousContext shouldBe Some(GameContext.initial)
val eq1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None
)
val eq2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None
)
eq1 shouldBe eq2
eq1.hashCode shouldBe eq2.hashCode
val hash1 = eq1.hashCode
val hash2 = eq1.hashCode
hash1 shouldBe hash2
@@ -1,367 +0,0 @@
package de.nowchess.chess.controller
import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{CastleSide, GameHistory}
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 def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
GameController.gameLoop(board, history, turn)
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
// ──── processMove ────────────────────────────────────────────────────
test("processMove: 'quit' input returns Quit"):
processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit
test("processMove: 'q' input returns Quit"):
processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit
test("processMove: quit with surrounding whitespace returns Quit"):
processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit
test("processMove: unparseable input returns InvalidFormat"):
processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz")
test("processMove: valid format but empty square returns NoPiece"):
// E3 is empty in the initial position
processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece
test("processMove: piece of wrong color returns WrongColor"):
// E7 has a Black pawn; it is White's turn
processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor
test("processMove: geometrically illegal move returns IllegalMove"):
// White pawn at E2 cannot jump three squares to E5
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, 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 board = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.WhiteKing
))
processMove(board, GameHistory.empty, Color.White, "e5d6") match
case MoveResult.Moved(newBoard, newHistory, 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)(block)
test("gameLoop: 'quit' exits cleanly without exception"):
withInput("quit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: EOF (null readLine) exits via quit fallback"):
withInput(""):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: invalid format prints message and recurses until quit"):
withInput("badmove\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: NoPiece prints message and recurses until quit"):
// E3 is empty in the initial position
withInput("e3e4\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, 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"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: IllegalMove prints message and recurses until quit"):
withInput("e2e5\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: legal non-capture move recurses with new board then quits"):
withInput("e2e4\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, 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,
sq(File.H, Rank.R1) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.WhiteKing
))
withInput("e5d6\nquit\n"):
gameLoop(captureBoard, GameHistory.empty, Color.White)
// ──── helpers ────────────────────────────────────────────────────────
private def captureOutput(block: => Unit): String =
val out = java.io.ByteArrayOutputStream()
scala.Console.withOut(out)(block)
out.toString("UTF-8")
// ──── processMove: check / checkmate / stalemate ─────────────────────
test("processMove: legal move that delivers check returns MovedInCheck"):
// White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check
// Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a1a8") match
case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black
case other => fail(s"Expected MovedInCheck, got $other")
test("processMove: legal move that results in checkmate returns Checkmate"):
// White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8)
// After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position)
// Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a1h8") match
case MoveResult.Checkmate(winner) => winner shouldBe Color.White
case other => fail(s"Expected Checkmate(White), got $other")
test("processMove: legal move that results in stalemate returns Stalemate"):
// White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6
// After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position)
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "b1b6") match
case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other")
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
test("gameLoop: checkmate prints winner message and resets to new game"):
// After Qa1-Qh8, position is checkmate; second "quit" exits the new game
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1h8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"):
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("b1b6\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
// White Rook A1 captures Black Pawn on A8, Ra8 then attacks rank 8 putting Kh8 in check
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackPawn,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("captures")
output should include("Black is in check!")
// ──── castling execution ─────────────────────────────────────────────
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook)
newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None
newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None
captured shouldBe None
newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other")
test("processMove: e1c1 returns Moved with king on c1 and rook on d1"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1c1") match
case MoveResult.Moved(newBoard, _, _, _) =>
newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing)
newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook)
case other => fail(s"Expected Moved, got $other")
// ──── rights revocation ──────────────────────────────────────────────
test("processMove: e1g1 revokes both white castling rights"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1g1") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other")
test("processMove: moving rook from h1 revokes white kingside right"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "h1h4") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
castlingRights(newHistory, Color.White).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving king from e1 revokes both white rights"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1e2") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White) shouldBe CastlingRights.None
case other => fail(s"Expected Moved, got $other")
test("processMove: enemy capture on h1 revokes white kingside right"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R2) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.Black, "h2h1") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).kingSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: castle attempt when rights revoked returns IllegalMove"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.H, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1))
processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
test("processMove: castle attempt when rook not on home square returns IllegalMove"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.G, Rank.R1) -> Piece.WhiteRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove
test("processMove: moving king from e8 revokes both black rights"):
val b = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "e8e7") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None
case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving rook from a8 revokes black queenside right"):
val b = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.A, Rank.R8) -> Piece.BlackRook,
sq(File.H, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "a8a1") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).queenSide shouldBe false
castlingRights(newHistory, Color.Black).kingSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: moving rook from h8 revokes black kingside right"):
val b = Board(Map(
sq(File.E, Rank.R8) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R1) -> Piece.WhiteKing
))
processMove(b, GameHistory.empty, Color.Black, "h8h4") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.Black).kingSide shouldBe false
castlingRights(newHistory, Color.Black).queenSide shouldBe true
case other => fail(s"Expected Moved or MovedInCheck, got $other")
test("processMove: enemy capture on a1 revokes white queenside right"):
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.A, Rank.R2) -> Piece.BlackRook,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.Black, "a2a1") match
case MoveResult.Moved(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other")
@@ -0,0 +1,40 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.*
import de.nowchess.io.fen.FenParser
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
object EngineTestHelpers:
def makeEngine(): GameEngine =
new GameEngine(ruleSet = DefaultRules)
def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine =
GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn))
def loadFen(engine: GameEngine, fen: String): Unit =
engine.loadGame(FenParser, fen)
def captureEvents(engine: GameEngine): mutable.ListBuffer[GameEvent] =
val events = mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
events
class MockObserver extends Observer:
private val _events = mutable.ListBuffer[GameEvent]()
def events: mutable.ListBuffer[GameEvent] = _events
def eventCount: Int = _events.length
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
_events.exists(ct.runtimeClass.isInstance(_))
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
_events.collectFirst { case e if ct.runtimeClass.isInstance(e) => e.asInstanceOf[T] }
override def onGameEvent(event: GameEvent): Unit =
_events += event
def clear(): Unit =
_events.clear()
@@ -0,0 +1,91 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine check/checkmate/stalemate paths */
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
// Play Fool's mate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.events.clear()
engine.processUserInput("d8h4")
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent]
val event = observer.events.last.asInstanceOf[CheckmateEvent]
event.winner shouldBe Color.Black
// Board should be reset after checkmate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
// Play a simple check
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("f1c4")
engine.processUserInput("g8f6")
observer.events.clear()
engine.processUserInput("c4f7") // Check!
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
checkEvents.size shouldBe 1
checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
// Shortest known stalemate is 19 moves. Here is a faster one:
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
// Wait, let's just use Sam Loyd's 10-move stalemate:
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
test("GameEngine handles Stalemate via 10-move known sequence"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
val moves = List(
"e2e3", "a7a5",
"d1h5", "a8a6",
"h5a5", "h7h5",
"h2h4", "a6h6",
"a5c7", "f7f6",
"c7d7", "e8f7",
"d7b7", "d8d3",
"b7b8", "d3h7",
"b8c8", "f7g6",
"c8e6"
)
moves.dropRight(1).foreach(engine.processUserInput)
observer.events.clear()
engine.processUserInput(moves.last)
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
stalemateEvents.size shouldBe 1
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -0,0 +1,178 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
import de.nowchess.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val events = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe((event: GameEvent) => events += event)
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine()
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
engine.processUserInput("e2e4")
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("")
engine.processUserInput("oops")
engine.processUserInput("undo")
engine.processUserInput("redo")
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e5")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
case _ => false
} shouldBe true
test("loadGame returns Left when importer fails"):
val engine = new GameEngine()
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e4")
val target = GameContext.initial.withTurn(Color.Black)
engine.loadPosition(target)
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine()
val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
events.clear()
engine.processUserInput("a1h1")
engine.processUserInput("undo")
engine.processUserInput("redo")
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val permissiveRules = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] = legalMoves(context, square)
def legalMoves(context: GameContext, square: Square): List[Move] =
if square == sq("e2") then List(promotionMove) else List.empty
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext, move: Move): GameContext = DefaultRules.applyMove(context, move)
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(promotionMove)
test("loadGame replay restores previous context when promotion cannot be completed"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val noLegalMoves = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] = List.empty
def legalMoves(context: GameContext, square: Square): List[Move] = List.empty
def allLegalMoves(context: GameContext): List[Move] = List.empty
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext, move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves)
engine.processUserInput("e2e4")
val saved = engine.context
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
result.isLeft shouldBe true
result.left.toOption.get should include("Promotion required")
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("replayMoves skips later moves after the first move triggers an error"):
val engine = new GameEngine()
val saved = engine.context
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
result shouldBe "e4"
test("pieceNotation default branch returns empty string"):
val engine = new GameEngine()
val result = engine.pieceNotation(PieceType.Pawn)
result shouldBe ""
test("observerCount reflects subscribe and unsubscribe operations"):
val engine = new GameEngine()
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = ()
engine.observerCount shouldBe 0
engine.subscribe(observer)
engine.observerCount shouldBe 1
engine.unsubscribe(observer)
engine.observerCount shouldBe 0
@@ -0,0 +1,43 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.fen.FenParser
import de.nowchess.io.pgn.PgnExporter
import de.nowchess.io.fen.FenExporter
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
val engine = new GameEngine()
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
val result = engine.loadGame(PgnParser, pgn)
result shouldBe Right(())
engine.context.moves.size shouldBe 2
engine.canUndo shouldBe true
test("loadGame with FenParser: loads position without replaying moves"):
val engine = new GameEngine()
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
val result = engine.loadGame(FenParser, fen)
result shouldBe Right(())
engine.context.moves.isEmpty shouldBe true
engine.canUndo shouldBe false
test("exportGame with PgnExporter: exports current game as PGN"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
val pgn = engine.exportGame(PgnExporter)
pgn.contains("e4") shouldBe true
pgn.contains("e5") shouldBe true
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -0,0 +1,127 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.PromotionPiece
import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests that exercise moveToPgn branches not covered by other test files:
* - CastleQueenside (line 223)
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
* - Promotion(Bishop) notation (line 230)
* - King normal move notation (line 246)
*/
class GameEngineNotationTest extends AnyFunSuite with Matchers:
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val buf = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = buf += e })
buf
// ── Queenside castling (line 223) ──────────────────────────────────
test("undo after queenside castling emits MoveUndoneEvent with O-O-O notation"):
// FEN: White king on e1, queenside rook on a1, b1/c1/d1 clear, black king away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
// Castling rights: white queen-side only (no king-side rook present)
val castlingRights = de.nowchess.api.board.CastlingRights(
whiteKingSide = false,
whiteQueenSide = true,
blackKingSide = false,
blackQueenSide = false
)
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(castlingRights)
val engine = new GameEngine(ctx)
val events = captureEvents(engine)
// White castles queenside: e1c1
engine.processUserInput("e1c1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation shouldBe "O-O-O"
// ── En passant notation + computeCaptured (lines 224-225, 254-255) ─
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
val epSquare = Square.fromAlgebraic("d6")
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withEnPassantSquare(epSquare)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx)
val events = captureEvents(engine)
// White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
// Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
moveEvt.capturedPiece shouldBe defined
moveEvt.capturedPiece.get should include ("Black")
events.clear()
engine.undo()
val undoEvt = events.collect { case e: MoveUndoneEvent => e }.head
undoEvt.pgnNotation shouldBe "exd6"
// ── Bishop underpromotion notation (line 230) ──────────────────────
test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"):
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Bishop)
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation shouldBe "e8=B"
// ── King normal move notation (line 246) ───────────────────────────
test("undo after king move emits MoveUndoneEvent with K notation"):
// White king on e1, no castling rights, black king far away
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get
val ctx = GameContext.initial
.withBoard(board)
.withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false))
val engine = new GameEngine(ctx)
val events = captureEvents(engine)
// King moves e1 -> f1
engine.processUserInput("e1f1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should startWith ("K")
evt.pgnNotation should include ("f1")

Some files were not shown because too many files have changed in this diff Show More