mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 20:43:02 +02:00
- Add test_engine.py with 100% coverage for engine.py - Tests for Engine class initialization - Tests for choose_move with various scenarios - Tests for best move parsing and validation - Tests for checkmate and stalemate detection - Add test_lichess_api.py with 98% coverage for lichess_api.py - Tests for API initialization and request handling - Tests for stream_events with proper infinite loop handling - Tests for challenge accept/decline - Tests for game streaming and move submission - Tests for rate limit handling and retry logic - Remove unreachable return statement in lichess_api.make_move Coverage: engine.py 17% -> 100%, lichess_api.py 0% -> 98%
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Unit tests for lichess_bot engine module."""
|
|
|
|
# ruff: noqa: SLF001
|
|
# Tests need to access private members to verify internal logic
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import chess
|
|
import pytest
|
|
|
|
from python_pkg.lichess_bot.engine import RandomEngine
|
|
|
|
|
|
class TestRandomEngineInit:
|
|
"""Tests for RandomEngine initialization."""
|
|
|
|
def test_init_with_missing_engine_raises(self) -> None:
|
|
"""Test that missing engine raises FileNotFoundError."""
|
|
with pytest.raises(FileNotFoundError, match="C engine not found"):
|
|
RandomEngine(engine_path="/nonexistent/path/to/engine")
|
|
|
|
def test_init_with_non_executable_raises(self, tmp_path: Path) -> None:
|
|
"""Test that non-executable engine raises FileNotFoundError."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("not executable")
|
|
with pytest.raises(FileNotFoundError, match="not executable"):
|
|
RandomEngine(engine_path=str(fake_engine))
|
|
|
|
def test_init_with_valid_engine(self, tmp_path: Path) -> None:
|
|
"""Test successful initialization with valid engine."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("#!/bin/bash\necho test")
|
|
fake_engine.chmod(0o755)
|
|
|
|
engine = RandomEngine(engine_path=str(fake_engine), max_time_sec=1.0, depth=5)
|
|
|
|
assert engine.engine_path == fake_engine
|
|
assert engine.max_time_sec == 1.0
|
|
assert engine.depth == 5
|
|
|
|
|
|
class TestCallEngine:
|
|
"""Tests for _call_engine method."""
|
|
|
|
@pytest.fixture
|
|
def mock_engine(self, tmp_path: Path) -> RandomEngine:
|
|
"""Create an engine with a mock executable."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("#!/bin/bash\necho test")
|
|
fake_engine.chmod(0o755)
|
|
return RandomEngine(engine_path=str(fake_engine))
|
|
|
|
def test_call_engine_success(self, mock_engine: RandomEngine) -> None:
|
|
"""Test successful engine call."""
|
|
mock_result = MagicMock()
|
|
mock_result.stdout = "e2e4\n"
|
|
|
|
with patch("subprocess.run", return_value=mock_result):
|
|
result = mock_engine._call_engine(["--test"], timeout=1.0)
|
|
|
|
assert result == "e2e4"
|
|
|
|
def test_call_engine_called_process_error(self, mock_engine: RandomEngine) -> None:
|
|
"""Test engine call with CalledProcessError."""
|
|
error = subprocess.CalledProcessError(1, "cmd", stderr="engine error")
|
|
|
|
with (
|
|
patch("subprocess.run", side_effect=error),
|
|
pytest.raises(RuntimeError, match="C engine failed"),
|
|
):
|
|
mock_engine._call_engine(["--test"], timeout=1.0)
|
|
|
|
def test_call_engine_timeout(self, mock_engine: RandomEngine) -> None:
|
|
"""Test engine call timeout."""
|
|
error = subprocess.TimeoutExpired("cmd", 1.0)
|
|
|
|
with (
|
|
patch("subprocess.run", side_effect=error),
|
|
pytest.raises(TimeoutError, match="C engine timed out"),
|
|
):
|
|
mock_engine._call_engine(["--test"], timeout=1.0)
|
|
|
|
|
|
class TestChooseMove:
|
|
"""Tests for choose_move methods."""
|
|
|
|
@pytest.fixture
|
|
def mock_engine(self, tmp_path: Path) -> RandomEngine:
|
|
"""Create an engine with a mock executable."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("#!/bin/bash\necho test")
|
|
fake_engine.chmod(0o755)
|
|
return RandomEngine(engine_path=str(fake_engine))
|
|
|
|
def test_choose_move_returns_valid_move(self, mock_engine: RandomEngine) -> None:
|
|
"""Test choose_move returns a valid move."""
|
|
board = chess.Board()
|
|
|
|
with patch.object(mock_engine, "_call_engine", return_value="e2e4"):
|
|
move = mock_engine.choose_move(board)
|
|
|
|
assert move == chess.Move.from_uci("e2e4")
|
|
assert move in board.legal_moves
|
|
|
|
def test_choose_move_with_explanation_no_legal_moves(
|
|
self, mock_engine: RandomEngine
|
|
) -> None:
|
|
"""Test choose_move_with_explanation when no legal moves."""
|
|
# Create a checkmate position - black king checkmated by rook
|
|
board = chess.Board("k7/2K5/8/8/8/8/8/R7 b - - 0 1")
|
|
|
|
move, reason = mock_engine.choose_move_with_explanation(
|
|
board, time_budget_sec=1.0
|
|
)
|
|
|
|
assert move is None
|
|
assert reason == "no_legal_moves"
|
|
|
|
def test_choose_move_with_explanation_invalid_move(
|
|
self, mock_engine: RandomEngine
|
|
) -> None:
|
|
"""Test choose_move_with_explanation with invalid move from engine."""
|
|
board = chess.Board()
|
|
|
|
with (
|
|
patch.object(mock_engine, "_call_engine", return_value="invalid"),
|
|
pytest.raises(RuntimeError, match="Engine returned invalid move"),
|
|
):
|
|
mock_engine.choose_move_with_explanation(board, time_budget_sec=1.0)
|
|
|
|
def test_choose_move_with_explanation_illegal_move(
|
|
self, mock_engine: RandomEngine
|
|
) -> None:
|
|
"""Test choose_move_with_explanation with illegal move from engine."""
|
|
board = chess.Board()
|
|
|
|
# e2e5 is a valid UCI format but illegal from starting position
|
|
with (
|
|
patch.object(mock_engine, "_call_engine", return_value="e2e5"),
|
|
pytest.raises(RuntimeError, match="Engine returned illegal move"),
|
|
):
|
|
mock_engine.choose_move_with_explanation(board, time_budget_sec=1.0)
|
|
|
|
|
|
class TestParseEngineAnalysis:
|
|
"""Tests for _parse_engine_analysis method."""
|
|
|
|
@pytest.fixture
|
|
def mock_engine(self, tmp_path: Path) -> RandomEngine:
|
|
"""Create an engine with a mock executable."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("#!/bin/bash\necho test")
|
|
fake_engine.chmod(0o755)
|
|
return RandomEngine(engine_path=str(fake_engine))
|
|
|
|
def test_parse_valid_json(self, mock_engine: RandomEngine) -> None:
|
|
"""Test parsing valid JSON output."""
|
|
board = chess.Board()
|
|
legal_moves = list(board.legal_moves)
|
|
output = json.dumps(
|
|
{
|
|
"analyze": {"candidate_score": 0.5},
|
|
"chosen_move": "e2e4",
|
|
"chosen_index": 0,
|
|
}
|
|
)
|
|
|
|
score, cand_expl, best_move, best_expl = mock_engine._parse_engine_analysis(
|
|
output, legal_moves
|
|
)
|
|
|
|
assert score == 0.5
|
|
assert best_move == chess.Move.from_uci("e2e4")
|
|
assert "candidate_score" in cand_expl
|
|
assert "chosen_move" in best_expl
|
|
|
|
def test_parse_invalid_json(self, mock_engine: RandomEngine) -> None:
|
|
"""Test parsing invalid JSON output."""
|
|
board = chess.Board()
|
|
legal_moves = list(board.legal_moves)
|
|
|
|
score, cand_expl, best_move, _best_expl = mock_engine._parse_engine_analysis(
|
|
"not json", legal_moves
|
|
)
|
|
|
|
assert score == 0.0
|
|
assert best_move is None
|
|
assert cand_expl == "not json"
|
|
|
|
def test_parse_json_with_illegal_move(self, mock_engine: RandomEngine) -> None:
|
|
"""Test parsing JSON with illegal move."""
|
|
legal_moves = [chess.Move.from_uci("e2e4")]
|
|
output = json.dumps(
|
|
{
|
|
"analyze": {"candidate_score": 1.0},
|
|
"chosen_move": "a1a8", # Not in legal moves
|
|
"chosen_index": 0,
|
|
}
|
|
)
|
|
|
|
score, _cand_expl, best_move, _best_expl = mock_engine._parse_engine_analysis(
|
|
output, legal_moves
|
|
)
|
|
|
|
assert score == 1.0
|
|
assert best_move is None # Move not in legal moves
|
|
|
|
def test_parse_json_without_chosen_move(self, mock_engine: RandomEngine) -> None:
|
|
"""Test parsing JSON without chosen_move field."""
|
|
legal_moves = [chess.Move.from_uci("e2e4")]
|
|
output = json.dumps(
|
|
{
|
|
"analyze": {"candidate_score": 0.7},
|
|
"chosen_index": 0,
|
|
# No chosen_move field
|
|
}
|
|
)
|
|
|
|
score, _cand_expl, best_move, _best_expl = mock_engine._parse_engine_analysis(
|
|
output, legal_moves
|
|
)
|
|
|
|
assert score == 0.7
|
|
assert best_move is None
|
|
|
|
def test_parse_json_without_score(self, mock_engine: RandomEngine) -> None:
|
|
"""Test parsing JSON without candidate_score field."""
|
|
board = chess.Board()
|
|
legal_moves = list(board.legal_moves)
|
|
output = json.dumps(
|
|
{
|
|
"analyze": {}, # No candidate_score
|
|
"chosen_move": "e2e4",
|
|
"chosen_index": 0,
|
|
}
|
|
)
|
|
|
|
score, _cand_expl, best_move, _best_expl = mock_engine._parse_engine_analysis(
|
|
output, legal_moves
|
|
)
|
|
|
|
assert score == 0.0 # Default score
|
|
assert best_move == chess.Move.from_uci("e2e4")
|
|
|
|
|
|
class TestEvaluateProposedMove:
|
|
"""Tests for evaluate_proposed_move_with_suggestion method."""
|
|
|
|
@pytest.fixture
|
|
def mock_engine(self, tmp_path: Path) -> RandomEngine:
|
|
"""Create an engine with a mock executable."""
|
|
fake_engine = tmp_path / "fake_engine"
|
|
fake_engine.write_text("#!/bin/bash\necho test")
|
|
fake_engine.chmod(0o755)
|
|
return RandomEngine(engine_path=str(fake_engine))
|
|
|
|
def test_evaluate_no_legal_moves(self, mock_engine: RandomEngine) -> None:
|
|
"""Test evaluate when no legal moves available."""
|
|
# Create a checkmate position - black king checkmated by rook
|
|
board = chess.Board("k7/2K5/8/8/8/8/8/R7 b - - 0 1")
|
|
|
|
score, cand_expl, best_move, best_expl = (
|
|
mock_engine.evaluate_proposed_move_with_suggestion(
|
|
board, "e1e2", time_budget_sec=1.0
|
|
)
|
|
)
|
|
|
|
assert score == 0.0
|
|
assert cand_expl == "no_legal_moves"
|
|
assert best_move is None
|
|
assert best_expl == "no_best_move"
|
|
|
|
assert score == 0.0
|
|
assert cand_expl == "no_legal_moves"
|
|
assert best_move is None
|
|
assert best_expl == "no_best_move"
|
|
|
|
def test_evaluate_with_valid_position(self, mock_engine: RandomEngine) -> None:
|
|
"""Test evaluate with a valid position."""
|
|
board = chess.Board()
|
|
output = json.dumps(
|
|
{
|
|
"analyze": {"candidate_score": 0.3},
|
|
"chosen_move": "e2e4",
|
|
"chosen_index": 0,
|
|
}
|
|
)
|
|
|
|
with patch.object(mock_engine, "_call_engine", return_value=output):
|
|
score, _cand_expl, best_move, _best_expl = (
|
|
mock_engine.evaluate_proposed_move_with_suggestion(
|
|
board, "d2d4", time_budget_sec=1.0
|
|
)
|
|
)
|
|
|
|
assert score == 0.3
|
|
assert best_move == chess.Move.from_uci("e2e4")
|