Skip to content
Draft
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ __pycache__/
.venv/
venv/

# Test / coverage artefacts
.coverage
.coverage.*
htmlcov/
.pytest_cache/

# PyInstaller build output (regenerated by build_exe.bat)
build/
dist/
Expand Down
7 changes: 4 additions & 3 deletions outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,19 +865,20 @@ def markdown_to_html(
figcaption { font-size: 0.85rem; color: #6b5942; font-style: italic; margin-top: 0.3rem; }
""" + viewer_css

html = f"""<!doctype html>
page_title = html.escape(md_path.stem)
html_doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{html.escape(md_path.stem)}</title>
<title>{page_title}</title>
<style>{css}</style>
</head>
<body>
{html_body}
</body>
</html>
"""
html_path.write_text(html, encoding="utf-8")
html_path.write_text(html_doc, encoding="utf-8")
return html_path


Expand Down
89 changes: 89 additions & 0 deletions tests/test_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@
Covers the engine-free helpers and the geometry fixes from the v0.9 sweep:
numbered-SAN variation rendering (2A), the pawn-fork legality filter, the
pinned-piece fork suppression, and the royal-alignment path-clear check.
Also covers normalize_cp, detect_phase, and score_to_cp_mate.
"""
import chess
import chess.engine

from analyzer import (
MATE_SCORE,
classify_move,
detect_allowed_pawn_fork,
detect_double_attack,
detect_royal_alignment,
detect_phase,
normalize_cp,
pv_to_numbered_san,
score_to_cp_mate,
)


Expand Down Expand Up @@ -122,3 +128,86 @@ def test_allowed_pawn_fork_excludes_illegal_push():
b.set_piece_at(chess.H1, chess.Piece(chess.KING, chess.WHITE))
b.turn = chess.BLACK
assert detect_allowed_pawn_fork(b, chess.WHITE) is None


# --- normalize_cp -----------------------------------------------------------

def test_normalize_cp_centipawn():
assert normalize_cp(50, None) == 50
assert normalize_cp(-300, None) == -300
assert normalize_cp(0, None) == 0


def test_normalize_cp_none_returns_zero():
assert normalize_cp(None, None) == 0


def test_normalize_cp_mate_positive():
result = normalize_cp(None, 3)
assert result == MATE_SCORE - 3
assert result > 0


def test_normalize_cp_mate_negative():
result = normalize_cp(None, -2)
assert result == -MATE_SCORE + 2
assert result < 0


def test_normalize_cp_mate_in_zero():
# mate=0 means already checkmated (the side to move lost).
assert normalize_cp(None, 0) == -MATE_SCORE


# --- score_to_cp_mate -------------------------------------------------------

def test_score_to_cp_mate_centipawn():
cp, mate = score_to_cp_mate(chess.engine.Cp(75))
assert cp == 75
assert mate is None


def test_score_to_cp_mate_mate():
cp, mate = score_to_cp_mate(chess.engine.Mate(2))
assert cp is None
assert mate == 2


# --- detect_phase -----------------------------------------------------------

def _full_board_ply(ply: int) -> chess.Board:
"""A standard starting board (all pieces present) at the given ply."""
b = chess.Board()
b.fullmove_number = ply // 2 + 1
return b


def test_detect_phase_opening():
b = chess.Board() # all pieces present, ply 1
assert detect_phase(b, 1) == "opening"


def test_detect_phase_endgame_low_material():
b = chess.Board(None)
b.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE))
b.set_piece_at(chess.E8, chess.Piece(chess.KING, chess.BLACK))
b.set_piece_at(chess.A1, chess.Piece(chess.ROOK, chess.WHITE))
# total material: 5 — well below the endgame threshold
assert detect_phase(b, 50) == "endgame"


def test_detect_phase_middlegame():
b = chess.Board(None)
# Add enough material to stay out of endgame but past opening ply threshold.
for sq, piece in [
(chess.E1, chess.Piece(chess.KING, chess.WHITE)),
(chess.E8, chess.Piece(chess.KING, chess.BLACK)),
(chess.D1, chess.Piece(chess.QUEEN, chess.WHITE)),
(chess.D8, chess.Piece(chess.QUEEN, chess.BLACK)),
(chess.A1, chess.Piece(chess.ROOK, chess.WHITE)),
(chess.A8, chess.Piece(chess.ROOK, chess.BLACK)),
(chess.C1, chess.Piece(chess.BISHOP, chess.WHITE)),
(chess.C8, chess.Piece(chess.BISHOP, chess.BLACK)),
]:
b.set_piece_at(sq, piece)
assert detect_phase(b, 25) == "middlegame"
55 changes: 55 additions & 0 deletions tests/test_bump_version_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Tests for scripts/bump_version.py — read_version() file I/O.

Kept separate from test_bump_version.py (which tests the pure classify/
apply_bump/format_version logic) so each group can be reverted independently.
No git calls; VERSION_FILE is patched to point to a tmp_path file.
"""
from __future__ import annotations

from unittest.mock import patch

import pytest

import scripts.bump_version as bv


def _write_version(path, ver_str: str) -> None:
path.write_text(f'__version__ = "{ver_str}"\n', encoding="utf-8")


# --- read_version -----------------------------------------------------------

def test_read_version_parses_three_part(tmp_path):
f = tmp_path / "version.py"
_write_version(f, "0.10.0")
with patch.object(bv, "VERSION_FILE", f):
assert bv.read_version() == (0, 10, 0, 0)


def test_read_version_parses_four_part(tmp_path):
f = tmp_path / "version.py"
_write_version(f, "1.2.3.4")
with patch.object(bv, "VERSION_FILE", f):
assert bv.read_version() == (1, 2, 3, 4)


def test_read_version_parses_two_part(tmp_path):
f = tmp_path / "version.py"
_write_version(f, "2.0")
with patch.object(bv, "VERSION_FILE", f):
assert bv.read_version() == (2, 0, 0, 0)


def test_read_version_single_quotes(tmp_path):
f = tmp_path / "version.py"
f.write_text("__version__ = '0.5.1'\n", encoding="utf-8")
with patch.object(bv, "VERSION_FILE", f):
assert bv.read_version() == (0, 5, 1, 0)


def test_read_version_exits_when_no_version_found(tmp_path):
f = tmp_path / "version.py"
f.write_text("# no version here\n", encoding="utf-8")
with patch.object(bv, "VERSION_FILE", f):
with pytest.raises(SystemExit):
bv.read_version()
130 changes: 130 additions & 0 deletions tests/test_commentary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Tests for commentary.py — style-guide loader and transcript collector.

All tests use pytest's tmp_path fixture to build a temporary commentary_refs/
directory. No external services, no Stockfish, no API calls.
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

import commentary


def _make_ref(base: Path, slug: str, transcript: str,
meta: dict | None = None, pgn: str | None = None) -> Path:
"""Create a commentary_refs/<slug>/ subfolder with the given content."""
sub = base / slug
sub.mkdir(parents=True)
(sub / "transcript.txt").write_text(transcript, encoding="utf-8")
if meta:
(sub / "meta.json").write_text(json.dumps(meta), encoding="utf-8")
if pgn:
(sub / "game.pgn").write_text(pgn, encoding="utf-8")
return sub


LONG_TRANSCRIPT = "A" * 300 # 300 chars > the 200-char minimum


# ---------------------------------------------------------------------------
# load_style_guide
# ---------------------------------------------------------------------------

def test_load_style_guide_returns_empty_when_missing(tmp_path):
fake_path = tmp_path / "nonexistent.md"
with patch.object(commentary, "STYLE_GUIDE_PATH", fake_path):
assert commentary.load_style_guide() == ""


def test_load_style_guide_wraps_content(tmp_path):
guide = tmp_path / "style.md"
guide.write_text("Be vivid. Vary sentence length.", encoding="utf-8")
with patch.object(commentary, "STYLE_GUIDE_PATH", guide):
result = commentary.load_style_guide()
assert "## Greco house voice" in result
assert "Be vivid." in result


def test_load_style_guide_truncates_at_max_chars(tmp_path):
guide = tmp_path / "style.md"
guide.write_text("X" * 5000, encoding="utf-8")
with patch.object(commentary, "STYLE_GUIDE_PATH", guide):
result = commentary.load_style_guide(max_chars=100)
assert "…(style guide truncated)" in result
assert len(result) < 5000


# ---------------------------------------------------------------------------
# load_commentary_references — filtering rules
# ---------------------------------------------------------------------------

def test_valid_ref_appears_in_output(tmp_path):
refs = tmp_path / "commentary_refs"
_make_ref(refs, "agadmator-test", LONG_TRANSCRIPT,
meta={"title": "Kasparov Immortal", "commentator": "Agadmator"})
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert "Kasparov Immortal" in result
assert "Agadmator" in result
assert "## Learning from real chess commentators" in result


def test_underscore_prefix_folder_is_skipped(tmp_path):
refs = tmp_path / "commentary_refs"
_make_ref(refs, "_example", LONG_TRANSCRIPT)
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert result == ""


def test_dot_prefix_folder_is_skipped(tmp_path):
refs = tmp_path / "commentary_refs"
_make_ref(refs, ".hidden", LONG_TRANSCRIPT)
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert result == ""


def test_short_transcript_is_skipped(tmp_path):
refs = tmp_path / "commentary_refs"
_make_ref(refs, "stub", "Too short.") # well under 200 chars
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert result == ""


def test_placeholder_transcript_is_skipped(tmp_path):
refs = tmp_path / "commentary_refs"
_make_ref(refs, "unfilled", "PLACEHOLDER — fill this in later " + "x" * 200)
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert result == ""


def test_max_refs_is_respected(tmp_path):
refs = tmp_path / "commentary_refs"
for i in range(5):
_make_ref(refs, f"ref-{i:02d}", LONG_TRANSCRIPT,
meta={"title": f"Title {i}", "commentator": "C"})
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references(max_refs=2)
# Only 2 of the 5 refs should appear.
assert result.count('Reference: "') == 2


def test_no_refs_dir_returns_empty(tmp_path):
missing = tmp_path / "no_such_dir"
with patch.object(commentary, "REFS_DIR", missing):
assert commentary.load_commentary_references() == ""


def test_pgn_game_label_appears(tmp_path):
refs = tmp_path / "commentary_refs"
pgn_text = '[White "Kasparov"][Black "Topalov"][Event "Wijk aan Zee 1999"]\n1. e4'
_make_ref(refs, "immortal", LONG_TRANSCRIPT, pgn=pgn_text)
with patch.object(commentary, "REFS_DIR", refs):
result = commentary.load_commentary_references()
assert "Kasparov" in result
assert "Topalov" in result
Loading
Loading