Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e727a7dc | |||
| 57a5185212 | |||
| 249a67b883 | |||
| bf0b9862fc | |||
| 1811f74aa7 | |||
| a9a4cdf590 | |||
| 395318909b | |||
| 23a5c763c5 | |||
| 14e88470de |
+1
-5
@@ -1,6 +1,5 @@
|
|||||||
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"
|
||||||
@@ -29,10 +28,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
#! /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
|
|
||||||
@@ -19,9 +19,6 @@ 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
|
||||||
@@ -29,8 +26,7 @@ import sys
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import glob
|
from pathlib import Path, PureWindowsPath
|
||||||
from pathlib import Path
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -116,6 +112,7 @@ 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:
|
||||||
@@ -123,7 +120,10 @@ 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():
|
||||||
if any(s.is_uncovered for s in stmts):
|
has_covered = any(s.is_covered for s in stmts)
|
||||||
|
has_uncovered = any(s.is_uncovered for s in stmts)
|
||||||
|
# Report line if any branch arm is uncovered
|
||||||
|
if has_uncovered:
|
||||||
partial.append(line)
|
partial.append(line)
|
||||||
return sorted(partial)
|
return sorted(partial)
|
||||||
|
|
||||||
@@ -169,10 +169,20 @@ 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]
|
||||||
|
|
||||||
|
|
||||||
@@ -180,10 +190,11 @@ def _normalise_source(raw: str) -> str:
|
|||||||
# Parser
|
# Parser
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def parse_scoverage_xml(xml_path: str) -> tuple[dict, list[ClassGap]]:
|
def parse_scoverage_xml(xml_path: str) -> 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)),
|
||||||
@@ -191,16 +202,17 @@ def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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] = {}
|
class_map: dict[str, ClassGap] = {} # full-class-name → 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))
|
||||||
@@ -209,8 +221,11 @@ def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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", "")
|
||||||
|
|
||||||
m_total = int(method_elem.get("statement-count", 0))
|
# Authoritative per-method totals from <method> attributes
|
||||||
m_invoked = int(method_elem.get("statements-invoked", 0))
|
m_total = int(method_elem.get("statement-count", 0))
|
||||||
|
m_invoked = int(method_elem.get("statements-invoked", 0))
|
||||||
|
m_stmt_rate = float(method_elem.get("statement-rate", 0.0))
|
||||||
|
m_br_rate = float(method_elem.get("branch-rate", 0.0))
|
||||||
|
|
||||||
for stmt_elem in method_elem.findall("statements/statement"):
|
for stmt_elem in method_elem.findall("statements/statement"):
|
||||||
raw_source = stmt_elem.get("source", filename)
|
raw_source = stmt_elem.get("source", filename)
|
||||||
@@ -242,6 +257,7 @@ def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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,
|
||||||
@@ -252,6 +268,7 @@ def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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(
|
||||||
@@ -265,6 +282,7 @@ def parse_scoverage_xml(xml_path: str) -> tuple[dict, 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]
|
||||||
|
|
||||||
|
|
||||||
@@ -292,60 +310,103 @@ def _compact_ranges(numbers: list[int]) -> str:
|
|||||||
# Formatters
|
# Formatters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
def _pct_bar(pct: float, width: int = 20) -> str:
|
||||||
"""
|
"""Render a compact ASCII progress bar, e.g. [████░░░░░░░░░░░░░░░░] 23.5%"""
|
||||||
Compact agent format — optimised for low token count.
|
filled = round(pct / 100 * width)
|
||||||
Emits only actionable gaps: file path, uncovered lines, branch-gap lines,
|
bar = "█" * filled + "░" * (width - filled)
|
||||||
and a per-method breakdown. No ASCII bars, no redundant tables.
|
return f"[{bar}] {pct:.1f}%"
|
||||||
"""
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
total_stmts = project_stats["total_statements"]
|
|
||||||
covered_stmts = project_stats["covered_statements"]
|
def format_agent(project_stats: dict, classes: list[ClassGap]) -> str:
|
||||||
missed_stmts = project_stats["missed_statements"]
|
lines: list[str] = []
|
||||||
|
lines.append("# scoverage Coverage Gaps — Agent Action Report")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ---- Project-level totals (authoritative from <scoverage> root element) ----
|
||||||
|
total_stmts = project_stats["total_statements"]
|
||||||
|
covered_stmts = project_stats["covered_statements"]
|
||||||
|
missed_stmts = project_stats["missed_statements"]
|
||||||
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
overall_stmt_pct = project_stats["stmt_coverage_pct"]
|
||||||
overall_branch_pct = project_stats["branch_coverage_pct"]
|
overall_branch_pct = project_stats["branch_coverage_pct"]
|
||||||
total_branches = sum(c.total_branches for c in classes)
|
total_branch_lines = sum(len(c.uncovered_branch_lines) for c in classes)
|
||||||
covered_branches = sum(c.covered_branches for c in classes)
|
# Branch totals: count from statement data (scoverage root has no branch count attr)
|
||||||
missed_branches = total_branches - covered_branches
|
total_branches = sum(c.total_branches for c in classes)
|
||||||
|
covered_branches = sum(c.covered_branches for c in classes)
|
||||||
|
missed_branches = sum(c.missed_branches for c in classes)
|
||||||
|
|
||||||
lines.append("# scoverage Coverage Gaps")
|
lines.append("## Project Coverage Summary")
|
||||||
lines.append(
|
lines.append("")
|
||||||
f"stmt: {overall_stmt_pct:.1f}% ({missed_stmts}/{total_stmts} missed) | "
|
lines.append(f"| Metric | Covered | Total | Missed | Coverage |")
|
||||||
f"branches: {overall_branch_pct:.1f}% ({missed_branches}/{total_branches} missed) | "
|
lines.append(f"|-------------------|---------|-------|--------|----------|")
|
||||||
f"files with gaps: {len(classes)}"
|
lines.append(f"| Statements | {covered_stmts:>7} | {total_stmts:>5} | {missed_stmts:>6} | {_pct_bar(overall_stmt_pct)} |")
|
||||||
)
|
lines.append(f"| Branch paths | {covered_branches:>7} | {total_branches:>5} | {missed_branches:>6} | {_pct_bar(overall_branch_pct)} |")
|
||||||
|
lines.append(f"| Files with gaps | {'—':>7} | {len(classes):>5} | {'—':>6} | {'—'} |")
|
||||||
|
lines.append(f"| Lines w/ br. gaps | {'—':>7} | {total_branch_lines:>5} | {'—':>6} | {'—'} |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Files Requiring Tests")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("> Each entry lists the SOURCE FILE PATH, uncovered LINE NUMBERS,")
|
||||||
|
lines.append("> and the METHODS that contain those gaps.")
|
||||||
|
lines.append("> Write or extend unit/integration tests to exercise these paths.")
|
||||||
lines.append("")
|
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:
|
||||||
uncov = cls.all_uncovered_lines
|
lines.append(f"### `{cls.source_path}`")
|
||||||
branch_lines = cls.uncovered_branch_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("")
|
||||||
|
|
||||||
lines.append(f"## {cls.source_path}")
|
uncov = cls.all_uncovered_lines
|
||||||
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:
|
if uncov:
|
||||||
lines.append(f"uncovered lines: {_compact_ranges(uncov)}")
|
lines.append("#### ❌ Uncovered Statements")
|
||||||
only_branch = [l for l in branch_lines if l not in cls.all_uncovered_lines]
|
lines.append(f"Lines never executed: `{_compact_ranges(uncov)}`")
|
||||||
if only_branch:
|
lines.append("")
|
||||||
lines.append(f"partial branches: {_compact_ranges(only_branch)}")
|
|
||||||
|
branch_lines = cls.uncovered_branch_lines
|
||||||
|
if branch_lines:
|
||||||
|
lines.append("#### ⚠️ Missing Branch Coverage (Conditional Paths)")
|
||||||
|
lines.append(f"Lines where not all conditional paths are taken: `{_compact_ranges(branch_lines)}`")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
if cls.method_gaps:
|
if cls.method_gaps:
|
||||||
lines.append("methods:")
|
lines.append("#### Methods with Gaps")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Method | Stmt Coverage | Branch Coverage | Uncovered Lines | Branch Gap Lines |")
|
||||||
|
lines.append("|--------|--------------|-----------------|-----------------|------------------|")
|
||||||
for mg in cls.method_gaps:
|
for mg in cls.method_gaps:
|
||||||
parts = [f" {mg.short_name}"]
|
stmt_cell = f"{_pct_bar(mg.stmt_coverage_pct, 10)} ({mg.total_statements - mg.covered_statements}/{mg.total_statements} missed)"
|
||||||
if mg.uncovered_lines:
|
branch_cell = f"{_pct_bar(mg.branch_coverage_pct, 10)} ({mg.missed_branches}/{mg.total_branches} missed)" if mg.total_branches else "n/a"
|
||||||
parts.append(f"lines={_compact_ranges(mg.uncovered_lines)}")
|
uncov_cell = f"`{_compact_ranges(mg.uncovered_lines)}`" if mg.uncovered_lines else "—"
|
||||||
if mg.uncovered_branch_lines:
|
br_cell = f"`{_compact_ranges(mg.uncovered_branch_lines)}`" if mg.uncovered_branch_lines else "—"
|
||||||
parts.append(f"branches={_compact_ranges(mg.uncovered_branch_lines)}")
|
lines.append(f"| `{mg.short_name}` | {stmt_cell} | {branch_cell} | {uncov_cell} | {br_cell} |")
|
||||||
lines.append(" ".join(parts))
|
lines.append("")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -450,87 +511,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -539,13 +519,7 @@ 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"],
|
||||||
@@ -563,30 +537,8 @@ 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)
|
||||||
@@ -613,4 +565,4 @@ def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -5,9 +5,3 @@
|
|||||||
## (2026-03-31)
|
## (2026-03-31)
|
||||||
## (2026-04-01)
|
## (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))
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("skipped", "failed")
|
events("passed", "skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=1
|
MINOR=0
|
||||||
PATCH=0
|
PATCH=7
|
||||||
|
|||||||
@@ -120,48 +120,3 @@
|
|||||||
* 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-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))
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("skipped", "failed")
|
events("passed", "skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.nowchess.chess.engine
|
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, File, Piece, PieceType, Rank, Square}
|
||||||
import de.nowchess.chess.logic.GameHistory
|
import de.nowchess.chess.logic.GameHistory
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
@@ -86,6 +86,28 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
// state is reset to initial (reset happens before replay, which fails)
|
// state is reset to initial (reset happens before replay, which fails)
|
||||||
engine.history.moves shouldBe empty
|
engine.history.moves shouldBe empty
|
||||||
|
|
||||||
|
test("loadPgn: promotion in PGN is replayed correctly"):
|
||||||
|
val engine = new GameEngine()
|
||||||
|
val pgn =
|
||||||
|
"""[Event "T"]
|
||||||
|
|
||||||
|
1. b4 h6 2. b5 h5 3. b6 h4 4. bxa7 h3 5. a8=Q
|
||||||
|
"""
|
||||||
|
engine.loadPgn(pgn) shouldBe Right(())
|
||||||
|
engine.history.moves.length shouldBe 9
|
||||||
|
|
||||||
|
test("loadPgn: en passant capture in PGN is replayed correctly"):
|
||||||
|
val engine = new GameEngine()
|
||||||
|
val pgn =
|
||||||
|
"""[Event "T"]
|
||||||
|
|
||||||
|
1. e4 e6 2. e5 d5 3. exd6
|
||||||
|
"""
|
||||||
|
engine.loadPgn(pgn) shouldBe Right(())
|
||||||
|
engine.history.moves.length shouldBe 5
|
||||||
|
engine.board.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece(Color.White, PieceType.Pawn))
|
||||||
|
engine.board.pieceAt(Square(File.D, Rank.R5)) shouldBe None
|
||||||
|
|
||||||
// ── undo/redo notation events ─────────────────────────────────────────────
|
// ── undo/redo notation events ─────────────────────────────────────────────
|
||||||
|
|
||||||
test("undo emits MoveUndoneEvent with pgnNotation"):
|
test("undo emits MoveUndoneEvent with pgnNotation"):
|
||||||
@@ -140,18 +162,16 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
|
|||||||
val engine = new GameEngine()
|
val engine = new GameEngine()
|
||||||
val cap = new EventCapture()
|
val cap = new EventCapture()
|
||||||
engine.subscribe(cap)
|
engine.subscribe(cap)
|
||||||
// White builds a capture on the a-file: b4, ... a6, b5, ... h6, bxa6
|
// e4, then Black plays d5, White pawn on e4 captures on d5
|
||||||
engine.processUserInput("b2b4")
|
engine.processUserInput("e2e4")
|
||||||
engine.processUserInput("a7a6")
|
engine.processUserInput("d7d5")
|
||||||
engine.processUserInput("b4b5")
|
engine.processUserInput("e4d5") // white pawn captures black pawn
|
||||||
engine.processUserInput("h7h6")
|
|
||||||
engine.processUserInput("b5a6") // white pawn captures black pawn
|
|
||||||
engine.undo()
|
engine.undo()
|
||||||
cap.events.clear()
|
cap.events.clear()
|
||||||
engine.redo()
|
engine.redo()
|
||||||
val evt = cap.events.last.asInstanceOf[MoveRedoneEvent]
|
val evt = cap.events.last.asInstanceOf[MoveRedoneEvent]
|
||||||
evt.fromSquare shouldBe "b5"
|
evt.fromSquare shouldBe "e4"
|
||||||
evt.toSquare shouldBe "a6"
|
evt.toSquare shouldBe "d5"
|
||||||
evt.capturedPiece.isDefined shouldBe true
|
evt.capturedPiece.isDefined shouldBe true
|
||||||
|
|
||||||
test("loadPgn: clears previous game state before loading"):
|
test("loadPgn: clears previous game state before loading"):
|
||||||
|
|||||||
@@ -96,6 +96,32 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
|
|||||||
game.moves.last.castleSide shouldBe Some(CastleSide.Queenside)
|
game.moves.last.castleSide shouldBe Some(CastleSide.Queenside)
|
||||||
case Left(err) => fail(s"Expected Right but got Left($err)")
|
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||||
|
|
||||||
|
test("validatePgn: valid promotion is accepted"):
|
||||||
|
val pgn =
|
||||||
|
"""[Event "Test"]
|
||||||
|
|
||||||
|
1. b4 h6 2. b5 h5 3. b6 h4 4. bxa7 h3 5. a8=Q
|
||||||
|
"""
|
||||||
|
PgnParser.validatePgn(pgn) match
|
||||||
|
case Right(game) =>
|
||||||
|
game.moves.takeWhile(m => m.promotionPiece.isEmpty).length shouldBe 8
|
||||||
|
game.moves.last.promotionPiece shouldBe Some(PromotionPiece.Queen)
|
||||||
|
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||||
|
|
||||||
|
test("validatePgn: en passant capture is parsed correctly"):
|
||||||
|
val pgn =
|
||||||
|
"""[Event "Test"]
|
||||||
|
|
||||||
|
1. e4 e6 2. e5 d5 3. exd6
|
||||||
|
"""
|
||||||
|
PgnParser.validatePgn(pgn) match
|
||||||
|
case Right(game) =>
|
||||||
|
game.moves.length shouldBe 5
|
||||||
|
val epMove = game.moves.last
|
||||||
|
epMove.isCapture shouldBe true
|
||||||
|
epMove.pieceType shouldBe PieceType.Pawn
|
||||||
|
case Left(err) => fail(s"Expected Right but got Left($err)")
|
||||||
|
|
||||||
test("validatePgn: disambiguation with two rooks is accepted"):
|
test("validatePgn: disambiguation with two rooks is accepted"):
|
||||||
val pieces: Map[Square, Piece] = Map(
|
val pieces: Map[Square, Piece] = Map(
|
||||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=9
|
MINOR=7
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -10,18 +10,3 @@
|
|||||||
|
|
||||||
* 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-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
* NCS-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))
|
||||||
## (2026-04-01)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
|
||||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
|
||||||
* NCS-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))
|
|
||||||
## (2026-04-02)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
|
||||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
|
||||||
* NCS-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))
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("scala")
|
id("scala")
|
||||||
id("org.scoverage")
|
id("org.scoverage") version "8.1"
|
||||||
application
|
application
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +55,10 @@ dependencies {
|
|||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
|
|
||||||
// ScalaFX dependencies
|
// ScalaFX dependencies
|
||||||
implementation("org.scalafx:scalafx_3:${versions["SCALAFX"]!!}")
|
implementation("org.scalafx:scalafx_3:21.0.0-R32")
|
||||||
|
|
||||||
// JavaFX dependencies for the current platform
|
// JavaFX dependencies for the current platform
|
||||||
val javaFXVersion = versions["JAVAFX"]!!
|
val javaFXVersion = "21.0.1"
|
||||||
val osName = System.getProperty("os.name").lowercase()
|
val osName = System.getProperty("os.name").lowercase()
|
||||||
val platform = when {
|
val platform = when {
|
||||||
osName.contains("win") -> "win"
|
osName.contains("win") -> "win"
|
||||||
@@ -71,7 +71,7 @@ dependencies {
|
|||||||
implementation("org.openjfx:javafx-$module:$javaFXVersion:$platform")
|
implementation("org.openjfx:javafx-$module:$javaFXVersion:$platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
@@ -83,7 +83,7 @@ tasks.test {
|
|||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeEngines("scalatest")
|
includeEngines("scalatest")
|
||||||
testLogging {
|
testLogging {
|
||||||
events("skipped", "failed")
|
events("passed", "skipped", "failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finalizedBy(tasks.reportScoverage)
|
finalizedBy(tasks.reportScoverage)
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ import de.nowchess.chess.notation.{FenExporter, FenParser, PgnExporter, PgnParse
|
|||||||
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
|
||||||
|
|
||||||
private val squareSize = 70.0
|
private val squareSize = 70.0
|
||||||
private val comicSansFontFamily = "Comic Sans MS"
|
|
||||||
private val boardGrid = new GridPane()
|
private val boardGrid = new GridPane()
|
||||||
private val messageLabel = new Label {
|
private val messageLabel = new Label {
|
||||||
text = "Welcome!"
|
text = "Welcome!"
|
||||||
font = Font.font(comicSansFontFamily, 16)
|
font = Font.font("Comic Sans MS", 16)
|
||||||
padding = Insets(10)
|
padding = Insets(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
children = Seq(
|
children = Seq(
|
||||||
new Label {
|
new Label {
|
||||||
text = "Chess"
|
text = "Chess"
|
||||||
font = Font.font(comicSansFontFamily, 24)
|
font = Font.font("Comic Sans MS", 24)
|
||||||
style = "-fx-font-weight: bold;"
|
style = "-fx-font-weight: bold;"
|
||||||
},
|
},
|
||||||
messageLabel
|
messageLabel
|
||||||
@@ -70,17 +69,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
alignment = Pos.Center
|
alignment = Pos.Center
|
||||||
children = Seq(
|
children = Seq(
|
||||||
new Button("Undo") {
|
new Button("Undo") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => if engine.canUndo then engine.undo()
|
onAction = _ => if engine.canUndo then engine.undo()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9DAD1;"
|
||||||
},
|
},
|
||||||
new Button("Redo") {
|
new Button("Redo") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => if engine.canRedo then engine.redo()
|
onAction = _ => if engine.canRedo then engine.redo()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9C2DA;"
|
||||||
},
|
},
|
||||||
new Button("Reset") {
|
new Button("Reset") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => engine.reset()
|
onAction = _ => engine.reset()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||||
}
|
}
|
||||||
@@ -91,22 +90,22 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
|||||||
alignment = Pos.Center
|
alignment = Pos.Center
|
||||||
children = Seq(
|
children = Seq(
|
||||||
new Button("FEN Export") {
|
new Button("FEN Export") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => doFenExport()
|
onAction = _ => doFenExport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #DAC4B9;"
|
||||||
},
|
},
|
||||||
new Button("FEN Import") {
|
new Button("FEN Import") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => doFenImport()
|
onAction = _ => doFenImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #DAD4B9;"
|
||||||
},
|
},
|
||||||
new Button("PGN Export") {
|
new Button("PGN Export") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => doPgnExport()
|
onAction = _ => doPgnExport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;"
|
style = "-fx-background-radius: 8; -fx-background-color: #C4DAB9;"
|
||||||
},
|
},
|
||||||
new Button("PGN Import") {
|
new Button("PGN Import") {
|
||||||
font = Font.font(comicSansFontFamily, 12)
|
font = Font.font("Comic Sans MS", 12)
|
||||||
onAction = _ => doPgnImport()
|
onAction = _ => doPgnImport()
|
||||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=4
|
MINOR=2
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
Reference in New Issue
Block a user