testsAndMisc-archive/python_pkg/lichess_bot/tests/test_main_analysis.py
Krzysztof kuhy Rudnicki 996617d4a0 test: achieve 100% branch coverage across all python_pkg packages
- Add comprehensive tests for all packages (3572 tests, 100% branch coverage)
- Split oversized test files to stay under 500-line limit
- Add per-file ruff ignores for test-appropriate suppressions
- Fix _cache_decks.py to properly convert JSON lists to tuples
- Add session-scoped conftest fixture for logging handler cleanup (Python 3.14)
- Update ruff pre-commit hook to v0.15.2
- Add codespell ignore words for test data
- Add generated output files to .gitignore
2026-03-21 17:51:36 +01:00

412 lines
16 KiB
Python

"""Tests for lichess_bot main module: game events and analysis."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, PropertyMock, patch
import chess
import pytest
from python_pkg.lichess_bot.main import (
BotContext,
GameMeta,
GameState,
_collect_analysis_lines,
_finalize_game,
_insert_analysis_into_log,
_log_analysis_progress,
_process_analysis_output,
_process_game_event,
_run_analysis_subprocess,
_write_pgn_to_log,
)
if TYPE_CHECKING:
from pathlib import Path
# Type alias to make mypy happy with test event dicts
Event = dict[str, Any]
class TestProcessGameEvent:
"""Tests for _process_game_event."""
def test_process_game_event_unhandled_type(self) -> None:
"""Test processing unhandled event type."""
ctx = MagicMock()
state = GameState()
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {"type": "chatLine", "text": "hello"}
result = _process_game_event(event, ctx, state, meta)
assert result is True
def test_process_game_event_game_full(self) -> None:
"""Test processing gameFull event."""
api = MagicMock()
api.get_my_user_id.return_value = "mybot"
engine = MagicMock()
engine.max_time_sec = 5.0
engine.choose_move_with_explanation.return_value = (
chess.Move.from_uci("e2e4"),
"opening",
)
ctx = BotContext(api=api, engine=engine, bot_version=1)
state = GameState()
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {
"type": "gameFull",
"state": {"moves": "", "status": "started"},
"white": {"id": "mybot"},
"black": {"id": "opp"},
}
result = _process_game_event(event, ctx, state, meta)
assert result is True
def test_process_game_event_game_end(self) -> None:
"""Test processing game end event."""
api = MagicMock()
api.get_my_user_id.return_value = "mybot"
engine = MagicMock()
engine.max_time_sec = 5.0
engine.choose_move_with_explanation.return_value = (None, "no moves")
ctx = BotContext(api=api, engine=engine, bot_version=1)
state = GameState(color="white", last_handled_len=-1)
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {
"type": "gameState",
"moves": "e2e4 e7e5",
"status": "mate",
}
result = _process_game_event(event, ctx, state, meta)
assert result is False
def test_process_game_event_game_end_after_move(self) -> None:
"""Test game ends with status after handling move.
This covers the case where _handle_move_if_needed returns True
but status indicates game end.
"""
api = MagicMock()
api.get_my_user_id.return_value = "mybot"
engine = MagicMock()
engine.max_time_sec = 5.0
engine.choose_move_with_explanation.return_value = (
chess.Move.from_uci("d2d4"),
"response",
)
ctx = BotContext(api=api, engine=engine, bot_version=1)
# Black's turn - it's opponent's move, so we don't need to move
state = GameState(color="black", last_handled_len=-1)
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {
"type": "gameState",
"moves": "e2e4", # One move - now it's black's turn
"status": "resign", # Game ended with resign
}
result = _process_game_event(event, ctx, state, meta)
assert result is False # Game should end
def test_process_game_event_unchanged_position(self) -> None:
"""Test processing event with unchanged position."""
api = MagicMock()
ctx = BotContext(api=api, engine=MagicMock(), bot_version=1)
state = GameState(last_handled_len=2, color="white")
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {"type": "gameState", "moves": "e2e4 e7e5"}
result = _process_game_event(event, ctx, state, meta)
assert result is True
def test_process_game_event_color_unknown(self) -> None:
"""Test processing event with unknown color."""
api = MagicMock()
api.get_my_user_id.return_value = "mybot"
ctx = BotContext(api=api, engine=MagicMock(), bot_version=1)
state = GameState(last_handled_len=-1)
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {"type": "gameState", "moves": "e2e4"}
result = _process_game_event(event, ctx, state, meta)
assert result is True
assert state.last_handled_len == 1
def test_process_game_event_color_unknown_on_gamefull(self) -> None:
"""Test processing gameFull event with still unknown color.
This covers the branch where event_type is gameFull but color
is not determined (e.g., spectator watching game).
"""
api = MagicMock()
# Return a user id that doesn't match either player
api.get_my_user_id.return_value = "spectator"
ctx = BotContext(api=api, engine=MagicMock(), bot_version=1)
state = GameState(last_handled_len=-1)
meta = GameMeta(game_id="game1", bot_version=1)
event: Event = {
"type": "gameFull",
"state": {"moves": "e2e4", "status": "started"},
"white": {"id": "player1"},
"black": {"id": "player2"},
}
result = _process_game_event(event, ctx, state, meta)
assert result is True
# last_handled_len should NOT be updated for gameFull with unknown color
assert state.last_handled_len == -1
class TestWritePgnToLog:
"""Tests for _write_pgn_to_log."""
def test_write_pgn_to_log(self, tmp_path: Path) -> None:
"""Test writing PGN to log file."""
log_path = tmp_path / "game.log"
log_path.write_text("header\n")
board = chess.Board()
board.push_uci("e2e4")
meta = GameMeta(
game_id="game1",
bot_version=1,
site_url="https://lichess.org/game1",
date_iso="2021.01.01",
white_name="White",
black_name="Black",
)
_write_pgn_to_log(log_path, board, meta)
content = log_path.read_text()
assert "PGN:" in content
assert "e4" in content
class TestRunAnalysisSubprocess:
"""Tests for _run_analysis_subprocess."""
def test_run_analysis_subprocess_script_not_found(self, tmp_path: Path) -> None:
"""Test analysis when script not found."""
log_path = tmp_path / "game.log"
with patch("python_pkg.lichess_bot.main.Path") as mock_path:
mock_script = MagicMock()
mock_script.is_file.return_value = False
resolve = mock_path.return_value.resolve.return_value
resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = (
mock_script
)
result = _run_analysis_subprocess("game1", log_path, 10)
assert result is None
def test_run_analysis_subprocess_success(self, tmp_path: Path) -> None:
"""Test successful analysis subprocess."""
log_path = tmp_path / "game.log"
log_path.write_text("test")
mock_proc = MagicMock()
mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"])
mock_proc.stderr.read.return_value = ""
mock_proc.wait.return_value = 0
mock_proc.__enter__ = MagicMock(return_value=mock_proc)
mock_proc.__exit__ = MagicMock(return_value=False)
with (
patch("python_pkg.lichess_bot.main.Path") as mock_path,
patch(
"python_pkg.lichess_bot.main.subprocess.Popen", return_value=mock_proc
),
):
mock_script = MagicMock()
mock_script.is_file.return_value = True
resolve = mock_path.return_value.resolve.return_value
resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = (
mock_script
)
result = _run_analysis_subprocess("game1", log_path, 2)
assert result is not None
class TestProcessAnalysisOutput:
"""Tests for _process_analysis_output."""
def test_process_analysis_output_success(self) -> None:
"""Test processing analysis output successfully."""
mock_proc = MagicMock()
mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"])
mock_proc.stderr.read.return_value = ""
mock_proc.wait.return_value = 0
result = _process_analysis_output(mock_proc, "game1", 2)
assert result is not None
assert "e4" in result
def test_process_analysis_output_error_exit(self) -> None:
"""Test processing analysis output with error exit."""
mock_proc = MagicMock()
mock_proc.stdout = iter(["output\n"])
mock_proc.stderr.read.return_value = "error message"
mock_proc.wait.return_value = 1
result = _process_analysis_output(mock_proc, "game1", 1)
assert result is not None
assert "stderr" in result
def test_process_analysis_output_error_exit_no_stderr(self) -> None:
"""Test processing analysis output with error exit but no stderr."""
mock_proc = MagicMock()
mock_proc.stdout = iter(["output\n"])
mock_proc.stderr.read.return_value = ""
mock_proc.wait.return_value = 1
result = _process_analysis_output(mock_proc, "game1", 1)
assert result is not None
assert "stderr" not in result
def test_process_analysis_output_none_pipes(self) -> None:
"""Test processing analysis output with None pipes."""
mock_proc = MagicMock()
mock_proc.stdout = None
mock_proc.stderr = None
with pytest.raises(RuntimeError, match="pipes unexpectedly None"):
_process_analysis_output(mock_proc, "game1", 1)
class TestCollectAnalysisLines:
"""Tests for _collect_analysis_lines helper."""
def test_collect_analysis_lines_empty_iterator(self) -> None:
"""Test collecting lines from empty iterator."""
empty_iter: list[str] = []
analyzed, lines = _collect_analysis_lines(iter(empty_iter), "game1", 10)
assert analyzed == 0
assert lines == []
def test_collect_analysis_lines_with_content(self) -> None:
"""Test collecting lines from iterator with content."""
content = [" 1 e4\n", " 2 e5\n", "not a ply line\n"]
analyzed, lines = _collect_analysis_lines(iter(content), "game1", 3)
assert analyzed == 2
assert lines == content
def test_collect_analysis_lines_full_iteration(self) -> None:
"""Test that all lines are collected."""
content = ["line1\n", " 3 Nf3\n", "line3\n"]
analyzed, lines = _collect_analysis_lines(iter(content), "game1", 1)
assert analyzed == 1
assert len(lines) == 3
class TestLogAnalysisProgress:
"""Tests for _log_analysis_progress."""
def test_log_analysis_progress_with_total(self) -> None:
"""Test logging progress with known total."""
with patch("python_pkg.lichess_bot.main._logger") as mock_logger:
_log_analysis_progress("game1", 5, 10)
mock_logger.info.assert_called_once()
call_args = mock_logger.info.call_args[0]
assert "50%" in call_args[0] % call_args[1:]
def test_log_analysis_progress_zero_total(self) -> None:
"""Test logging progress with zero total."""
with patch("python_pkg.lichess_bot.main._logger") as mock_logger:
_log_analysis_progress("game1", 5, 0)
mock_logger.info.assert_called_once()
call_args = mock_logger.info.call_args[0]
assert "unknown" in call_args[0]
class TestInsertAnalysisIntoLog:
"""Tests for _insert_analysis_into_log."""
def test_insert_analysis_before_pgn(self, tmp_path: Path) -> None:
"""Test inserting analysis before PGN section."""
log_path = tmp_path / "game.log"
log_path.write_text("header\n\nPGN:\n1. e4\n")
meta = GameMeta(
game_id="game1",
bot_version=1,
date_iso="2021.01.01",
white_name="White",
black_name="Black",
)
_insert_analysis_into_log(log_path, "Analysis here", meta)
content = log_path.read_text()
assert "ANALYSIS:" in content
assert content.index("ANALYSIS:") < content.index("PGN:")
def test_insert_analysis_at_start(self, tmp_path: Path) -> None:
"""Test inserting analysis when PGN at start."""
log_path = tmp_path / "game.log"
log_path.write_text("PGN:\n1. e4\n")
meta = GameMeta(game_id="game1", bot_version=1)
_insert_analysis_into_log(log_path, "Analysis here", meta)
content = log_path.read_text()
assert "ANALYSIS:" in content
def test_insert_analysis_no_pgn(self, tmp_path: Path) -> None:
"""Test inserting analysis when no PGN section."""
log_path = tmp_path / "game.log"
log_path.write_text("header\n")
meta = GameMeta(game_id="game1", bot_version=1)
_insert_analysis_into_log(log_path, "Analysis here", meta)
content = log_path.read_text()
assert "ANALYSIS:" in content
def test_insert_analysis_oserror(self, tmp_path: Path) -> None:
"""Test inserting analysis with OSError."""
log_path = tmp_path / "nonexistent" / "game.log"
meta = GameMeta(game_id="game1", bot_version=1)
# Should not raise, just log debug
_insert_analysis_into_log(log_path, "Analysis", meta)
class TestFinalizeGame:
"""Tests for _finalize_game."""
def test_finalize_game_no_log_path(self) -> None:
"""Test finalize game with no log path."""
state = GameState(log_path=None)
meta = GameMeta(game_id="game1", bot_version=1)
_finalize_game(state, meta) # Should not raise
def test_finalize_game_write_error(self, tmp_path: Path) -> None:
"""Test finalize game with write error."""
log_path = tmp_path / "game.log"
log_path.write_text("header")
state = GameState(log_path=log_path)
meta = GameMeta(game_id="game1", bot_version=1)
with patch(
"python_pkg.lichess_bot.main._write_pgn_to_log",
side_effect=OSError("error"),
):
_finalize_game(state, meta) # Should not raise
def test_finalize_game_type_error_on_move_stack(self, tmp_path: Path) -> None:
"""Test finalize game with TypeError on move_stack."""
log_path = tmp_path / "game.log"
log_path.write_text("header\n")
state = GameState(log_path=log_path)
meta = GameMeta(game_id="game1", bot_version=1)
mock_board = MagicMock()
# Use PropertyMock to raise TypeError when move_stack is accessed
type(mock_board).move_stack = PropertyMock(side_effect=TypeError())
state.board = mock_board
with patch("python_pkg.lichess_bot.main._write_pgn_to_log"):
_finalize_game(state, meta) # Should not raise
def test_finalize_game_analysis_error(self, tmp_path: Path) -> None:
"""Test finalize game with analysis error."""
log_path = tmp_path / "game.log"
log_path.write_text("header\n")
state = GameState(log_path=log_path)
meta = GameMeta(game_id="game1", bot_version=1)
with (
patch("python_pkg.lichess_bot.main._write_pgn_to_log"),
patch(
"python_pkg.lichess_bot.main._run_analysis_subprocess",
side_effect=OSError("error"),
),
):
_finalize_game(state, meta) # Should not raise