Compare commits

...

8 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
167 changed files with 4898 additions and 5543 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"enabledPlugins": { "enabledPlugins": {
"superpowers@claude-plugins-official": true, "superpowers@claude-plugins-official": false,
"ui-ux-pro-max@ui-ux-pro-max-skill": true "ui-ux-pro-max@ui-ux-pro-max-skill": false
} }
} }
+2
View File
@@ -12,6 +12,8 @@
<option value="$PROJECT_DIR$/modules" /> <option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/api" /> <option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/core" /> <option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/ui" /> <option value="$PROJECT_DIR$/modules/ui" />
</set> </set>
</option> </option>
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
</profile> </profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.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="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
<parameters> <parameters>
+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

+5 -1
View File
@@ -1,5 +1,6 @@
plugins { plugins {
id("org.sonarqube") version "7.2.3.7755" id("org.sonarqube") version "7.2.3.7755"
id("org.scoverage") version "8.1" apply false
} }
group = "de.nowchess" group = "de.nowchess"
@@ -28,7 +29,10 @@ val versions = mapOf(
"SCALA_LIBRARY" to "2.13.18", "SCALA_LIBRARY" to "2.13.18",
"SCALATEST" to "3.2.19", "SCALATEST" to "3.2.19",
"SCALATEST_JUNIT" to "0.1.11", "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 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
+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> --output agent (default)
python scoverage_coverage_gaps.py <scoverage.xml> --package-filter de.nowchess.chess.controller 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 <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 import xml.etree.ElementTree as ET
@@ -26,7 +29,8 @@ import sys
import argparse import argparse
import json import json
import re import re
from pathlib import Path, PureWindowsPath import glob
from pathlib import Path
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@@ -112,7 +116,6 @@ class ClassGap:
@property @property
def uncovered_branch_lines(self) -> list[int]: def uncovered_branch_lines(self) -> list[int]:
"""Lines that are branch points and have at least one uncovered branch statement.""" """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 from collections import defaultdict
by_line: dict[int, list[Statement]] = defaultdict(list) by_line: dict[int, list[Statement]] = defaultdict(list)
for s in self.statements: for s in self.statements:
@@ -120,10 +123,7 @@ class ClassGap:
by_line[s.line].append(s) by_line[s.line].append(s)
partial = [] partial = []
for line, stmts in by_line.items(): for line, stmts in by_line.items():
has_covered = any(s.is_covered for s in stmts) if any(s.is_uncovered for s in stmts):
has_uncovered = any(s.is_uncovered for s in stmts)
# Report line if any branch arm is uncovered
if has_uncovered:
partial.append(line) partial.append(line)
return sorted(partial) return sorted(partial)
@@ -169,20 +169,10 @@ class ClassGap:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _normalise_source(raw: str) -> str: 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("\\", "/") normalised = raw.replace("\\", "/")
match = re.search(r"(src/(?:main|test)/scala/.+)", normalised) match = re.search(r"(src/(?:main|test)/scala/.+)", normalised)
if match: if match:
return match.group(1) return match.group(1)
# Fallback: just the filename portion
return normalised.split("/")[-1] return normalised.split("/")[-1]
@@ -190,11 +180,10 @@ def _normalise_source(raw: str) -> str:
# Parser # 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) tree = ET.parse(xml_path)
root = tree.getroot() root = tree.getroot()
# ── Authoritative project-level totals from <scoverage> root element ──────
project_stats = { project_stats = {
"total_statements": int(root.get("statement-count", 0)), "total_statements": int(root.get("statement-count", 0)),
"covered_statements": int(root.get("statements-invoked", 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)), "branch_coverage_pct": float(root.get("branch-rate", 0.0)),
} }
project_stats["missed_statements"] = ( 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 package in root.findall("packages/package"):
for cls_elem in package.findall("classes/class"): for cls_elem in package.findall("classes/class"):
class_name = cls_elem.get("name", "") class_name = cls_elem.get("name", "")
filename = cls_elem.get("filename", "") filename = cls_elem.get("filename", "")
# Authoritative per-class totals from <class> attributes
cls_total = int(cls_elem.get("statement-count", 0)) cls_total = int(cls_elem.get("statement-count", 0))
cls_invoked = int(cls_elem.get("statements-invoked", 0)) cls_invoked = int(cls_elem.get("statements-invoked", 0))
cls_stmt_rate = float(cls_elem.get("statement-rate", 0.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"): for method_elem in cls_elem.findall("methods/method"):
method_name = method_elem.get("name", "") method_name = method_elem.get("name", "")
# Authoritative per-method totals from <method> attributes m_total = int(method_elem.get("statement-count", 0))
m_total = int(method_elem.get("statement-count", 0)) m_invoked = int(method_elem.get("statements-invoked", 0))
m_invoked = int(method_elem.get("statements-invoked", 0))
m_stmt_rate = float(method_elem.get("statement-rate", 0.0))
m_br_rate = float(method_elem.get("branch-rate", 0.0))
for stmt_elem in method_elem.findall("statements/statement"): for stmt_elem in method_elem.findall("statements/statement"):
raw_source = stmt_elem.get("source", filename) raw_source = stmt_elem.get("source", filename)
@@ -257,7 +242,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
method=method_name, method=method_name,
)) ))
# Register method-level gap using authoritative XML stats
cg = next( cg = next(
(v for v in class_map.values() if v.class_name == class_name), (v for v in class_map.values() if v.class_name == class_name),
None, 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_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}) 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: 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) 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) cov_b = sum(1 for s in active if s.is_branch and s.is_covered)
mg = MethodGap( mg = MethodGap(
@@ -282,7 +265,6 @@ def parse_scoverage_xml(xml_path: str) -> list[ClassGap]:
) )
cg.method_gaps.append(mg) 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] 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 # 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: 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: 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"]
total_stmts = project_stats["total_statements"] covered_stmts = project_stats["covered_statements"]
covered_stmts = project_stats["covered_statements"] missed_stmts = project_stats["missed_statements"]
missed_stmts = project_stats["missed_statements"]
overall_stmt_pct = project_stats["stmt_coverage_pct"] overall_stmt_pct = project_stats["stmt_coverage_pct"]
overall_branch_pct = project_stats["branch_coverage_pct"] overall_branch_pct = project_stats["branch_coverage_pct"]
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes) total_branches = sum(c.total_branches for c in classes)
# Branch totals: count from statement data (scoverage root has no branch count attr) covered_branches = sum(c.covered_branches for c in classes)
total_branches = sum(c.total_branches for c in classes) missed_branches = total_branches - covered_branches
covered_branches = sum(c.covered_branches for c in classes)
missed_branches = sum(c.missed_branches for c in classes)
lines.append("## Project Coverage Summary") lines.append("# scoverage Coverage Gaps")
lines.append("") lines.append(
lines.append(f"| Metric | Covered | Total | Missed | Coverage |") f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
lines.append(f"|-------------------|---------|-------|--------|----------|") f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |") f"files with gaps: {len(classes)}"
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("") lines.append("")
sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches)) sorted_classes = sorted(classes, key=lambda c: -(c.missed_statements + c.missed_branches))
for cls in sorted_classes: for cls in sorted_classes:
lines.append(f"### `{cls.source_path}`") uncov = cls.all_uncovered_lines
lines.append(f"**Class**: `{cls.class_name}`")
lines.append("")
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
lines.append(f"|--------------|---------|-------|--------|----------|")
lines.append(f"| Statements | {cls.covered_statements:>7} | {cls.total_statements:>5} | {cls.missed_statements:>6} | {_pct_bar(cls.stmt_coverage_pct)} |")
if cls.total_branches:
lines.append(f"| Branch paths | {cls.covered_branches:>7} | {cls.total_branches:>5} | {cls.missed_branches:>6} | {_pct_bar(cls.branch_coverage_pct)} |")
lines.append("")
uncov = cls.all_uncovered_lines
if uncov:
lines.append("#### ❌ Uncovered Statements")
lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`")
lines.append("")
branch_lines = cls.uncovered_branch_lines branch_lines = cls.uncovered_branch_lines
if branch_lines:
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)") lines.append(f"## {cls.source_path}")
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`") lines.append(
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: if cls.method_gaps:
lines.append("#### Methods with Gaps") lines.append("methods:")
lines.append("")
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
for mg in cls.method_gaps: 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)" parts = [f" {mg.short_name}"]
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a" if mg.uncovered_lines:
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "" parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "" if mg.uncovered_branch_lines:
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |") parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
lines.append("") lines.append(" ".join(parts))
lines.append("**Action**: Add tests that exercise the lines/branches listed above.")
lines.append("") 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) return "\n".join(lines)
@@ -511,6 +450,87 @@ def format_markdown(project_stats: dict, classes: list[ClassGap]) -> str:
return "\n".join(lines) 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 # Entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -519,7 +539,13 @@ def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Report missing statement & branch coverage from a scoverage XML report." 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( parser.add_argument(
"--output", "-o", "--output", "-o",
choices=["agent", "json", "markdown"], choices=["agent", "json", "markdown"],
@@ -537,8 +563,30 @@ def main() -> None:
default=None, default=None,
help="Only report classes in this package prefix (e.g. de.nowchess.chess.controller)", 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() 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) xml_path = Path(args.xml_file)
if not xml_path.exists(): if not xml_path.exists():
print(f"ERROR: File not found: {xml_path}", file=sys.stderr) print(f"ERROR: File not found: {xml_path}", file=sys.stderr)
@@ -565,4 +613,4 @@ def main() -> None:
if __name__ == "__main__": 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()
+12
View File
@@ -4,3 +4,15 @@
## (2026-03-29) ## (2026-03-29)
## (2026-03-31) ## (2026-03-31)
## (2026-04-01) ## (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 { useJUnitPlatform {
includeEngines("scalatest") includeEngines("scalatest")
testLogging { testLogging {
events("passed", "skipped", "failed") events("skipped", "failed")
} }
} }
finalizedBy(tasks.reportScoverage) finalizedBy(tasks.reportScoverage)
@@ -14,6 +14,9 @@ object Board:
val captured = b.get(to) val captured = b.get(to)
val updatedBoard = b.removed(from).updated(to, b(from)) val updatedBoard = b.removed(from).updated(to, b(from))
(updatedBoard, captured) (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 def pieces: Map[Square, Piece] = b
val initial: Board = 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 if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
) )
for f <- fileOpt; r <- rankOpt yield Square(f, r) 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. */ /** Classifies special move semantics beyond a plain quiet move or capture. */
enum MoveType: enum MoveType:
/** A normal move or capture with no special rule. */ /** A normal move or capture with no special rule. */
case Normal case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */ /** Kingside castling (O-O). */
case CastleKingside case CastleKingside
/** Queenside castling (O-O-O). */ /** Queenside castling (O-O-O). */
@@ -29,5 +29,5 @@ enum MoveType:
final case class Move( final case class Move(
from: Square, from: Square,
to: Square, to: Square,
moveType: MoveType = MoveType.Normal moveType: MoveType = MoveType.Normal()
) )
@@ -1,5 +1,6 @@
package de.nowchess.api.board package de.nowchess.api.board
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers 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 e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4) 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) Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
}
test("pieceAt returns None for empty square") {
Board.initial.pieceAt(e4) shouldBe None Board.initial.pieceAt(e4) shouldBe None
} }
@@ -34,38 +31,20 @@ class BoardTest extends AnyFunSuite with Matchers:
board.pieceAt(from) shouldBe None board.pieceAt(from) shouldBe None
} }
test("pieces returns the underlying map") { test("Board.apply and pieces expose the wrapped map") {
val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map)
b.pieces shouldBe map
}
test("Board.apply constructs board from map") {
val map = Map(e2 -> Piece.WhitePawn) val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map) val b = Board(map)
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn) 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 Board.initial.pieces should have size 32
}
test("initial board has 16 white pieces") {
Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16 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 Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16
}
test("initial board white pawns on rank 2") {
File.values.foreach { file => File.values.foreach { file =>
Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn) 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) 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 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 b = Board(Map(e2 -> Piece.WhitePawn))
val updated = b.updated(e4, Piece.WhiteKnight) val added = b.updated(e4, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn) added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
}
test("updated replaces existing piece") { val replaced = b.updated(e2, Piece.WhiteKnight)
val b = Board(Map(e2 -> Piece.WhitePawn)) replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
val updated = b.updated(e2, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
} }
test("removed deletes piece from board") { test("removed deletes piece from board") {
@@ -120,3 +96,13 @@ class BoardTest extends AnyFunSuite with Matchers:
removed.pieceAt(e2) shouldBe None removed.pieceAt(e2) shouldBe None
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight) 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: class ColorTest extends AnyFunSuite with Matchers:
test("White.opposite returns Black") { test("Color values expose opposite and label consistently"):
Color.White.opposite shouldBe Color.Black val cases = List(
} (Color.White, Color.Black, "White"),
(Color.Black, Color.White, "Black")
)
test("Black.opposite returns White") { cases.foreach { (color, opposite, label) =>
Color.Black.opposite shouldBe Color.White color.opposite shouldBe opposite
} color.label shouldBe label
}
test("White.label returns 'White'") {
Color.White.label shouldBe "White"
}
test("Black.label returns 'Black'") {
Color.Black.label shouldBe "Black"
}
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
p.pieceType shouldBe PieceType.Queen p.pieceType shouldBe PieceType.Queen
} }
test("WhitePawn convenience constant") { test("all convenience constants map to expected color and piece type") {
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn) 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") { expected.foreach { case (actual, wanted) =>
Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight) actual shouldBe wanted
} }
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)
} }
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
class PieceTypeTest extends AnyFunSuite with Matchers: class PieceTypeTest extends AnyFunSuite with Matchers:
test("Pawn.label returns 'Pawn'") { test("PieceType values expose the expected labels"):
PieceType.Pawn.label shouldBe "Pawn" 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'") { expectedLabels.foreach { (pieceType, expectedLabel) =>
PieceType.Knight.label shouldBe "Knight" pieceType.label shouldBe expectedLabel
} }
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"
}
@@ -5,58 +5,33 @@ import org.scalatest.matchers.should.Matchers
class SquareTest extends AnyFunSuite with Matchers: class SquareTest extends AnyFunSuite with Matchers:
test("Square.toString produces lowercase file and rank number") { test("toString renders algebraic notation for edge and middle squares") {
Square(File.E, Rank.R4).toString shouldBe "e4"
}
test("Square.toString for a1") {
Square(File.A, Rank.R1).toString shouldBe "a1" Square(File.A, Rank.R1).toString shouldBe "a1"
} Square(File.E, Rank.R4).toString shouldBe "e4"
test("Square.toString for h8") {
Square(File.H, Rank.R8).toString shouldBe "h8" Square(File.H, Rank.R8).toString shouldBe "h8"
} }
test("fromAlgebraic parses valid square e4") { test("fromAlgebraic parses valid coordinates including case-insensitive files") {
Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4)) 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") { test("fromAlgebraic rejects malformed coordinates") {
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1)) List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
Square.fromAlgebraic(raw) shouldBe None
}
} }
test("fromAlgebraic parses valid square h8") { test("offset returns Some in-bounds and None out-of-bounds") {
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8)) 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 e2 = Square(File.E, Rank.R2)
private val e4 = Square(File.E, Rank.R4) private val e4 = Square(File.E, Rank.R4)
test("Move defaults moveType to Normal") { test("Move defaults to Normal and keeps from/to squares") {
val m = Move(e2, e4)
m.moveType shouldBe MoveType.Normal
}
test("Move stores from and to squares") {
val m = Move(e2, e4) val m = Move(e2, e4)
m.from shouldBe e2 m.from shouldBe e2
m.to shouldBe e4 m.to shouldBe e4
m.moveType shouldBe MoveType.Normal()
} }
test("Move with CastleKingside moveType") { test("Move accepts all supported move types") {
val m = Move(e2, e4, MoveType.CastleKingside) val moveTypes = List(
m.moveType shouldBe MoveType.CastleKingside 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") { moveTypes.foreach { moveType =>
val m = Move(e2, e4, MoveType.CastleQueenside) Move(e2, e4, moveType).moveType shouldBe moveType
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)
} }
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
class PlayerInfoTest extends AnyFunSuite with Matchers: class PlayerInfoTest extends AnyFunSuite with Matchers:
test("PlayerId.apply wraps a string") { test("PlayerId and PlayerInfo preserve constructor values") {
val id = PlayerId("player-123") val raw = "player-123"
id.value shouldBe "player-123" val id = PlayerId(raw)
}
test("PlayerId.value unwraps to original string") { id.value shouldBe raw
val raw = "abc-456"
PlayerId(raw).value shouldBe raw
}
test("PlayerInfo holds id and displayName") { val playerId = PlayerId("p1")
val id = PlayerId("p1") val info = PlayerInfo(playerId, "Magnus")
val info = PlayerInfo(id, "Magnus")
info.id.value shouldBe "p1" info.id.value shouldBe "p1"
info.displayName shouldBe "Magnus" info.displayName shouldBe "Magnus"
} }
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
class ApiResponseTest extends AnyFunSuite with 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) val r = ApiResponse.Success(42)
r.data shouldBe 42 r.data shouldBe 42
}
test("ApiResponse.Failure carries error list") {
val err = ApiError("CODE", "msg") val err = ApiError("CODE", "msg")
val r = ApiResponse.Failure(List(err)) ApiResponse.Failure(List(err)).errors shouldBe List(err)
r.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") val e = ApiError("CODE", "message")
e.code shouldBe "CODE" e.code shouldBe "CODE"
e.message shouldBe "message" e.message shouldBe "message"
e.field shouldBe None e.field shouldBe None
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
} }
test("ApiError holds optional field") { test("Pagination.totalPages handles normal and guarded inputs") {
val e = ApiError("INVALID", "bad value", Some("email"))
e.field shouldBe Some("email")
}
test("Pagination.totalPages with exact division") {
Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3 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 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 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 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 Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=0 MINOR=2
PATCH=6 PATCH=0
+89
View File
@@ -99,3 +99,92 @@
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150)) * 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 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)) * 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 { plugins {
id("scala") id("scala")
id("org.scoverage") version "8.1" id("org.scoverage") version "8.1"
application
} }
group = "de.nowchess" group = "de.nowchess"
@@ -22,19 +21,10 @@ scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!) scoverageVersion.set(versions["SCOVERAGE"]!!)
} }
application {
mainClass.set("de.nowchess.chess.Main")
}
tasks.withType<ScalaCompile> { tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") 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 { dependencies {
implementation("org.scala-lang:scala3-compiler_3") { implementation("org.scala-lang:scala3-compiler_3") {
@@ -49,6 +39,8 @@ dependencies {
} }
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -62,7 +54,7 @@ tasks.test {
useJUnitPlatform { useJUnitPlatform {
includeEngines("scalatest") includeEngines("scalatest")
testLogging { testLogging {
events("passed", "skipped", "failed") events("skipped", "failed")
} }
} }
finalizedBy(tasks.reportScoverage) finalizedBy(tasks.reportScoverage)
@@ -1,7 +1,7 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Board, Color, Piece} import de.nowchess.api.board.{Square, Piece}
import de.nowchess.chess.logic.GameHistory import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone. /** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions. * Commands encapsulate user actions and game state transitions.
@@ -23,23 +23,22 @@ case class MoveCommand(
from: Square, from: Square,
to: Square, to: Square,
moveResult: Option[MoveResult] = None, moveResult: Option[MoveResult] = None,
previousBoard: Option[Board] = None, previousContext: Option[GameContext] = None,
previousHistory: Option[GameHistory] = None, notation: String = ""
previousTurn: Option[Color] = None
) extends Command: ) extends Command:
override def execute(): Boolean = override def execute(): Boolean =
moveResult.isDefined moveResult.isDefined
override def undo(): Boolean = override def undo(): Boolean =
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined previousContext.isDefined
override def description: String = s"Move from $from to $to" override def description: String = s"Move from $from to $to"
// Sealed hierarchy of move outcomes (for tracking state changes) // Sealed hierarchy of move outcomes (for tracking state changes)
sealed trait MoveResult sealed trait MoveResult
object MoveResult: object MoveResult:
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult case object InvalidMove extends MoveResult
@@ -51,14 +50,12 @@ case class QuitCommand() extends Command:
/** Command to reset the board to initial position. */ /** Command to reset the board to initial position. */
case class ResetCommand( case class ResetCommand(
previousBoard: Option[Board] = None, previousContext: Option[GameContext] = None
previousHistory: Option[GameHistory] = None,
previousTurn: Option[Color] = None
) extends Command: ) extends Command:
override def execute(): Boolean = true override def execute(): Boolean = true
override def undo(): Boolean = override def undo(): Boolean =
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined previousContext.isDefined
override def description: String = "Reset board" override def description: String = "Reset board"
@@ -1,105 +0,0 @@
package de.nowchess.chess.controller
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.*
// ---------------------------------------------------------------------------
// 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 PromotionRequired(
from: Square,
to: Square,
boardBefore: Board,
historyBefore: GameHistory,
captured: Option[Piece],
turn: Color
) 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)) => validateAndApply(board, history, turn, from, to)
/** Apply a previously detected promotion move with the chosen piece.
* Called after processMove returned PromotionRequired.
*/
def completePromotion(
board: Board,
history: GameHistory,
from: Square,
to: Square,
piece: PromotionPiece,
turn: Color
): MoveResult =
val (boardAfterMove, captured) = board.withMove(from, to)
val promotedPieceType = piece match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
// Promotion is always a pawn move → clock resets
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
toMoveResult(newBoard, newHistory, captured, turn)
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
board.pieceAt(from) match
case None => MoveResult.NoPiece
case Some(piece) if piece.color != turn => MoveResult.WrongColor
case Some(_) =>
if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
else if MoveValidator.isPromotionMove(board, from, to) then
MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
else applyNormalMove(board, history, turn, from, to)
private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val wasCapture = captured.isDefined
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
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
@@ -1,46 +1,37 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, Square} import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus} import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.{GameController, Parser, MoveResult} import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand} 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. /** Pure game engine that manages game state and notifies observers of state changes.
* This class is the single source of truth for the game state. * All rule queries delegate to the injected RuleSet.
* All user interactions must go through this engine via Commands, and all state changes * All user interactions go through Commands; state changes are broadcast via GameEvents.
* are communicated to observers via GameEvent notifications.
*/ */
class GameEngine( class GameEngine(
initialBoard: Board = Board.initial, val initialContext: GameContext = GameContext.initial,
initialHistory: GameHistory = GameHistory.empty, val ruleSet: RuleSet = DefaultRules
initialTurn: Color = Color.White,
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
GameController.completePromotion
) extends Observable: ) extends Observable:
private var currentBoard: Board = initialBoard private var currentContext: GameContext = initialContext
private var currentHistory: GameHistory = initialHistory
private var currentTurn: Color = initialTurn
private val invoker = new CommandInvoker() private val invoker = new CommandInvoker()
/** Inner class for tracking pending promotion state */ /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion( private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
from: Square, to: Square,
boardBefore: Board, historyBefore: GameHistory,
turn: Color
)
/** Current pending promotion, if any */
private var pendingPromotion: Option[PendingPromotion] = None private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */ /** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
// Synchronized accessors for current state // Synchronized accessors for current state
def board: Board = synchronized { currentBoard } def board: Board = synchronized { currentContext.board }
def history: GameHistory = synchronized { currentHistory } def turn: Color = synchronized { currentContext.turn }
def turn: Color = synchronized { currentTurn } def context: GameContext = synchronized { currentContext }
/** Check if undo is available. */ /** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo } def canUndo: Boolean = synchronized { invoker.canUndo }
@@ -58,7 +49,6 @@ class GameEngine(
val trimmed = rawInput.trim.toLowerCase val trimmed = rawInput.trim.toLowerCase
trimmed match trimmed match
case "quit" | "q" => case "quit" | "q" =>
// Client should handle quit logic; we just return
() ()
case "undo" => case "undo" =>
@@ -68,96 +58,55 @@ class GameEngine(
performRedo() performRedo()
case "draw" => case "draw" =>
if currentHistory.halfMoveClock >= 100 then if currentContext.halfMoveClock >= 100 then
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear() invoker.clear()
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(DrawClaimedEvent(currentContext))
else else
notifyObservers(InvalidMoveEvent( notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn, currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered." "Draw cannot be claimed: the 50-move rule has not been triggered."
)) ))
case "" => case "" =>
val event = InvalidMoveEvent( notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
currentBoard,
currentHistory,
currentTurn,
"Please enter a valid move or command."
)
notifyObservers(event)
case moveInput => case moveInput =>
Parser.parseMove(moveInput) match Parser.parseMove(moveInput) match
case None => case None =>
notifyObservers(InvalidMoveEvent( notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn, currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
)) ))
case Some((from, to)) => case Some((from, to)) =>
handleParsedMove(from, to, moveInput) handleParsedMove(from, to)
} }
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit = private def handleParsedMove(from: Square, to: Square): Unit =
val cmd = MoveCommand( currentContext.board.pieceAt(from) match
from = from, case None =>
to = to, notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
previousBoard = Some(currentBoard), case Some(piece) if piece.color != currentContext.turn =>
previousHistory = Some(currentHistory), notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
previousTurn = Some(currentTurn) case Some(piece) =>
) val legal = ruleSet.legalMoves(currentContext, from)
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match // Find all legal moves going to `to`
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => val candidates = legal.filter(_.to == to)
handleFailedMove(moveInput) 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)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => private def isPromotionMove(piece: Piece, to: Square): Boolean =
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) piece.pieceType == PieceType.Pawn && {
invoker.execute(updatedCmd) val promoRank = if piece.color == Color.White then 7 else 0
updateGameState(newBoard, newHistory, newTurn) to.rank.ordinal == promoRank
emitMoveEvent(from.toString, to.toString, captured, newTurn) }
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
/** Undo the last move. */
def undo(): Unit = synchronized {
performUndo()
}
/** Redo the last undone move. */
def redo(): Unit = synchronized {
performRedo()
}
/** Apply a player's promotion piece choice. /** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true. * Must only be called when isPendingPromotion is true.
@@ -165,130 +114,205 @@ class GameEngine(
def completePromotion(piece: PromotionPiece): Unit = synchronized { def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match pendingPromotion match
case None => case None =>
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending.")) notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending."))
case Some(pending) => case Some(pending) =>
pendingPromotion = None pendingPromotion = None
val cmd = MoveCommand( val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
from = pending.from, // Verify it's actually legal
to = pending.to, val legal = ruleSet.legalMoves(currentContext, pending.from)
previousBoard = Some(pending.boardBefore), if legal.contains(move) then
previousHistory = Some(pending.historyBefore), executeMove(move)
previousTurn = Some(pending.turn) else
) notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
completePromotionFn( }
pending.boardBefore, pending.historyBefore,
pending.from, pending.to, piece, pending.turn
) match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => /** Undo the last move. */
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) def undo(): Unit = synchronized { performUndo() }
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) => /** Redo the last undone move. */
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) def redo(): Unit = synchronized { performRedo() }
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate => /** Load a game using the provided importer.
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) * If the imported context has moves, they are replayed through the command system.
invoker.execute(updatedCmd) * Otherwise, the position is set directly.
currentBoard = Board.initial * Notifies observers with PgnLoadedEvent on success.
currentHistory = GameHistory.empty */
currentTurn = Color.White def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn)) importer.importGameContext(input) match
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
notifyObservers(PgnLoadedEvent(currentContext))
}
}
case _ => private def replayGame(ctx: GameContext): Either[String, Unit] =
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion.")) 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. */ /** Reset the board to initial position. */
def reset(): Unit = synchronized { def reset(): Unit = synchronized {
currentBoard = Board.initial currentContext = GameContext.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear() invoker.clear()
notifyObservers(BoardResetEvent( notifyObservers(BoardResetEvent(currentContext))
currentBoard,
currentHistory,
currentTurn
))
} }
// ──── Private Helpers ──── // ──── 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 = private def performUndo(): Unit =
if invoker.canUndo then if invoker.canUndo then
val cmd = invoker.history(invoker.getCurrentIndex) val cmd = invoker.history(invoker.getCurrentIndex)
(cmd: @unchecked) match (cmd: @unchecked) match
case moveCmd: MoveCommand => case moveCmd: MoveCommand =>
moveCmd.previousBoard.foreach(currentBoard = _) moveCmd.previousContext.foreach(currentContext = _)
moveCmd.previousHistory.foreach(currentHistory = _)
moveCmd.previousTurn.foreach(currentTurn = _)
invoker.undo() invoker.undo()
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn)) notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo.")) notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit = private def performRedo(): Unit =
if invoker.canRedo then if invoker.canRedo then
val cmd = invoker.history(invoker.getCurrentIndex + 1) val cmd = invoker.history(invoker.getCurrentIndex + 1)
(cmd: @unchecked) match (cmd: @unchecked) match
case moveCmd: MoveCommand => case moveCmd: MoveCommand =>
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
updateGameState(nb, nh, nt) currentContext = nextCtx
invoker.redo() invoker.redo()
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt) 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 else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo.")) notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
currentBoard = newBoard
currentHistory = newHistory
currentTurn = newTurn
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveExecutedEvent(
currentBoard,
currentHistory,
newTurn,
fromSq,
toSq,
capturedDesc
))
private def handleFailedMove(moveInput: String): Unit =
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
case MoveResult.NoPiece =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"No piece on that square."
))
case MoveResult.WrongColor =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"That is not your piece."
))
case MoveResult.IllegalMove =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"Illegal move."
))
@@ -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,32 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
object EnPassantCalculator:
/** Returns the en passant target square if the last move was a double pawn push.
* The target is the square the pawn passed through (e.g. e2e4 yields e3).
*/
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
history.moves.lastOption.flatMap: move =>
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
val isDoublePush = math.abs(rankDiff) == 2
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
if isDoublePush && isPawn then
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
Some(Square(move.to.file, Rank.values(midRankIdx)))
else None
/** True if moving from→to is an en passant capture. */
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
enPassantTarget(board, history).contains(to) &&
math.abs(to.file.ordinal - from.file.ordinal) == 1
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
* White captures upward → captured pawn is one rank below `to`.
* Black captures downward → captured pawn is one rank above `to`.
*/
def capturedPawnSquare(to: Square, color: Color): Square =
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
Square(to.file, Rank.values(capturedRankIdx))
@@ -1,46 +0,0 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.Square
import de.nowchess.api.move.PromotionPiece
/** 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],
promotionPiece: Option[PromotionPiece] = None
)
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
*
* @param moves moves played so far, oldest first
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
*/
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
/** Add a raw HistoryMove record. Clock increments by 1.
* Use the coordinate overload when you know whether the move is a pawn move or capture.
*/
def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move, halfMoveClock + 1)
/** Add a move by coordinates.
*
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
*
* If neither flag is set the clock increments by 1.
*/
def addMove(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
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,183 +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 Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color)
case _ =>
legalTargets(board, from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color)
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val epCapture: Set[Square] =
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
.toSet
existing ++ epCapture
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from) match
case Some(Piece(_, PieceType.Pawn)) =>
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
(from.rank == Rank.R2 && to.rank == Rank.R1)
case _ => false
@@ -1,44 +0,0 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
object PgnExporter:
/** Export a game with headers and history to PGN format. */
def exportGame(headers: Map[String, String], history: GameHistory): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if history.moves.isEmpty then ""
else
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
/** Convert a HistoryMove to algebraic notation. */
private def moveToAlgebraic(move: HistoryMove): String =
move.castleSide match
case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O"
case None =>
val base = s"${move.from}${move.to}"
move.promotionPiece match
case Some(PromotionPiece.Queen) => s"$base=Q"
case Some(PromotionPiece.Rook) => s"$base=R"
case Some(PromotionPiece.Bishop) => s"$base=B"
case Some(PromotionPiece.Knight) => s"$base=N"
case None => base
@@ -1,175 +0,0 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
headers: Map[String, String],
moves: List[HistoryMove]
)
object PgnParser:
/** Parse a complete PGN text into a PgnGame with headers and moves.
* Always succeeds (returns Some); malformed tokens are silently skipped. */
def parsePgn(pgn: String): Option[PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText)
Some(PgnGame(headers, moves))
/** Parse PGN header lines of the form [Key "Value"]. */
private def parseHeaders(lines: Array[String]): Map[String, String] =
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
private def parseMovesText(moveText: String): List[HistoryMove] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
// Fold over tokens, threading (board, history, currentColor, accumulator)
val (_, _, _, moves) = tokens.foldLeft(
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
):
case (state @ (board, history, color, acc), token) =>
// Skip move-number markers (e.g. "1.", "2.") and result tokens
if isMoveNumberOrResult(token) then state
else
parseAlgebraicMove(token, board, history, color) match
case None => state // unrecognised token — skip silently
case Some(move) =>
val newBoard = applyMoveToBoard(board, move, color)
val newHistory = history.addMove(move)
(newBoard, newHistory, color.opposite, acc :+ move)
moves
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
move.castleSide match
case Some(side) => board.withCastle(color, side)
case None =>
val (boardAfterMove, _) = board.withMove(move.from, move.to)
move.promotionPiece match
case Some(pp) =>
val pieceType = pp match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
boardAfterMove.updated(move.to, Piece(color, pieceType))
case None => boardAfterMove
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") ||
token == "*" ||
token == "1-0" ||
token == "0-1" ||
token == "1/2-1/2"
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
notation match
case "O-O" | "O-O+" | "O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
case _ =>
parseRegularMove(notation, board, history, color)
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
val clean = notation
.replace("+", "")
.replace("#", "")
.replace("x", "")
.replaceAll("=[NBRQ]$", "")
// The destination square is always the last two characters
if clean.length < 2 then None
else
val destStr = clean.takeRight(2)
Square.fromAlgebraic(destStr).flatMap: toSquare =>
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
// Determine required piece type: upper-case first char = piece letter; else pawn
val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn)
// Collect the disambiguation hint that remains after stripping the piece letter
val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig // hint is file/rank info or empty
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
// We prefer pieces that can actually reach the target; if none can (positionally illegal
// PGN input), fall back to any piece of the matching type belonging to `color`.
val reachable: Set[Square] =
board.pieces.collect {
case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board, from).contains(toSquare) => from
}.toSet
val candidates: Set[Square] =
if reachable.nonEmpty then reachable
else
// Fallback for positionally-illegal but syntactically valid PGN notation:
// find any piece of `color` with the correct piece type on the board.
board.pieces.collect {
case (from, piece) if piece.color == color => from
}.toSet
// Filter by required piece type
val byPiece = candidates.filter(from =>
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
)
// Apply disambiguation hint (file letter or rank digit)
val disambiguated =
if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint))
val promotion = extractPromotion(notation)
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true)
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
val promotionPattern = """=([A-Z])""".r
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
m.group(1) match
case "Q" => Some(PromotionPiece.Queen)
case "R" => Some(PromotionPiece.Rook)
case "B" => Some(PromotionPiece.Bishop)
case "N" => Some(PromotionPiece.Knight)
case _ => None
}
/** Convert a piece-letter character to a PieceType. */
private def charToPieceType(c: Char): Option[PieceType] =
c match
case 'N' => Some(PieceType.Knight)
case 'B' => Some(PieceType.Bishop)
case 'R' => Some(PieceType.Rook)
case 'Q' => Some(PieceType.Queen)
case 'K' => Some(PieceType.King)
case _ => None
@@ -1,21 +1,17 @@
package de.nowchess.chess.observer package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color, Square} import de.nowchess.api.board.{Color, Square}
import de.nowchess.chess.logic.GameHistory import de.nowchess.api.game.GameContext
/** Base trait for all game state events. /** Base trait for all game state events.
* Events are immutable snapshots of game state changes. * Events are immutable snapshots of game state changes.
*/ */
sealed trait GameEvent: sealed trait GameEvent:
def board: Board def context: GameContext
def history: GameHistory
def turn: Color
/** Fired when a move is successfully executed. */ /** Fired when a move is successfully executed. */
case class MoveExecutedEvent( case class MoveExecutedEvent(
board: Board, context: GameContext,
history: GameHistory,
turn: Color,
fromSquare: String, fromSquare: String,
toSquare: String, toSquare: String,
capturedPiece: Option[String] capturedPiece: Option[String]
@@ -23,62 +19,66 @@ case class MoveExecutedEvent(
/** Fired when the current player is in check. */ /** Fired when the current player is in check. */
case class CheckDetectedEvent( case class CheckDetectedEvent(
board: Board, context: GameContext
history: GameHistory,
turn: Color
) extends GameEvent ) extends GameEvent
/** Fired when the game reaches checkmate. */ /** Fired when the game reaches checkmate. */
case class CheckmateEvent( case class CheckmateEvent(
board: Board, context: GameContext,
history: GameHistory,
turn: Color,
winner: Color winner: Color
) extends GameEvent ) extends GameEvent
/** Fired when the game reaches stalemate. */ /** Fired when the game reaches stalemate. */
case class StalemateEvent( case class StalemateEvent(
board: Board, context: GameContext
history: GameHistory,
turn: Color
) extends GameEvent ) extends GameEvent
/** Fired when a move is invalid. */ /** Fired when a move is invalid. */
case class InvalidMoveEvent( case class InvalidMoveEvent(
board: Board, context: GameContext,
history: GameHistory,
turn: Color,
reason: String reason: String
) extends GameEvent ) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */ /** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent( case class PromotionRequiredEvent(
board: Board, context: GameContext,
history: GameHistory,
turn: Color,
from: Square, from: Square,
to: Square to: Square
) extends GameEvent ) extends GameEvent
/** Fired when the board is reset. */ /** Fired when the board is reset. */
case class BoardResetEvent( case class BoardResetEvent(
board: Board, context: GameContext
history: GameHistory,
turn: Color
) extends GameEvent ) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */ /** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent( case class FiftyMoveRuleAvailableEvent(
board: Board, context: GameContext
history: GameHistory,
turn: Color
) extends GameEvent ) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */ /** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent( case class DrawClaimedEvent(
board: Board, context: GameContext
history: GameHistory, ) extends GameEvent
turn: Color
/** 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 ) extends GameEvent
/** Observer trait: implement to receive game state updates. */ /** Observer trait: implement to receive game state updates. */
@@ -1,216 +1,148 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.chess.logic.GameHistory import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
class CommandInvokerBranchTest extends AnyFunSuite with Matchers: class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
// ──── Helper: Command that always fails ────
private case class FailingCommand() extends Command: private case class FailingCommand() extends Command:
override def execute(): Boolean = false override def execute(): Boolean = false
override def undo(): Boolean = false override def undo(): Boolean = false
override def description: String = "Failing command" override def description: String = "Failing command"
// ──── Helper: Command that conditionally fails on undo or execute ────
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command: private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
override def execute(): Boolean = !shouldFailOnExecute override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo override def undo(): Boolean = !shouldFailOnUndo
override def description: String = "Conditional fail" override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
val cmd = MoveCommand( MoveCommand(
from = from, from = from,
to = to, to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None, moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousBoard = Some(Board.initial), previousContext = Some(GameContext.initial)
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
) )
cmd
// ──── BRANCH: execute() returns false ──── test("execute rejects failing commands and keeps history unchanged"):
test("CommandInvoker.execute() with failing command returns false"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = FailingCommand() val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.execute() does not add failed command to history"):
val invoker = new CommandInvoker()
val failingCmd = FailingCommand() val failingCmd = FailingCommand()
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(failingCmd) shouldBe false invoker.execute(failingCmd) shouldBe false
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.execute(successCmd) shouldBe true invoker.execute(successCmd) shouldBe true
invoker.history.size shouldBe 1 invoker.history.size shouldBe 1
invoker.history(0) shouldBe successCmd invoker.history.head shouldBe successCmd
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ──── test("undo redo and history trimming cover all command state transitions"):
test("CommandInvoker.undo() returns false when currentIndex < 0"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
// currentIndex starts at -1 invoker.undo() shouldBe false
invoker.undo() shouldBe false invoker.canUndo shouldBe false
invoker.undo() shouldBe false
}
test("CommandInvoker.undo() returns false when empty history"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
invoker.canUndo shouldBe false val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.undo() shouldBe false 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
}
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ──── {
test("CommandInvoker.undo() returns false when currentIndex >= history size"): val invoker = new CommandInvoker()
val invoker = new CommandInvoker() val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(failingUndoCmd) shouldBe true
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) invoker.canUndo shouldBe true
invoker.undo() shouldBe false
invoker.execute(cmd1) invoker.getCurrentIndex shouldBe 0
invoker.execute(cmd2) }
// currentIndex now = 1, history.size = 2
invoker.undo() // currentIndex becomes 0
invoker.undo() // currentIndex becomes -1
invoker.undo() // currentIndex still -1, should fail
// ──── BRANCH: undo() command returns false ──── {
test("CommandInvoker.undo() returns false when command.undo() fails"): val invoker = new CommandInvoker()
val invoker = new CommandInvoker() val successUndoCmd = ConditionalFailCommand()
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true) invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true
invoker.execute(failingCmd) shouldBe true invoker.getCurrentIndex shouldBe -1
invoker.canUndo shouldBe true }
invoker.undo() shouldBe false
// Index should not change when undo fails
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.undo() returns true when command.undo() succeeds"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false) invoker.redo() shouldBe false
}
invoker.execute(successCmd) shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ──── {
test("CommandInvoker.redo() returns false when nothing to redo"): val invoker = new CommandInvoker()
val invoker = new CommandInvoker() val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.redo() shouldBe false invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
test("CommandInvoker.redo() returns false when at end of history"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd) invoker.execute(cmd1)
// currentIndex = 0, history.size = 1 invoker.execute(redoFailCmd)
invoker.canRedo shouldBe false invoker.undo()
invoker.redo() shouldBe false invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = 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(cmd) shouldBe true
invoker.undo() shouldBe true
invoker.execute(cmd1) invoker.redo() shouldBe true
invoker.execute(cmd2) invoker.getCurrentIndex shouldBe 0
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false }
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
// ──── BRANCH: redo() command returns false ──── {
test("CommandInvoker.redo() returns false when command.execute() fails"): val invoker = new CommandInvoker()
val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
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 redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd1) invoker.execute(cmd2)
invoker.execute(redoFailCmd) // Succeeds and added to history invoker.undo()
invoker.canRedo shouldBe true
invoker.undo() invoker.execute(cmd3)
// currentIndex = 0, redoFailCmd is at index 1 invoker.canRedo shouldBe false
invoker.canRedo shouldBe true invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
// Now modify to fail on next execute (redo) }
redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false
// currentIndex should not change
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
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
// ──── BRANCH: execute() with redo history discarding (while loop) ────
test("CommandInvoker.execute() discards redo history via while loop"):
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)
// currentIndex = 1, size = 2
invoker.undo()
// currentIndex = 0, size = 2
// Redo history exists: cmd2 is at index 1
invoker.canRedo shouldBe true
invoker.execute(cmd3)
// while loop should discard cmd2
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
test("CommandInvoker.execute() discards multiple redo commands"):
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)
// currentIndex = 3, size = 4
invoker.undo()
invoker.undo()
// currentIndex = 1, size = 4
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
invoker.canRedo shouldBe true
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(newCmd)
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
invoker.history.size shouldBe 3
invoker.canRedo shouldBe false
// ──── BRANCH: execute() with no redo history to discard ────
test("CommandInvoker.execute() with no redo history (while condition 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)
// currentIndex = 1, size = 2
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
invoker.canRedo shouldBe false
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd3) // While loop condition should be false, no iterations
invoker.history.size shouldBe 3
{
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
}
@@ -1,81 +1,47 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color} import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.chess.logic.GameHistory import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
class CommandInvokerTest extends AnyFunSuite with Matchers: class CommandInvokerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand = private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand( MoveCommand(
from = from, from = from,
to = to, to = to,
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousBoard = Some(Board.initial), previousContext = Some(GameContext.initial)
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
) )
test("CommandInvoker executes a command and adds it to history"): test("execute appends commands and updates index"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1 invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
test("CommandInvoker executes multiple commands in sequence"):
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 cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1) shouldBe true
invoker.execute(cmd2) shouldBe true invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2 invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1 invoker.getCurrentIndex shouldBe 1
test("CommandInvoker.canUndo returns false when empty"): test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
invoker.canUndo shouldBe false
test("CommandInvoker.canUndo returns true after execution"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd) invoker.execute(cmd)
invoker.canUndo shouldBe true invoker.canUndo shouldBe true
test("CommandInvoker.undo decrements current index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.getCurrentIndex shouldBe 0
invoker.undo() shouldBe true invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.canRedo returns true after undo"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo()
invoker.canRedo shouldBe true invoker.canRedo shouldBe true
test("CommandInvoker.redo re-executes a command"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo() shouldBe true
invoker.redo() shouldBe true invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.canUndo returns false when at beginning"): 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.undo()
invoker.canUndo shouldBe false
test("CommandInvoker clear removes all history"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) invoker.execute(cmd)
@@ -83,7 +49,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker discards all history when executing after undoing all"): test("execute after undo discards redo history"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) 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 cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
@@ -91,33 +57,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
invoker.execute(cmd1) invoker.execute(cmd1)
invoker.execute(cmd2) invoker.execute(cmd2)
invoker.undo() invoker.undo()
invoker.undo()
// After undoing twice, we're at the beginning (before any commands)
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
// Executing a new command from the beginning discards all redo history
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 1
invoker.history(0) shouldBe cmd3
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker discards redo history when executing mid-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()
// After one undo, we're at the end of cmd1
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
invoker.canRedo shouldBe true invoker.canRedo shouldBe true
// Executing a new command discards cmd2 (the redo history)
invoker.execute(cmd3) invoker.execute(cmd3)
invoker.canRedo shouldBe false invoker.canRedo shouldBe false
invoker.history.size shouldBe 2 invoker.history.size shouldBe 2
invoker.history(0) shouldBe cmd1 invoker.history.head shouldBe cmd1
invoker.history(1) shouldBe cmd3 invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1 invoker.getCurrentIndex shouldBe 1
@@ -1,131 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.collection.mutable
class CommandInvokerThreadSafetyTest 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(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
val invoker = new CommandInvoker()
@volatile var raceDetected = false
val exceptions = mutable.ListBuffer[Exception]()
// Thread 1: executes commands
val executorThread = new Thread(new Runnable {
def run(): Unit = {
try {
for i <- 1 to 1000 do
val cmd = createMoveCommand(
sq(File.E, Rank.R2),
sq(File.E, Rank.R4)
)
invoker.execute(cmd)
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 2: reads history during execution
val readerThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 1000 do
val _ = invoker.history
val _ = invoker.getCurrentIndex
Thread.sleep(0) // Yield to increase contention
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
executorThread.start()
readerThread.start()
executorThread.join()
readerThread.join()
exceptions.isEmpty shouldBe true
raceDetected shouldBe false
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
val invoker = new CommandInvoker()
@volatile var raceDetected = false
val exceptions = mutable.ListBuffer[Exception]()
// Pre-populate with some commands
for _ <- 1 to 5 do
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
// Thread 1: executes new commands
val executorThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 2: undoes commands
val undoThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
if invoker.canUndo then
invoker.undo()
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 3: redoes commands
val redoThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
if invoker.canRedo then
invoker.redo()
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
executorThread.start()
undoThread.start()
redoThread.start()
executorThread.join()
undoThread.join()
redoThread.join()
exceptions.isEmpty shouldBe true
raceDetected shouldBe false
@@ -1,52 +1,24 @@
package de.nowchess.chess.command package de.nowchess.chess.command
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.game.GameContext
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers: class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand can be created"): test("QuitCommand properties and behavior"):
val cmd = QuitCommand() val cmd = QuitCommand()
cmd shouldNot be(null) cmd shouldNot be(null)
test("QuitCommand execute returns true"):
val cmd = QuitCommand()
cmd.execute() shouldBe true cmd.execute() shouldBe true
test("QuitCommand undo returns false (cannot undo quit)"):
val cmd = QuitCommand()
cmd.undo() shouldBe false cmd.undo() shouldBe false
test("QuitCommand description"):
val cmd = QuitCommand()
cmd.description shouldBe "Quit game" cmd.description shouldBe "Quit game"
test("ResetCommand with no prior state"): test("ResetCommand behavior depends on previousContext"):
val cmd = ResetCommand() val noState = ResetCommand()
cmd.execute() shouldBe true noState.execute() shouldBe true
cmd.undo() shouldBe false noState.undo() shouldBe false
noState.description shouldBe "Reset board"
test("ResetCommand with prior state can undo"):
val cmd = ResetCommand(
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd.execute() shouldBe true
cmd.undo() shouldBe true
test("ResetCommand with partial state cannot undo"):
val cmd = ResetCommand(
previousBoard = Some(Board.initial),
previousHistory = None, // missing
previousTurn = Some(Color.White)
)
cmd.execute() shouldBe true
cmd.undo() shouldBe false
test("ResetCommand description"):
val cmd = ResetCommand()
cmd.description shouldBe "Reset board"
val withState = ResetCommand(previousContext = Some(GameContext.initial))
withState.execute() shouldBe true
withState.undo() shouldBe true
@@ -1,65 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
// Create second command with filled state
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
// Original should be unchanged
cmd1.moveResult shouldBe None
cmd1.previousBoard shouldBe None
cmd1.previousHistory shouldBe None
cmd1.previousTurn shouldBe None
// New should have values
cmd2.moveResult shouldBe Some(result)
cmd2.previousBoard shouldBe Some(Board.initial)
cmd2.previousHistory shouldBe Some(GameHistory.empty)
cmd2.previousTurn shouldBe Some(Color.White)
test("MoveCommand equals and hashCode respect immutability"):
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousBoard = None,
previousHistory = None,
previousTurn = None
)
val cmd2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousBoard = None,
previousHistory = None,
previousTurn = None
)
// Same values should be equal
cmd1 shouldBe cmd2
cmd1.hashCode shouldBe cmd2.hashCode
// Hash should be consistent (required for use as map keys)
val hash1 = cmd1.hashCode
val hash2 = cmd1.hashCode
hash1 shouldBe hash2
@@ -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,526 +0,0 @@
package de.nowchess.chess.controller
import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory}
import de.nowchess.chess.notation.FenParser
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
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 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: move that leaves own king in check returns IllegalMove"):
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
// geometrically legal but does not resolve the check — must be rejected.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.D, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
test("processMove: move that resolves check is allowed"):
// White King E1 is in check from Black Rook E8 along the E-file.
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a5e5") match
case _: MoveResult.Moved => succeed
case other => fail(s"Expected Moved, got $other")
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")
// ──── 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")
// ──── 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")
// ──── en passant ────────────────────────────────────────────────────────
test("en passant capture removes the captured pawn from the board"):
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
val b = Board(Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
val result = GameController.processMove(b, h, Color.White, "e5d6")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
captured shouldBe Some(Piece.BlackPawn)
case other => fail(s"Expected Moved but got $other")
test("en passant capture by black removes the captured white pawn"):
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
val b = Board(Map(
Square(File.D, Rank.R4) -> Piece.BlackPawn,
Square(File.E, Rank.R4) -> Piece.WhitePawn,
Square(File.E, Rank.R8) -> Piece.BlackKing,
Square(File.E, Rank.R1) -> Piece.WhiteKing
))
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val result = GameController.processMove(b, h, Color.Black, "d4e3")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other")
// ──── pawn promotion detection ───────────────────────────────────────────
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
from should be (sq(File.E, Rank.R7))
to should be (sq(File.E, Rank.R8))
turn should be (Color.White)
case _ => fail("Expected PromotionRequired")
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
from should be (sq(File.E, Rank.R2))
to should be (sq(File.E, Rank.R1))
turn should be (Color.Black)
case _ => fail("Expected PromotionRequired")
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
case _ => fail("Expected PromotionRequired")
// ──── completePromotion ──────────────────────────────────────────────────
test("completePromotion applies move and places queen"):
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case _: MoveResult.Moved => }
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
newHistory.moves should have length 1
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
case _ => fail("Expected Moved")
test("completePromotion with rook underpromotion"):
// Black king on h1: not attacked by rook on e8 (different file and rank)
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Rook, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
case _ => fail("Expected Moved with Rook")
test("completePromotion with bishop underpromotion"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Bishop, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
case _ => fail("Expected Moved with Bishop")
test("completePromotion with knight underpromotion"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Knight, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
case _ => fail("Expected Moved with Knight")
test("completePromotion captures opponent piece"):
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
PromotionPiece.Queen, Color.White
)
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
case _ => fail("Expected Moved with captured piece")
test("completePromotion for black pawn to R1"):
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
PromotionPiece.Knight, Color.Black
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
case _ => fail("Expected Moved")
test("completePromotion evaluates check after promotion"):
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case _: MoveResult.MovedInCheck => }
test("completePromotion full round-trip via processMove then completePromotion"):
// Black king on h1: not attacked by queen on e8
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
result should matchPattern { case _: MoveResult.Moved => }
result match
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
case _ => fail("Expected Moved")
case _ => fail("Expected PromotionRequired")
test("completePromotion results in checkmate when promotion delivers checkmate"):
// Black king a8, white pawn h7, white king b6.
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case MoveResult.Checkmate(_) => }
result match
case MoveResult.Checkmate(winner) => winner should be (Color.White)
case _ => fail("Expected Checkmate")
test("completePromotion results in stalemate when promotion stalemates opponent"):
// Black king a8, white pawn b7, white bishop c7, white king b6.
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
PromotionPiece.Knight, Color.White
)
result should be (MoveResult.Stalemate)
// ──── half-move clock propagation ────────────────────────────────────
test("processMove: non-pawn non-capture increments halfMoveClock"):
// g1f3 is a knight move — not a pawn, not a capture
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 1
case other => fail(s"Expected Moved, got $other")
test("processMove: pawn move resets halfMoveClock to 0"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
// White pawn on e5, Black pawn on d6 — exd6 is a capture
val board = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val history = GameHistory(halfMoveClock = 10)
processMove(board, history, Color.White, "e5d6") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: clock carries from previous history on non-pawn non-capture"):
val history = GameHistory(halfMoveClock = 5)
processMove(Board.initial, history, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 6
case other => fail(s"Expected Moved, 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()
@@ -1,214 +0,0 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine edge cases and uncovered paths */
class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
test("GameEngine handles empty input"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Please enter a valid move or command")
test("GameEngine processes quit command"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("quit")
// Quit just returns, no events
observer.events.isEmpty shouldBe true
test("GameEngine processes q command (short form)"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("q")
observer.events.isEmpty shouldBe true
test("GameEngine handles uppercase quit"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("QUIT")
observer.events.isEmpty shouldBe true
test("GameEngine handles undo on empty history"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.canUndo shouldBe false
engine.processUserInput("undo")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Nothing to undo")
test("GameEngine handles redo on empty redo history"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.canRedo shouldBe false
engine.processUserInput("redo")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Nothing to redo")
test("GameEngine parses invalid move format"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("invalid_move_format")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Invalid move format")
test("GameEngine handles lowercase input normalization"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput(" UNDO ") // With spaces and uppercase
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent] // No moves to undo yet
test("GameEngine preserves board state on invalid move"):
val engine = new GameEngine()
val initialBoard = engine.board
engine.processUserInput("invalid")
engine.board shouldBe initialBoard
test("GameEngine preserves turn on invalid move"):
val engine = new GameEngine()
val initialTurn = engine.turn
engine.processUserInput("invalid")
engine.turn shouldBe initialTurn
test("GameEngine undo with no commands available"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Make a valid move
engine.processUserInput("e2e4")
observer.events.clear()
// Undo it
engine.processUserInput("undo")
// Board should be reset
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine redo after undo"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
val turnAfterMove = engine.turn
observer.events.clear()
engine.processUserInput("undo")
engine.processUserInput("redo")
engine.board shouldBe boardAfterMove
engine.turn shouldBe turnAfterMove
test("GameEngine canUndo flag tracks state correctly"):
val engine = new GameEngine()
engine.canUndo shouldBe false
engine.processUserInput("e2e4")
engine.canUndo shouldBe true
engine.processUserInput("undo")
engine.canUndo shouldBe false
test("GameEngine canRedo flag tracks state correctly"):
val engine = new GameEngine()
engine.canRedo shouldBe false
engine.processUserInput("e2e4")
engine.canRedo shouldBe false
engine.processUserInput("undo")
engine.canRedo shouldBe true
test("GameEngine command history is accessible"):
val engine = new GameEngine()
engine.commandHistory.isEmpty shouldBe true
engine.processUserInput("e2e4")
engine.commandHistory.size shouldBe 1
test("GameEngine processes multiple moves in sequence"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
observer.events.clear()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
observer.events.size shouldBe 2
engine.commandHistory.size shouldBe 2
test("GameEngine can undo multiple moves"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("undo")
engine.turn shouldBe Color.Black
engine.processUserInput("undo")
engine.turn shouldBe Color.White
test("GameEngine thread-safe operations"):
val engine = new GameEngine()
// Access from synchronized methods
val board = engine.board
val history = engine.history
val turn = engine.turn
val canUndo = engine.canUndo
val canRedo = engine.canRedo
board shouldBe Board.initial
canUndo shouldBe false
canRedo shouldBe false
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -2,7 +2,6 @@ package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent} import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -22,12 +21,11 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
observer.events.clear() observer.events.clear()
engine.processUserInput("d8h4") engine.processUserInput("d8h4")
// Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent]
// Verify CheckmateEvent val event = observer.events.last.asInstanceOf[CheckmateEvent]
observer.events.size shouldBe 1
observer.events.head shouldBe a[CheckmateEvent]
val event = observer.events.head.asInstanceOf[CheckmateEvent]
event.winner shouldBe Color.Black event.winner shouldBe Color.Black
// Board should be reset after checkmate // Board should be reset after checkmate
@@ -50,7 +48,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e } val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
checkEvents.size shouldBe 1 checkEvents.size shouldBe 1
checkEvents.head.turn shouldBe Color.Black // Black is now in check checkEvents.head.context.turn shouldBe Color.Black // Black is now in check
// Shortest known stalemate is 19 moves. Here is a faster one: // 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 // e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
@@ -1,110 +0,0 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests to maximize handleFailedMove coverage */
class GameEngineHandleFailedMoveTest extends AnyFunSuite with Matchers:
test("GameEngine handles InvalidFormat error type"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("not_a_valid_move_format")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val msg1 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
msg1 should include("Invalid move format")
test("GameEngine handles NoPiece error type"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("h3h4")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val msg2 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
msg2 should include("No piece on that square")
test("GameEngine handles WrongColor error type"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4") // White move
observer.events.clear()
engine.processUserInput("a1b2") // Try to move black's rook position with white's move (wrong color)
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val msg3 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
msg3 should include("That is not your piece")
test("GameEngine handles IllegalMove error type"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e1") // Try pawn backward
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val msg4 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
msg4 should include("Illegal move")
test("GameEngine invalid move message for InvalidFormat"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("xyz123")
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("coordinate notation")
test("GameEngine invalid move message for NoPiece"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("a3a4") // a3 is empty
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("No piece")
test("GameEngine invalid move message for WrongColor"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.events.clear()
engine.processUserInput("e4e5") // e4 has white pawn, it's black's turn
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("not your piece")
test("GameEngine invalid move message for IllegalMove"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e1") // Pawn can't move backward
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Illegal move")
test("GameEngine board unchanged after each type of invalid move"):
val engine = new GameEngine()
val initial = engine.board
engine.processUserInput("invalid")
engine.board shouldBe initial
engine.processUserInput("h3h4")
engine.board shouldBe initial
engine.processUserInput("e2e1")
engine.board shouldBe initial
@@ -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
@@ -1,114 +0,0 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine invalid move handling via handleFailedMove */
class GameEngineInvalidMovesTest extends AnyFunSuite with Matchers:
test("GameEngine handles no piece at source square"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Try to move from h1 which may be empty or not have our piece
// We'll try from a clearly empty square
engine.processUserInput("h1h2")
// Should get an InvalidMoveEvent about NoPiece
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
test("GameEngine handles moving wrong color piece"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// White moves first
engine.processUserInput("e2e4")
observer.events.clear()
// White tries to move again (should fail - it's black's turn)
// But we need to try a move that looks legal but has wrong color
// This is hard to test because we'd need to be black and move white's piece
// Let's skip this for now and focus on testable cases
// Actually, let's try moving a square that definitely has the wrong piece
// Move a white pawn as black by reaching that position
engine.processUserInput("e7e5")
observer.events.clear()
// Now try to move white's e4 pawn as black (it's black's turn but e4 is white)
engine.processUserInput("e4e5")
observer.events.size shouldBe 1
val event = observer.events.head
event shouldBe an[InvalidMoveEvent]
test("GameEngine handles illegal move"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// A pawn can't move backward
engine.processUserInput("e2e1")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Illegal move")
test("GameEngine handles pawn trying to move 3 squares"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Pawn can only move 1 or 2 squares on first move, not 3
engine.processUserInput("e2e5")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
test("GameEngine handles moving from empty square"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// h3 is empty in starting position
engine.processUserInput("h3h4")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("No piece on that square")
test("GameEngine processes valid move after invalid attempt"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Try invalid move
engine.processUserInput("h3h4")
observer.events.clear()
// Make valid move
engine.processUserInput("e2e4")
observer.events.size shouldBe 1
observer.events.head shouldBe an[MoveExecutedEvent]
test("GameEngine maintains state after failed move attempt"):
val engine = new GameEngine()
val initialTurn = engine.turn
val initialBoard = engine.board
// Try invalid move
engine.processUserInput("h3h4")
// State should not change
engine.turn shouldBe initialTurn
engine.board shouldBe initialBoard
@@ -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")
@@ -0,0 +1,176 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Checkmate ───────────────────────────────────────────────────
test("checkmate ends game with CheckmateEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.clear()
engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("f1c4")
engine.processUserInput("b8c6")
engine.processUserInput("d1h5")
engine.processUserInput("g8f6")
observer.clear()
engine.processUserInput("h5f7")
val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White
// ── Stalemate ───────────────────────────────────────────────────
test("stalemate ends game with StalemateEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
val moves = List(
"e2e3", "a7a5",
"d1h5", "a8a6",
"h5a5", "h7h5",
"h2h4", "a6h6",
"a5c7", "f7f6",
"c7d7", "e8f7",
"d7b7", "d8d3",
"b7b8", "d3h7",
"b8c8", "f7g6"
)
moves.foreach(engine.processUserInput)
observer.clear()
engine.processUserInput("c8e6")
observer.hasEvent[StalemateEvent] shouldBe true
test("stalemate when king has no moves and no pieces"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
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.foreach(engine.processUserInput)
observer.hasEvent[StalemateEvent] shouldBe true
engine.turn shouldBe Color.White
// ── Check detection ────────────────────────────────────────────
test("check detected after move puts king in check"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("f1c4")
engine.processUserInput("g8f6")
observer.clear()
engine.processUserInput("c4f7")
observer.hasEvent[CheckDetectedEvent] shouldBe true
test("check by knight"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "8/4k3/8/8/3N4/8/8/4K3 w - - 0 1")
observer.clear()
engine.processUserInput("d4f5")
observer.hasEvent[CheckDetectedEvent] shouldBe true
// ── Fifty-move rule ────────────────────────────────────────────
test("fifty-move rule triggers when half-move clock reaches 100"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 99 1")
observer.clear()
engine.processUserInput("g1f3")
observer.hasEvent[FiftyMoveRuleAvailableEvent] shouldBe true
test("fifty-move rule clock resets on pawn move"):
val engine = EngineTestHelpers.makeEngine()
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 50 1")
engine.processUserInput("a2a3")
// Clock should reset to 0 after pawn move
engine.context.halfMoveClock shouldBe 0
test("fifty-move rule clock resets on capture"):
val engine = EngineTestHelpers.makeEngine()
// FEN: white pawn on e5, black pawn on d6, clock at 50
EngineTestHelpers.loadFen(engine, "4k3/8/3p4/4P3/8/8/8/4K3 w - - 50 1")
engine.processUserInput("e5d6")
// Clock should reset to 0 after capture
engine.context.halfMoveClock shouldBe 0
// ── Draw claim ────────────────────────────────────────────────
test("draw can be claimed when fifty-move rule is available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 100 1")
observer.clear()
engine.processUserInput("draw")
observer.hasEvent[DrawClaimedEvent] shouldBe true
test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true

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