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 "