diff --git a/.gitignore b/.gitignore index d882344..7f0e845 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/outputs.py b/outputs.py index 56061db..0cfd82e 100644 --- a/outputs.py +++ b/outputs.py @@ -865,11 +865,12 @@ def markdown_to_html( figcaption { font-size: 0.85rem; color: #6b5942; font-style: italic; margin-top: 0.3rem; } """ + viewer_css - html = f""" + page_title = html.escape(md_path.stem) + html_doc = f""" -{html.escape(md_path.stem)} +{page_title} @@ -877,7 +878,7 @@ def markdown_to_html( """ - html_path.write_text(html, encoding="utf-8") + html_path.write_text(html_doc, encoding="utf-8") return html_path diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index 018ce0b..df1d0be 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -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, ) @@ -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" diff --git a/tests/test_bump_version_io.py b/tests/test_bump_version_io.py new file mode 100644 index 0000000..60c2d7b --- /dev/null +++ b/tests/test_bump_version_io.py @@ -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() diff --git a/tests/test_commentary.py b/tests/test_commentary.py new file mode 100644 index 0000000..acdaa20 --- /dev/null +++ b/tests/test_commentary.py @@ -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// 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 diff --git a/tests/test_knowledge_passage.py b/tests/test_knowledge_passage.py new file mode 100644 index 0000000..2cf9af0 --- /dev/null +++ b/tests/test_knowledge_passage.py @@ -0,0 +1,139 @@ +"""Tests for knowledge.py — _is_human_authored, _format_featured_passage, +and load_knowledge_for_game (with mocked retrieval). + +These tests target the uncovered lines 698-847 (get_featured_passage / +load_knowledge_for_game) and the pure helpers _is_human_authored and +_format_featured_passage. No corpus DB required: retrieval is mocked. +""" +from __future__ import annotations + +from unittest.mock import patch + +import knowledge +from knowledge import Passage, _format_featured_passage, _is_human_authored + + +# --- helpers ---------------------------------------------------------------- + +def _passage(**overrides) -> Passage: + defaults = dict( + text=( + "The king must be active in the endgame. " + "He is a fighting piece; use him. " + "Never leave him idle when the endgame arrives." + ), + title="Chess Fundamentals", + author="Capablanca", + year=1921, + bucket="chess_principles", + book_id="capablanca-chess-fundamentals", + chunk_index=0, + matched_theme="endgame", + matched_phrases=["king", "endgame"], + ) + defaults.update(overrides) + return Passage(**defaults) + + +# --- _is_human_authored ------------------------------------------------------- + +def test_is_human_authored_named_author(): + assert _is_human_authored(_passage(author="Capablanca")) is True + + +def test_is_human_authored_empty_author(): + assert _is_human_authored(_passage(author="")) is False + + +def test_is_human_authored_greco_project_excluded(): + assert _is_human_authored(_passage(author="Greco Project")) is False + + +def test_is_human_authored_case_insensitive(): + assert _is_human_authored(_passage(author="GRECO PROJECT")) is False + assert _is_human_authored(_passage(author="greco project")) is False + + +# --- _format_featured_passage ------------------------------------------------ + +def test_format_featured_passage_full_attribution(): + p = _passage(author="Capablanca", title="Chess Fundamentals", year=1921) + result = _format_featured_passage(p, "The king must be active.") + assert "Capablanca" in result + assert "Chess Fundamentals" in result + assert "1921" in result + assert "FEATURED PASSAGE" in result + assert '"The king must be active."' in result + + +def test_format_featured_passage_no_year(): + p = _passage(year=None, title="Some Book") + result = _format_featured_passage(p, "A sentence.") + assert "Some Book" in result + assert "(None)" not in result + + +def test_format_featured_passage_endgame_placement_hint(): + p = _passage(matched_theme="endgame") + result = _format_featured_passage(p, "A sentence.") + assert "endgame" in result.lower() + + +def test_format_featured_passage_sacrifice_placement_hint(): + p = _passage(matched_theme="sacrifice") + result = _format_featured_passage(p, "A sentence.") + assert "sacrifice" in result.lower() + + +# --- load_knowledge_for_game (mocked) ---------------------------------------- + +def test_load_knowledge_returns_empty_on_exception(make_move, make_game): + game = make_game([make_move()]) + with patch.object(knowledge, "themes_from_game", side_effect=RuntimeError("db gone")): + assert knowledge.load_knowledge_for_game(game) == "" + + +def test_load_knowledge_returns_empty_when_no_passages(make_move, make_game): + game = make_game([make_move()]) + with ( + patch.object(knowledge, "themes_from_game", return_value=["endgame"]), + patch.object(knowledge, "retrieve", return_value=[]), + ): + assert knowledge.load_knowledge_for_game(game) == "" + + +def test_load_knowledge_returns_block_for_human_passage(make_move, make_game): + game = make_game([make_move()]) + p = _passage(author="Capablanca", matched_theme="endgame") + with ( + patch.object(knowledge, "themes_from_game", return_value=["endgame"]), + patch.object(knowledge, "retrieve", return_value=[p]), + ): + result = knowledge.load_knowledge_for_game(game) + assert "Capablanca" in result + assert "Classical chess literature" in result + + +def test_load_knowledge_excludes_greco_seed_passages(make_move, make_game): + game = make_game([make_move()]) + seed = _passage(author="Greco Project", matched_theme="endgame") + with ( + patch.object(knowledge, "themes_from_game", return_value=["endgame"]), + patch.object(knowledge, "retrieve", return_value=[seed]), + ): + assert knowledge.load_knowledge_for_game(game) == "" + + +def test_load_knowledge_respects_max_chars(make_move, make_game): + game = make_game([make_move()]) + # Five passages with distinct authors; a very tight budget means not all make it + # into the literature block (though a FEATURED PASSAGE is always prepended). + passages = [_passage(author=f"Author{i}") for i in range(5)] + with ( + patch.object(knowledge, "themes_from_game", return_value=["endgame"]), + patch.object(knowledge, "retrieve", return_value=passages), + ): + result_small = knowledge.load_knowledge_for_game(game, max_chars=50) + result_large = knowledge.load_knowledge_for_game(game, max_chars=100_000) + # A larger budget should include more passages than a tiny one. + assert result_large.count("Author") >= result_small.count("Author") diff --git a/tests/test_narrator_helpers.py b/tests/test_narrator_helpers.py new file mode 100644 index 0000000..892feb8 --- /dev/null +++ b/tests/test_narrator_helpers.py @@ -0,0 +1,88 @@ +"""Narrator helper tests — _piece_placement, _format_eval edge cases, +and _move_to_dict flag serialisation. + +No API calls, no Stockfish. Complements test_narrator.py with edge cases +that were not covered there (lines 29-37, 349, 377-433 of narrator.py). +""" +from __future__ import annotations + +from narrator import _format_eval, _move_to_dict, _piece_placement + + +# --- _piece_placement ------------------------------------------------------- + +STARTING_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" +AFTER_E4_FEN = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1" + + +def test_piece_placement_starting_position(): + result = _piece_placement(STARTING_FEN) + assert "White" in result and "Black" in result + assert "K:" in result + assert "Q:" in result + assert "P:" in result + + +def test_piece_placement_correct_square_after_e4(): + result = _piece_placement(AFTER_E4_FEN) + assert "e4" in result + + +def test_piece_placement_bad_fen_returns_empty(): + assert _piece_placement("not_a_fen") == "" + + +def test_piece_placement_pipe_separator(): + result = _piece_placement(STARTING_FEN) + assert "|" in result # White ... | Black ... + + +# --- _format_eval edge case: mate=0 means already checkmated ---------------- + +def test_format_eval_mate_zero(): + assert _format_eval(None, 0) == "checkmate" + + +# --- _move_to_dict flag serialisation --------------------------------------- + +def test_move_to_dict_no_flags_by_default(make_move): + d = _move_to_dict(make_move(), tier=1) + assert "flags" not in d + + +def test_move_to_dict_check_flag(make_move): + d = _move_to_dict(make_move(is_check=True), tier=1) + assert "check" in d["flags"] + + +def test_move_to_dict_capture_and_captured_piece(make_move): + d = _move_to_dict(make_move(is_capture=True, captured_piece="knight"), tier=1) + assert "capture" in d["flags"] + assert d["captured"] == "knight" + + +def test_move_to_dict_castle_flag(make_move): + d = _move_to_dict(make_move(is_castle=True), tier=1) + assert "castle" in d["flags"] + + +def test_move_to_dict_brilliant_and_sacrifice_flags(make_move): + d = _move_to_dict(make_move(is_brilliant=True, is_sacrifice=True), tier=1) + assert "brilliant" in d["flags"] + assert "sound-sacrifice" in d["flags"] + + +def test_move_to_dict_unsound_sacrifice_flag(make_move): + d = _move_to_dict(make_move(is_unsound_sacrifice=True), tier=1) + assert "unsound-sacrifice" in d["flags"] + + +def test_move_to_dict_forced_and_only_good(make_move): + d = _move_to_dict(make_move(is_forced=True, is_only_good_move=True), tier=1) + assert "forced" in d["flags"] + assert "only-good-move" in d["flags"] + + +def test_move_to_dict_still_winning_flag(make_move): + d = _move_to_dict(make_move(still_winning=True), tier=1) + assert "still-decisively-winning" in d["flags"] diff --git a/tests/test_outputs_assembly.py b/tests/test_outputs_assembly.py new file mode 100644 index 0000000..2949a8d --- /dev/null +++ b/tests/test_outputs_assembly.py @@ -0,0 +1,100 @@ +"""Tests for assemble_report() and markdown_to_html() in outputs.py. + +Uses tmp_path and the conftest GameAnalysis fixtures. No engine, no API. +Targets the uncovered lines in outputs.py (420-489, 817-881). +""" +from __future__ import annotations + +from outputs import assemble_report, markdown_to_html + + +# --- assemble_report ------------------------------------------------------- + +def test_assemble_report_creates_md_file(tmp_path, make_move, make_game): + game = make_game( + [make_move(ply=1, san="e4", uci="e2e4", + fen_before="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + fen_after="rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1")], + White="Kasparov", Black="Deep Blue", + ) + out = tmp_path / "report.md" + result = assemble_report( + game, [1], "## Opening\n\nThe game opened with 1. e4.", + output_md=out, boards_at="off", render_eval_graph=False, + ) + assert result == out + assert out.is_file() + content = out.read_text(encoding="utf-8") + assert "Kasparov" in content + assert "Deep Blue" in content + + +def test_assemble_report_strips_top_level_title(tmp_path, make_move, make_game): + game = make_game([make_move()], White="A", Black="B") + out = tmp_path / "report.md" + assemble_report( + game, [0], "# Unwanted Title Added By Model\n## Opening\n\nSome text.", + output_md=out, boards_at="off", render_eval_graph=False, + ) + content = out.read_text(encoding="utf-8") + assert "# Unwanted Title Added By Model" not in content + assert "## Opening" in content + + +def test_assemble_report_with_eval_graph(tmp_path, make_move, make_game): + game = make_game( + [make_move(ply=1, eval_after_cp=50, classification="best"), + make_move(ply=2, eval_after_cp=-20, classification="good")], + White="A", Black="B", + ) + out = tmp_path / "report.md" + assemble_report( + game, [1, 0], "## Opening\n\nSome commentary.", + output_md=out, boards_at="off", render_eval_graph=True, + ) + assert out.is_file() + content = out.read_text(encoding="utf-8") + assert "Evaluation" in content + + +def test_assemble_report_creates_parent_dirs(tmp_path, make_move, make_game): + game = make_game([make_move()], White="A", Black="B") + out = tmp_path / "nested" / "deep" / "report.md" + assemble_report( + game, [0], "## Opening\n\nText.", + output_md=out, boards_at="off", render_eval_graph=False, + ) + assert out.is_file() + + +# --- markdown_to_html ------------------------------------------------------- + +def test_markdown_to_html_creates_html_file(tmp_path): + md = tmp_path / "report.md" + md.write_text("# Test Report\n\nSome narrative text.", encoding="utf-8") + result = markdown_to_html(md, embed_assets=False) + assert result == md.with_suffix(".html") + assert result.is_file() + html = result.read_text(encoding="utf-8") + assert "" in html.lower() + assert "