testsAndMisc-archive/python_pkg/stockfish_analysis/tests/test_analyze_chess_game.py

362 lines
13 KiB
Python
Raw Normal View History

"""Tests for analyze_chess_game utility and scoring functions."""
from __future__ import annotations
import argparse
from unittest.mock import MagicMock, mock_open, patch
import chess
import chess.engine
import pytest
from python_pkg.stockfish_analysis.analyze_chess_game import (
_auto_hash_mb,
_detect_total_mem_mb,
_parse_hash_mb,
_parse_threads,
classify_cp_loss,
extract_pgn_text,
fmt_eval,
score_to_cp,
)
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