testsAndMisc/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py
Krzysztof kuhy Rudnicki 1652c24c1e Add comprehensive tests for stockfish_analysis (100% coverage)
- Create test_analyze_chess_game.py with 90 tests covering all functions
- Add tests for PGN extraction, score conversion, move classification
- Add tests for engine configuration, memory detection, analysis
- Add pragma: no branch for unreachable code paths
- Update pyproject.toml with coverage exclusions
- All pre-commit hooks pass (ruff, mypy, pylint, bandit)
2025-12-02 22:58:06 +01:00

1153 lines
39 KiB
Python

"""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()