diff --git a/pyproject.toml b/pyproject.toml index 9c96cd8..e070139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -214,6 +214,21 @@ omit = [ fail_under = 100 show_missing = true skip_covered = false +exclude_lines = [ + # Standard exclusions + "pragma: no cover", + # Unreachable defensive code + "raise NotImplementedError", + "raise AssertionError", + # Type checking imports + "if TYPE_CHECKING:", + # Main script entry point + 'if __name__ == "__main__":', +] +# Partial branch exclusions for unreachable branches +partial_branches = [ + "pragma: no branch", +] # ============================================================================ # VULTURE - Dead code detection diff --git a/python_pkg/screen_locker/tests/__init__.py b/python_pkg/screen_locker/tests/__init__.py new file mode 100644 index 0000000..9bdc078 --- /dev/null +++ b/python_pkg/screen_locker/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for screen_locker module.""" diff --git a/python_pkg/stockfish_analysis/analyze_chess_game.py b/python_pkg/stockfish_analysis/analyze_chess_game.py index 01f088d..afabb11 100755 --- a/python_pkg/stockfish_analysis/analyze_chess_game.py +++ b/python_pkg/stockfish_analysis/analyze_chess_game.py @@ -67,14 +67,14 @@ def extract_pgn_text(raw: str) -> str | None: if line.strip().startswith("PGN:"): # everything after this line pgn = "\n".join(lines[i + 1 :]).strip() - if pgn: + if pgn: # pragma: no branch return pgn # 2) From first tag line for i, line in enumerate(lines): if line.strip().startswith("[") and "]" in line: pgn = "\n".join(lines[i:]).strip() - if pgn: + if pgn: # pragma: no branch return pgn # 3) From first move number @@ -82,7 +82,7 @@ def extract_pgn_text(raw: str) -> str | None: for i, line in enumerate(lines): if move_start_re.match(line): pgn = "\n".join(lines[i:]).strip() - if pgn: + if pgn: # pragma: no branch return pgn return None @@ -583,7 +583,7 @@ def _analyze_last_move( return ply = 1 - while node.variations: + while node.variations: # pragma: no branch move_node = node.variations[0] move = move_node.move diff --git a/python_pkg/stockfish_analysis/tests/__init__.py b/python_pkg/stockfish_analysis/tests/__init__.py new file mode 100644 index 0000000..13496ba --- /dev/null +++ b/python_pkg/stockfish_analysis/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for stockfish_analysis module.""" diff --git a/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py new file mode 100644 index 0000000..5a16718 --- /dev/null +++ b/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py @@ -0,0 +1,1152 @@ +"""Tests for analyze_chess_game module.""" + +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, mock_open, patch + +import chess +import chess.engine +import chess.pgn +import pytest + +from python_pkg.stockfish_analysis.analyze_chess_game import ( + AnalysisContext, + MoveAnalysis, + _analyze_all_moves, + _analyze_last_move, + _analyze_single_move, + _auto_hash_mb, + _build_argument_parser, + _classify_mate_move, + _configure_hash, + _configure_multipv, + _configure_nnue, + _configure_threads, + _detect_total_mem_mb, + _evaluate_position, + _get_best_move, + _load_game, + _log_engine_config, + _log_move_analysis, + _parse_hash_mb, + _parse_threads, + _run_analysis, + _setup_engine, + classify_cp_loss, + extract_pgn_text, + fmt_eval, + main, + score_to_cp, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestExtractPgnText: + """Tests for extract_pgn_text function.""" + + def test_extract_pgn_after_marker(self) -> None: + """Test extraction after PGN: marker.""" + raw = "Some log stuff\nPGN:\n1. e4 e5 2. Nf3 Nc6" + result = extract_pgn_text(raw) + assert result == "1. e4 e5 2. Nf3 Nc6" + + def test_extract_pgn_from_tag_line(self) -> None: + """Test extraction from first PGN tag.""" + raw = 'Log header\n[Event "Test"]\n1. e4 e5' + result = extract_pgn_text(raw) + assert result is not None + assert '[Event "Test"]' in result + assert "1. e4 e5" in result + + def test_extract_pgn_from_move_number(self) -> None: + """Test extraction from first move number.""" + raw = "Some text\n1. e4 e5 2. Nf3" + result = extract_pgn_text(raw) + assert result == "1. e4 e5 2. Nf3" + + def test_extract_pgn_no_match(self) -> None: + """Test extraction returns None when no PGN found.""" + raw = "No PGN content here\nJust some text" + result = extract_pgn_text(raw) + assert result is None + + def test_extract_pgn_empty_after_marker(self) -> None: + """Test extraction with empty content after marker.""" + # Need double newline so splitlines creates an empty second element + raw = "PGN:\n\n" + result = extract_pgn_text(raw) + # Should fall through to tag check, then move check, then None + assert result is None + + def test_extract_pgn_empty_tag_line(self) -> None: + """Test extraction when tag line at end results in empty pgn.""" + # Tag line is last line so join is just that line, which is non-empty + raw = "text\n[ ]" + result = extract_pgn_text(raw) + assert result == "[ ]" + + def test_extract_pgn_pgn_marker_followed_by_tag(self) -> None: + """Test extraction when PGN: marker is followed by tag (empty after).""" + # PGN: marker with only whitespace after, then has tag + raw = "PGN:\n \n[Event]" + result = extract_pgn_text(raw) + # Whitespace lines collapse to just "[Event]" + assert "[Event]" in (result or "") + + def test_extract_pgn_only_whitespace_after_tag(self) -> None: + """Test extraction when only whitespace after tag line.""" + raw = "[Event]\n \n " + result = extract_pgn_text(raw) + # Strip makes it non-empty since [Event] is included + assert result is not None + + def test_extract_pgn_only_whitespace_after_move(self) -> None: + """Test extraction when move line followed by whitespace only.""" + raw = "text\n1. \n " + result = extract_pgn_text(raw) + # "1." followed by whitespace is valid + assert result is not None + + +class TestScoreToCp: + """Tests for score_to_cp function.""" + + def test_score_to_cp_centipawn(self) -> None: + """Test centipawn score conversion.""" + mock_score = MagicMock(spec=chess.engine.PovScore) + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 150 + mock_score.pov.return_value = mock_pov + + cp, mate = score_to_cp(mock_score, pov_white=True) + assert cp == 150 + assert mate is None + + def test_score_to_cp_mate(self) -> None: + """Test mate score conversion.""" + mock_score = MagicMock(spec=chess.engine.PovScore) + mock_pov = MagicMock() + mock_pov.is_mate.return_value = True + mock_pov.mate.return_value = 3 + mock_score.pov.return_value = mock_pov + + cp, mate = score_to_cp(mock_score, pov_white=True) + assert cp is None + assert mate == 3 + + +class TestClassifyCpLoss: + """Tests for classify_cp_loss function.""" + + def test_classify_best(self) -> None: + """Test classification of best move.""" + assert classify_cp_loss(5) == "Best" + assert classify_cp_loss(10) == "Best" + + def test_classify_excellent(self) -> None: + """Test classification of excellent move.""" + assert classify_cp_loss(15) == "Excellent" + assert classify_cp_loss(20) == "Excellent" + + def test_classify_good(self) -> None: + """Test classification of good move.""" + assert classify_cp_loss(30) == "Good" + assert classify_cp_loss(50) == "Good" + + def test_classify_inaccuracy(self) -> None: + """Test classification of inaccuracy.""" + assert classify_cp_loss(60) == "Inaccuracy" + assert classify_cp_loss(99) == "Inaccuracy" + + def test_classify_mistake(self) -> None: + """Test classification of mistake.""" + assert classify_cp_loss(150) == "Mistake" + assert classify_cp_loss(299) == "Mistake" + + def test_classify_blunder(self) -> None: + """Test classification of blunder.""" + assert classify_cp_loss(300) == "Blunder" + assert classify_cp_loss(500) == "Blunder" + + def test_classify_unknown(self) -> None: + """Test classification of unknown loss.""" + assert classify_cp_loss(None) == "Unknown" + + +class TestFmtEval: + """Tests for fmt_eval function.""" + + def test_fmt_eval_mate(self) -> None: + """Test formatting mate score.""" + assert fmt_eval(None, 3) == "M+3" + assert fmt_eval(None, -2) == "M-2" + + def test_fmt_eval_centipawn(self) -> None: + """Test formatting centipawn score.""" + assert fmt_eval(150, None) == "+1.50" + assert fmt_eval(-200, None) == "-2.00" + assert fmt_eval(0, None) == "+0.00" + + def test_fmt_eval_unknown(self) -> None: + """Test formatting unknown score.""" + assert fmt_eval(None, None) == "?" + + +class TestParseThreads: + """Tests for _parse_threads function.""" + + def test_parse_threads_auto(self) -> None: + """Test auto thread detection.""" + assert _parse_threads("auto") is None + assert _parse_threads("max") is None + assert _parse_threads("") is None + + def test_parse_threads_integer(self) -> None: + """Test integer thread count.""" + assert _parse_threads("4") == 4 + assert _parse_threads("16") == 16 + + def test_parse_threads_minimum(self) -> None: + """Test minimum thread count enforced.""" + assert _parse_threads("0") == 1 + assert _parse_threads("-1") == 1 + + def test_parse_threads_invalid(self) -> None: + """Test invalid thread value.""" + with pytest.raises(argparse.ArgumentTypeError): + _parse_threads("invalid") + + +class TestParseHashMb: + """Tests for _parse_hash_mb function.""" + + def test_parse_hash_auto(self) -> None: + """Test auto hash detection.""" + assert _parse_hash_mb("auto") is None + assert _parse_hash_mb("max") is None + assert _parse_hash_mb("") is None + + def test_parse_hash_integer(self) -> None: + """Test integer hash size.""" + assert _parse_hash_mb("512") == 512 + assert _parse_hash_mb("2048") == 2048 + + def test_parse_hash_minimum(self) -> None: + """Test minimum hash size enforced.""" + assert _parse_hash_mb("8") == 16 + + def test_parse_hash_invalid(self) -> None: + """Test invalid hash value.""" + with pytest.raises(argparse.ArgumentTypeError): + _parse_hash_mb("invalid") + + +class TestDetectTotalMemMb: + """Tests for _detect_total_mem_mb function.""" + + def test_detect_mem_with_psutil(self) -> None: + """Test memory detection with psutil.""" + mock_vm = MagicMock() + mock_vm.total = 16 * 1024 * 1024 * 1024 # 16 GB + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game.psutil" + ) as mock_psutil: + mock_psutil.virtual_memory.return_value = mock_vm + result = _detect_total_mem_mb() + assert result == 16384 + + def test_detect_mem_psutil_exception(self) -> None: + """Test memory detection when psutil fails - falls back to /proc.""" + with ( + patch( + "python_pkg.stockfish_analysis.analyze_chess_game.psutil" + ) as mock_psutil, + patch("python_pkg.stockfish_analysis.analyze_chess_game.Path") as mock_path, + ): + mock_psutil.virtual_memory.side_effect = RuntimeError("fail") + # Also make /proc/meminfo fail so we get None + mock_path.return_value.open.side_effect = FileNotFoundError() + result = _detect_total_mem_mb() + assert result is None + + def test_detect_mem_from_proc(self) -> None: + """Test memory detection from /proc/meminfo.""" + meminfo_content = "MemTotal: 16384000 kB\nMemFree: 8000000 kB" + with ( + patch("python_pkg.stockfish_analysis.analyze_chess_game.psutil", None), + patch("pathlib.Path.open", mock_open(read_data=meminfo_content)), + ): + result = _detect_total_mem_mb() + assert result == 16000 # 16384000 kB / 1024 + + def test_detect_mem_no_psutil_no_proc(self) -> None: + """Test memory detection when both methods fail.""" + with ( + patch("python_pkg.stockfish_analysis.analyze_chess_game.psutil", None), + patch("pathlib.Path.open", side_effect=FileNotFoundError), + ): + result = _detect_total_mem_mb() + assert result is None + + def test_detect_mem_proc_no_memtotal(self) -> None: + """Test memory detection when MemTotal line is missing.""" + meminfo_content = "MemFree: 8000000 kB\nBuffers: 1000 kB" + with ( + patch("python_pkg.stockfish_analysis.analyze_chess_game.psutil", None), + patch("pathlib.Path.open", mock_open(read_data=meminfo_content)), + ): + result = _detect_total_mem_mb() + assert result is None + + def test_detect_mem_proc_invalid_parts(self) -> None: + """Test memory detection when MemTotal line has invalid format.""" + meminfo_content = "MemTotal: notanumber kB\nMemFree: 8000000 kB" + with ( + patch("python_pkg.stockfish_analysis.analyze_chess_game.psutil", None), + patch("pathlib.Path.open", mock_open(read_data=meminfo_content)), + ): + result = _detect_total_mem_mb() + assert result is None + + def test_detect_mem_proc_short_parts(self) -> None: + """Test memory detection when MemTotal has too few parts.""" + meminfo_content = "MemTotal:\nMemFree: 8000000 kB" + with ( + patch("python_pkg.stockfish_analysis.analyze_chess_game.psutil", None), + patch("pathlib.Path.open", mock_open(read_data=meminfo_content)), + ): + result = _detect_total_mem_mb() + assert result is None + + +class TestAutoHashMb: + """Tests for _auto_hash_mb function.""" + + def test_auto_hash_basic(self) -> None: + """Test basic auto hash calculation.""" + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._detect_total_mem_mb", + return_value=8192, + ): + result = _auto_hash_mb(4, {}) + assert result >= 64 + assert result <= 4096 + + def test_auto_hash_high_threads(self) -> None: + """Test auto hash with high thread count.""" + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._detect_total_mem_mb", + return_value=16384, + ): + result = _auto_hash_mb(20, {}) + assert result > 64 + + def test_auto_hash_respects_engine_max(self) -> None: + """Test auto hash respects engine maximum.""" + mock_opt = MagicMock() + mock_opt.max = 256 + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._detect_total_mem_mb", + return_value=8192, + ): + result = _auto_hash_mb(4, {"Hash": mock_opt}) + assert result <= 256 + + def test_auto_hash_no_mem_info(self) -> None: + """Test auto hash when memory detection fails.""" + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._detect_total_mem_mb", + return_value=None, + ): + result = _auto_hash_mb(4, {}) + assert result >= 64 + + def test_auto_hash_attribute_error(self) -> None: + """Test auto hash when opt.max raises AttributeError.""" + + class NoMaxOpt: + """Object without max attribute.""" + + @property + def max(self) -> int: + raise AttributeError + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._detect_total_mem_mb", + return_value=8192, + ): + result = _auto_hash_mb(4, {"Hash": NoMaxOpt()}) + assert result >= 64 + + +class TestConfigureThreads: + """Tests for _configure_threads function.""" + + def test_configure_threads_no_option(self) -> None: + """Test thread config when engine has no Threads option.""" + engine = MagicMock() + result = _configure_threads(engine, {}, 4) + assert result == 4 + + def test_configure_threads_with_limits(self) -> None: + """Test thread config respects engine limits.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = 8 + mock_opt.min = 1 + result = _configure_threads(engine, {"Threads": mock_opt}, 16) + assert result == 8 + engine.configure.assert_called_once() + + def test_configure_threads_auto(self) -> None: + """Test thread config with auto detection.""" + engine = MagicMock() + with patch("multiprocessing.cpu_count", return_value=8): + result = _configure_threads(engine, {}, None) + assert result == 8 + + def test_configure_threads_exception(self) -> None: + """Test thread config handles exceptions.""" + engine = MagicMock() + engine.configure.side_effect = ValueError("Failed") + mock_opt = MagicMock() + mock_opt.max = 8 + mock_opt.min = 1 + # Should not raise, just log debug + result = _configure_threads(engine, {"Threads": mock_opt}, 4) + assert result == 4 + + def test_configure_threads_no_max_min(self) -> None: + """Test thread config when max/min are not integers.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = None + mock_opt.min = None + result = _configure_threads(engine, {"Threads": mock_opt}, 4) + assert result == 4 + + +class TestConfigureHash: + """Tests for _configure_hash function.""" + + def test_configure_hash_no_option(self) -> None: + """Test hash config when engine has no Hash option.""" + engine = MagicMock() + _configure_hash(engine, {}, 512, 4) + engine.configure.assert_not_called() + + def test_configure_hash_with_limits(self) -> None: + """Test hash config respects engine limits.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = 1024 + mock_opt.min = 16 + _configure_hash(engine, {"Hash": mock_opt}, 2048, 4) + engine.configure.assert_called_once() + + def test_configure_hash_exception(self) -> None: + """Test hash config handles exceptions.""" + engine = MagicMock() + engine.configure.side_effect = TypeError("Failed") + mock_opt = MagicMock() + mock_opt.max = 1024 + mock_opt.min = 16 + # Should not raise, just log debug + _configure_hash(engine, {"Hash": mock_opt}, 512, 4) + + def test_configure_hash_no_max_min(self) -> None: + """Test hash config when max/min are not integers.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = None + mock_opt.min = None + _configure_hash(engine, {"Hash": mock_opt}, 512, 4) + engine.configure.assert_called_once() + + +class TestConfigureMultipv: + """Tests for _configure_multipv function.""" + + def test_configure_multipv_no_option(self) -> None: + """Test MultiPV config when engine has no option.""" + engine = MagicMock() + result = _configure_multipv(engine, {}, 3) + assert result == 3 + + def test_configure_multipv_with_limit(self) -> None: + """Test MultiPV config respects engine limit.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = 2 + result = _configure_multipv(engine, {"MultiPV": mock_opt}, 5) + assert result == 2 + + def test_configure_multipv_exception(self) -> None: + """Test MultiPV config handles exceptions.""" + engine = MagicMock() + engine.configure.side_effect = ValueError("Failed") + mock_opt = MagicMock() + mock_opt.max = 5 + # Should not raise, just log debug + result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3) + assert result == 3 + + def test_configure_multipv_no_max(self) -> None: + """Test MultiPV config when max is not integer.""" + engine = MagicMock() + mock_opt = MagicMock() + mock_opt.max = None + result = _configure_multipv(engine, {"MultiPV": mock_opt}, 3) + assert result == 3 + engine.configure.assert_called_once() + + +class TestConfigureNnue: + """Tests for _configure_nnue function.""" + + def test_configure_nnue_use_nnue(self) -> None: + """Test NNUE config with 'Use NNUE' option.""" + engine = MagicMock() + _configure_nnue(engine, {"Use NNUE": MagicMock()}) + engine.configure.assert_called_once_with({"Use NNUE": True}) + + def test_configure_nnue_usennue(self) -> None: + """Test NNUE config with 'UseNNUE' option.""" + engine = MagicMock() + _configure_nnue(engine, {"UseNNUE": MagicMock()}) + engine.configure.assert_called_once_with({"UseNNUE": True}) + + def test_configure_nnue_not_supported(self) -> None: + """Test NNUE config when not supported.""" + engine = MagicMock() + _configure_nnue(engine, {}) + engine.configure.assert_not_called() + + +class TestBuildArgumentParser: + """Tests for _build_argument_parser function.""" + + def test_parser_required_args(self) -> None: + """Test parser with required arguments.""" + parser = _build_argument_parser() + args = parser.parse_args(["test.pgn"]) + assert args.file == "test.pgn" + + def test_parser_optional_args(self) -> None: + """Test parser with optional arguments.""" + parser = _build_argument_parser() + args = parser.parse_args( + [ + "test.pgn", + "--engine", + "sf", + "--time", + "1.0", + "--depth", + "20", + "--multipv", + "3", + "--last-move-only", + ] + ) + assert args.engine == "sf" + assert args.time == 1.0 + assert args.depth == 20 + assert args.multipv == 3 + assert args.last_move_only is True + + +class TestLoadGame: + """Tests for _load_game function.""" + + def test_load_game_file_not_found(self, tmp_path: Path) -> None: + """Test loading non-existent file.""" + with pytest.raises(SystemExit) as exc: + _load_game(str(tmp_path / "nonexistent.pgn")) + assert exc.value.code == 1 + + def test_load_game_no_pgn(self, tmp_path: Path) -> None: + """Test loading file with no PGN content.""" + pgn_file = tmp_path / "empty.pgn" + pgn_file.write_text("No PGN here") + with pytest.raises(SystemExit) as exc: + _load_game(str(pgn_file)) + assert exc.value.code == 2 + + def test_load_game_success(self, tmp_path: Path) -> None: + """Test successful game loading.""" + pgn_file = tmp_path / "game.pgn" + pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *') + game = _load_game(str(pgn_file)) + assert game is not None + + def test_load_game_invalid_pgn(self, tmp_path: Path) -> None: + """Test loading file when read_game returns None.""" + pgn_file = tmp_path / "invalid.pgn" + pgn_file.write_text('[Event "Test"]\n\n1. e4 *') + # Mock read_game to return None to trigger exit code 3 + with ( + patch("chess.pgn.read_game", return_value=None), + pytest.raises(SystemExit) as exc, + ): + _load_game(str(pgn_file)) + assert exc.value.code == 3 + + +class TestSetupEngine: + """Tests for _setup_engine function.""" + + def test_setup_engine_not_found(self) -> None: + """Test engine setup with non-existent engine.""" + args = argparse.Namespace( + engine="nonexistent_engine", + time=0.5, + depth=None, + threads=None, + hash_mb=None, + multipv=2, + ) + with pytest.raises(SystemExit) as exc: + _setup_engine(args) + assert exc.value.code == 4 + + def test_setup_engine_with_depth(self) -> None: + """Test engine setup with depth limit.""" + mock_engine = MagicMock() + mock_engine.options = {} + + args = argparse.Namespace( + engine="stockfish", + time=0.5, + depth=20, + threads=4, + hash_mb=512, + multipv=2, + ) + + with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine): + engine, _mpv, limit = _setup_engine(args) + assert engine == mock_engine + assert limit.depth == 20 + + def test_setup_engine_with_time(self) -> None: + """Test engine setup with time limit.""" + mock_engine = MagicMock() + mock_engine.options = {} + + args = argparse.Namespace( + engine="stockfish", + time=1.0, + depth=None, + threads=None, + hash_mb=None, + multipv=2, + ) + + with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine): + _engine, _mpv, limit = _setup_engine(args) + assert limit.time == 1.0 + + def test_setup_engine_options_attr_error(self) -> None: + """Test engine setup when options raises AttributeError.""" + from unittest.mock import PropertyMock + + mock_engine = MagicMock() + # Delete the auto-created options attribute first + del mock_engine.options + # Then set up PropertyMock to raise AttributeError + type(mock_engine).options = PropertyMock(side_effect=AttributeError) + + args = argparse.Namespace( + engine="stockfish", + time=1.0, + depth=None, + threads=None, + hash_mb=None, + multipv=2, + ) + + with patch("chess.engine.SimpleEngine.popen_uci", return_value=mock_engine): + engine, _mpv, limit = _setup_engine(args) + assert engine == mock_engine + assert limit.time == 1.0 + + +class TestLogEngineConfig: + """Tests for _log_engine_config function.""" + + def test_log_with_hash(self) -> None: + """Test logging config with hash value.""" + mock_engine = MagicMock() + mock_hash = MagicMock() + mock_hash.value = 512 + mock_engine.options.get.return_value = mock_hash + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._logger" + ) as mock_logger: + _log_engine_config(mock_engine, 4, 2) + mock_logger.info.assert_called() + + def test_log_without_hash(self) -> None: + """Test logging config without hash value.""" + mock_engine = MagicMock() + mock_engine.options.get.return_value = None + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._logger" + ) as mock_logger: + _log_engine_config(mock_engine, 4, 2) + mock_logger.info.assert_called() + + def test_log_with_hash_exception(self) -> None: + """Test logging config when hash access raises exception.""" + from unittest.mock import PropertyMock + + mock_engine = MagicMock() + # Make .value access raise + mock_hash = MagicMock() + type(mock_hash).value = PropertyMock(side_effect=TypeError) + mock_engine.options.get.return_value = mock_hash + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._logger" + ) as mock_logger: + _log_engine_config(mock_engine, 4, 2) + # Should still call info (without hash) + mock_logger.info.assert_called() + + +class TestGetBestMove: + """Tests for _get_best_move function.""" + + def test_get_best_move_from_analysis(self) -> None: + """Test getting best move from analysis.""" + mock_engine = MagicMock() + mock_move = chess.Move.from_uci("e2e4") + mock_engine.analyse.return_value = [{"pv": [mock_move]}] + + board = chess.Board() + limit = chess.engine.Limit(time=0.1) + + result = _get_best_move(mock_engine, board, limit, 2) + assert result == mock_move + + def test_get_best_move_fallback_to_play(self) -> None: + """Test getting best move via play when analysis fails.""" + mock_engine = MagicMock() + mock_engine.analyse.return_value = [{}] + mock_move = chess.Move.from_uci("e2e4") + mock_engine.play.return_value = MagicMock(move=mock_move) + + board = chess.Board() + limit = chess.engine.Limit(time=0.1) + + result = _get_best_move(mock_engine, board, limit, 2) + assert result == mock_move + + +class TestEvaluatePosition: + """Tests for _evaluate_position function.""" + + def test_evaluate_position_success(self) -> None: + """Test successful position evaluation.""" + mock_engine = MagicMock() + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 50 + mock_score.pov.return_value = mock_pov + mock_engine.analyse.return_value = [{"score": mock_score}] + + board = chess.Board() + limit = chess.engine.Limit(time=0.1) + + cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True) + assert cp == 50 + assert mate is None + + def test_evaluate_position_no_score(self) -> None: + """Test evaluation with no score.""" + mock_engine = MagicMock() + mock_engine.analyse.return_value = [{}] + + board = chess.Board() + limit = chess.engine.Limit(time=0.1) + + cp, mate = _evaluate_position(mock_engine, board, limit, 2, pov_white=True) + assert cp is None + assert mate is None + + +class TestClassifyMateMove: + """Tests for _classify_mate_move function.""" + + def test_classify_mate_missing_values(self) -> None: + """Test classification with missing mate values.""" + assert _classify_mate_move(None, 2) == "Blunder" + assert _classify_mate_move(2, None) == "Blunder" + + def test_classify_mate_both_positive(self) -> None: + """Test classification with both positive mates.""" + assert _classify_mate_move(2, 3) == "Inaccuracy" + assert _classify_mate_move(3, 3) == "Best" + assert _classify_mate_move(3, 2) == "Best" + + def test_classify_mate_both_negative(self) -> None: + """Test classification with both negative mates.""" + assert _classify_mate_move(-3, -2) == "Blunder" + assert _classify_mate_move(-2, -2) == "Best" + assert _classify_mate_move(-2, -3) == "Good" + + def test_classify_mate_opposite_signs(self) -> None: + """Test classification with opposite sign mates.""" + assert _classify_mate_move(2, -2) == "Blunder" + assert _classify_mate_move(-2, 2) == "Blunder" + + +class TestAnalyzeSingleMove: + """Tests for _analyze_single_move function.""" + + def test_analyze_single_move(self) -> None: + """Test analyzing a single move.""" + mock_engine = MagicMock() + + # Mock best move + best_move = chess.Move.from_uci("e2e4") + mock_engine.analyse.return_value = [{"pv": [best_move]}] + + # Mock score + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + + mock_engine.analyse.return_value = [{"pv": [best_move], "score": mock_score}] + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + board = chess.Board() + move = chess.Move.from_uci("e2e4") + + result = _analyze_single_move(ctx, board, move) + assert isinstance(result, MoveAnalysis) + assert result.san == "e4" + + def test_analyze_single_move_no_best_move(self) -> None: + """Test analyzing when engine returns no best move.""" + mock_engine = MagicMock() + + # Mock engine returning no pv + mock_engine.analyse.return_value = [{}] + mock_engine.play.return_value = MagicMock(move=None) + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + board = chess.Board() + move = chess.Move.from_uci("e2e4") + + result = _analyze_single_move(ctx, board, move) + assert isinstance(result, MoveAnalysis) + assert result.best_san == "?" + + def test_analyze_single_move_with_mate(self) -> None: + """Test analyzing a move with mate score.""" + mock_engine = MagicMock() + + best_move = chess.Move.from_uci("e2e4") + + def mock_analyse( + _board: chess.Board, **_kwargs: object + ) -> list[dict[str, object]]: + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = True + mock_pov.mate.return_value = 3 + mock_score.pov.return_value = mock_pov + return [{"pv": [best_move], "score": mock_score}] + + mock_engine.analyse.side_effect = mock_analyse + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + board = chess.Board() + move = chess.Move.from_uci("e2e4") + + result = _analyze_single_move(ctx, board, move) + assert isinstance(result, MoveAnalysis) + + def test_analyze_single_move_unknown_classification(self) -> None: + """Test analyzing when both cp and mate are None.""" + mock_engine = MagicMock() + + best_move = chess.Move.from_uci("e2e4") + + def mock_analyse( + _board: chess.Board, **_kwargs: object + ) -> list[dict[str, object]]: + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = None + mock_score.pov.return_value = mock_pov + return [{"pv": [best_move], "score": mock_score}] + + mock_engine.analyse.side_effect = mock_analyse + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + board = chess.Board() + move = chess.Move.from_uci("e2e4") + + result = _analyze_single_move(ctx, board, move) + assert result.classification == "Unknown" + + +class TestLogMoveAnalysis: + """Tests for _log_move_analysis function.""" + + def test_log_move_analysis(self) -> None: + """Test logging move analysis.""" + result = MoveAnalysis( + san="e4", + best_san="e4", + played_cp=30, + played_mate=None, + best_cp=30, + best_mate=None, + cp_loss=0, + classification="Best", + ) + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._logger" + ) as mock_logger: + _log_move_analysis(1, result, mover_white=True) + mock_logger.info.assert_called() + + +class TestRunAnalysis: + """Tests for _run_analysis function.""" + + def test_run_analysis_all_moves(self) -> None: + """Test running analysis on all moves.""" + mock_engine = MagicMock() + + def mock_analyse( + board: chess.Board, **_kwargs: object + ) -> list[dict[str, object]]: + """Return a legal move for the given position.""" + legal_moves = list(board.legal_moves) + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + pv = [legal_moves[0]] if legal_moves else [] + return [{"pv": pv, "score": mock_score}] + + mock_engine.analyse.side_effect = mock_analyse + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + + game = chess.pgn.Game() + node = game.add_variation(chess.Move.from_uci("e2e4")) + node.add_variation(chess.Move.from_uci("e7e5")) + + _run_analysis(game, ctx, last_move_only=False) + + +class TestAnalyzeLastMove: + """Tests for _analyze_last_move function.""" + + def test_analyze_last_move_no_moves(self) -> None: + """Test analyzing last move with no moves.""" + mock_engine = MagicMock() + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + + game = chess.pgn.Game() + board = game.board() + + with patch( + "python_pkg.stockfish_analysis.analyze_chess_game._logger" + ) as mock_logger: + _analyze_last_move(game, board, ctx) + mock_logger.warning.assert_called_once() + + def test_analyze_last_move_with_moves(self) -> None: + """Test analyzing last move with actual moves.""" + mock_engine = MagicMock() + + def mock_analyse( + board: chess.Board, **_kwargs: object + ) -> list[dict[str, object]]: + """Return a legal move for the given position.""" + legal_moves = list(board.legal_moves) + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + pv = [legal_moves[0]] if legal_moves else [] + return [{"pv": pv, "score": mock_score}] + + mock_engine.analyse.side_effect = mock_analyse + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + + game = chess.pgn.Game() + node = game.add_variation(chess.Move.from_uci("e2e4")) + node.add_variation(chess.Move.from_uci("e7e5")) + board = game.board() + + _analyze_last_move(game, board, ctx) + + +class TestAnalyzeAllMoves: + """Tests for _analyze_all_moves function.""" + + def test_analyze_all_moves(self) -> None: + """Test analyzing all moves.""" + mock_engine = MagicMock() + mock_move = chess.Move.from_uci("e2e4") + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}] + + ctx = AnalysisContext( + engine=mock_engine, + limit=chess.engine.Limit(time=0.1), + multipv=2, + ) + + game = chess.pgn.Game() + game.add_variation(chess.Move.from_uci("e2e4")) + board = game.board() + + _analyze_all_moves(game, board, ctx) + + +class TestMain: + """Tests for main function.""" + + def test_main(self, tmp_path: Path) -> None: + """Test main function.""" + pgn_file = tmp_path / "game.pgn" + pgn_file.write_text('[Event "Test"]\n\n1. e4 *') + + mock_engine = MagicMock() + mock_move = chess.Move.from_uci("e2e4") + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}] + mock_engine.options = {} + + with ( + patch("sys.argv", ["prog", str(pgn_file)]), + patch( + "chess.engine.SimpleEngine.popen_uci", + return_value=mock_engine, + ), + ): + main() + mock_engine.quit.assert_called_once() + + def test_main_last_move_only(self, tmp_path: Path) -> None: + """Test main function with --last-move-only flag.""" + pgn_file = tmp_path / "game.pgn" + pgn_file.write_text('[Event "Test"]\n\n1. e4 e5 2. Nf3 *') + + mock_engine = MagicMock() + + def mock_analyse( + board: chess.Board, **_kwargs: object + ) -> list[dict[str, object]]: + legal_moves = list(board.legal_moves) + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + pv = [legal_moves[0]] if legal_moves else [] + return [{"pv": pv, "score": mock_score}] + + mock_engine.analyse.side_effect = mock_analyse + mock_engine.options = {} + + with ( + patch("sys.argv", ["prog", str(pgn_file), "--last-move-only"]), + patch( + "chess.engine.SimpleEngine.popen_uci", + return_value=mock_engine, + ), + ): + main() + mock_engine.quit.assert_called_once() + + def test_main_with_engine_options_attr_error(self, tmp_path: Path) -> None: + """Test main when engine.options raises AttributeError.""" + pgn_file = tmp_path / "game.pgn" + pgn_file.write_text('[Event "Test"]\n\n1. e4 *') + + mock_engine = MagicMock() + mock_move = chess.Move.from_uci("e2e4") + mock_score = MagicMock() + mock_pov = MagicMock() + mock_pov.is_mate.return_value = False + mock_pov.score.return_value = 30 + mock_score.pov.return_value = mock_pov + mock_engine.analyse.return_value = [{"pv": [mock_move], "score": mock_score}] + + # Delete auto-created options, then set up PropertyMock to raise + from unittest.mock import PropertyMock + + del mock_engine.options + type(mock_engine).options = PropertyMock(side_effect=AttributeError) + + with ( + patch("sys.argv", ["prog", str(pgn_file)]), + patch( + "chess.engine.SimpleEngine.popen_uci", + return_value=mock_engine, + ), + ): + main() + mock_engine.quit.assert_called_once()