montana/Русский/Тесты/test_breathing_sync.py

614 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
test_breathing_sync.py — Unit tests для breathing_sync.py
Montana Protocol
Тестирование механизма Breathing Synchronization (12-секундный цикл git sync)
"""
import sys
import os
import unittest
import asyncio
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock, call
from datetime import datetime, timezone
# Добавляем путь к модулю
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../бот'))
from breathing_sync import (
BreathingConfig,
BreathingSync,
get_breathing_sync
)
class TestBreathingConfig(unittest.TestCase):
"""Тесты конфигурации Breathing Sync"""
# ═══════════════════════════════════════════════════════════════════════
# КОНСТАНТЫ
# ═══════════════════════════════════════════════════════════════════════
def test_sync_interval(self):
"""Интервал синхронизации = 12 секунд"""
self.assertEqual(BreathingConfig.SYNC_INTERVAL_SEC, 12)
def test_startup_delay(self):
"""Задержка перед первой синхронизацией = 5 секунд"""
self.assertEqual(BreathingConfig.STARTUP_DELAY_SEC, 5)
def test_remote_name(self):
"""Git remote = origin"""
self.assertEqual(BreathingConfig.REMOTE_NAME, "origin")
def test_branch_name(self):
"""Git branch = main"""
self.assertEqual(BreathingConfig.BRANCH_NAME, "main")
def test_git_timeout(self):
"""Таймаут git операций = 30 секунд"""
self.assertEqual(BreathingConfig.GIT_TIMEOUT_SEC, 30)
def test_sync_paths(self):
"""Проверка списка синхронизируемых файлов"""
self.assertIsInstance(BreathingConfig.SYNC_PATHS, list)
self.assertIn("data/users.json", BreathingConfig.SYNC_PATHS)
self.assertIn("node_crypto/nodes.json", BreathingConfig.SYNC_PATHS)
class TestBreathingSyncInit(unittest.TestCase):
"""Тесты инициализации BreathingSync"""
# ═══════════════════════════════════════════════════════════════════════
# INITIALIZATION
# ═══════════════════════════════════════════════════════════════════════
def test_init_default_path(self):
"""Инициализация с дефолтным путём"""
sync = BreathingSync()
self.assertIsNotNone(sync.repo_path)
self.assertIsInstance(sync.repo_path, Path)
self.assertFalse(sync._running)
def test_init_custom_path(self):
"""Инициализация с кастомным путём"""
custom_path = Path("/tmp/montana_test")
sync = BreathingSync(repo_path=custom_path)
self.assertEqual(sync.repo_path, custom_path)
def test_init_stats(self):
"""Инициализация создаёт статистику"""
sync = BreathingSync()
self.assertIsInstance(sync.stats, dict)
self.assertEqual(sync.stats["total_inhales"], 0)
self.assertEqual(sync.stats["total_exhales"], 0)
self.assertEqual(sync.stats["failed_inhales"], 0)
self.assertEqual(sync.stats["failed_exhales"], 0)
self.assertIsNone(sync.stats["last_inhale"])
self.assertIsNone(sync.stats["last_exhale"])
self.assertIsNone(sync.stats["last_error"])
def test_init_stop_event(self):
"""Инициализация создаёт stop event"""
sync = BreathingSync()
self.assertIsInstance(sync._stop_event, asyncio.Event)
self.assertFalse(sync._stop_event.is_set())
class TestBreathingSyncGitCommand(unittest.TestCase):
"""Тесты выполнения git команд"""
def setUp(self):
"""Создаём BreathingSync перед каждым тестом"""
self.sync = BreathingSync()
# ═══════════════════════════════════════════════════════════════════════
# GIT COMMAND EXECUTION
# ═══════════════════════════════════════════════════════════════════════
@patch('breathing_sync.subprocess.run')
def test_run_git_command_success(self, mock_run):
"""Успешное выполнение git команды"""
mock_run.return_value = Mock(
returncode=0,
stdout="Already up to date.",
stderr=""
)
result = self.sync._run_git_command(["pull"])
self.assertTrue(result["success"])
self.assertEqual(result["returncode"], 0)
self.assertEqual(result["stdout"], "Already up to date.")
self.assertEqual(result["stderr"], "")
@patch('breathing_sync.subprocess.run')
def test_run_git_command_failure(self, mock_run):
"""Неудачное выполнение git команды"""
mock_run.return_value = Mock(
returncode=1,
stdout="",
stderr="fatal: not a git repository"
)
result = self.sync._run_git_command(["status"])
self.assertFalse(result["success"])
self.assertEqual(result["returncode"], 1)
self.assertIn("not a git repository", result["stderr"])
@patch('breathing_sync.subprocess.run')
def test_run_git_command_timeout(self, mock_run):
"""Таймаут git команды"""
from subprocess import TimeoutExpired
mock_run.side_effect = TimeoutExpired("git", 30)
result = self.sync._run_git_command(["pull"], timeout=30)
self.assertFalse(result["success"])
self.assertEqual(result["returncode"], -1)
self.assertIn("Timeout", result["stderr"])
@patch('breathing_sync.subprocess.run')
def test_run_git_command_exception(self, mock_run):
"""Исключение при выполнении git команды"""
mock_run.side_effect = Exception("Network error")
result = self.sync._run_git_command(["fetch"])
self.assertFalse(result["success"])
self.assertEqual(result["returncode"], -1)
self.assertIn("Network error", result["stderr"])
@patch('breathing_sync.subprocess.run')
def test_run_git_command_uses_correct_path(self, mock_run):
"""Git команда выполняется в правильной директории"""
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
custom_path = Path("/custom/repo")
sync = BreathingSync(repo_path=custom_path)
sync._run_git_command(["status"])
# Проверяем что git вызван с правильным -C флагом
call_args = mock_run.call_args[0][0]
self.assertIn("-C", call_args)
self.assertIn(str(custom_path), call_args)
class TestBreathingSyncInhale(unittest.TestCase):
"""Тесты inhale (git pull)"""
def setUp(self):
"""Создаём BreathingSync перед каждым тестом"""
self.sync = BreathingSync()
# ═══════════════════════════════════════════════════════════════════════
# INHALE (GIT PULL)
# ═══════════════════════════════════════════════════════════════════════
@patch.object(BreathingSync, '_run_git_command')
def test_inhale_success(self, mock_git):
"""Успешный inhale (получены изменения)"""
mock_git.return_value = {
"success": True,
"stdout": "Updating abc123..def456",
"stderr": "",
"returncode": 0
}
result = self.sync.inhale()
self.assertTrue(result)
self.assertEqual(self.sync.stats["total_inhales"], 1)
self.assertEqual(self.sync.stats["failed_inhales"], 0)
self.assertIsNotNone(self.sync.stats["last_inhale"])
@patch.object(BreathingSync, '_run_git_command')
def test_inhale_already_up_to_date(self, mock_git):
"""Inhale когда уже up to date"""
mock_git.return_value = {
"success": True,
"stdout": "Already up to date.",
"stderr": "",
"returncode": 0
}
result = self.sync.inhale()
self.assertTrue(result)
self.assertEqual(self.sync.stats["total_inhales"], 1)
@patch.object(BreathingSync, '_run_git_command')
def test_inhale_failure(self, mock_git):
"""Неудачный inhale"""
mock_git.return_value = {
"success": False,
"stdout": "",
"stderr": "error: cannot pull",
"returncode": 1
}
result = self.sync.inhale()
self.assertFalse(result)
self.assertEqual(self.sync.stats["total_inhales"], 1)
self.assertEqual(self.sync.stats["failed_inhales"], 1)
self.assertEqual(self.sync.stats["last_error"], "error: cannot pull")
@patch.object(BreathingSync, '_run_git_command')
def test_inhale_uses_rebase(self, mock_git):
"""Inhale использует --rebase"""
mock_git.return_value = {
"success": True,
"stdout": "Already up to date.",
"stderr": "",
"returncode": 0
}
self.sync.inhale()
# Проверяем что вызван git pull с --rebase
call_args = mock_git.call_args[0][0]
self.assertIn("pull", call_args)
self.assertIn("--rebase", call_args)
self.assertIn("origin", call_args)
self.assertIn("main", call_args)
class TestBreathingSyncExhale(unittest.TestCase):
"""Тесты exhale (git push)"""
def setUp(self):
"""Создаём BreathingSync перед каждым тестом"""
self.sync = BreathingSync()
# ═══════════════════════════════════════════════════════════════════════
# EXHALE (GIT PUSH)
# ═══════════════════════════════════════════════════════════════════════
@patch.object(BreathingSync, '_run_git_command')
def test_exhale_no_changes(self, mock_git):
"""Exhale когда нет изменений"""
# git status --porcelain возвращает пусто (нет изменений)
mock_git.return_value = {
"success": True,
"stdout": "", # Пусто = нет изменений
"stderr": "",
"returncode": 0
}
result = self.sync.exhale()
# Должно быть успешно (нечего пушить)
self.assertTrue(result)
@patch.object(BreathingSync, '_run_git_command')
@patch.object(Path, 'exists')
def test_exhale_with_changes(self, mock_exists, mock_git):
"""Exhale когда есть изменения"""
mock_exists.return_value = True
# Последовательность git команд
def git_side_effect(args):
if "status" in args:
return {"success": True, "stdout": "M data/users.json", "stderr": "", "returncode": 0}
elif "add" in args:
return {"success": True, "stdout": "", "stderr": "", "returncode": 0}
elif "commit" in args:
return {"success": True, "stdout": "[main abc123]", "stderr": "", "returncode": 0}
elif "push" in args:
return {"success": True, "stdout": "Everything up-to-date", "stderr": "", "returncode": 0}
return {"success": True, "stdout": "", "stderr": "", "returncode": 0}
mock_git.side_effect = git_side_effect
result = self.sync.exhale()
self.assertTrue(result)
self.assertEqual(self.sync.stats["total_exhales"], 1)
self.assertEqual(self.sync.stats["failed_exhales"], 0)
self.assertIsNotNone(self.sync.stats["last_exhale"])
@patch.object(BreathingSync, '_run_git_command')
@patch.object(Path, 'exists')
def test_exhale_push_failure(self, mock_exists, mock_git):
"""Exhale когда push не удался"""
mock_exists.return_value = True
def git_side_effect(args):
if "status" in args:
return {"success": True, "stdout": "M data/users.json", "stderr": "", "returncode": 0}
elif "add" in args:
return {"success": True, "stdout": "", "stderr": "", "returncode": 0}
elif "commit" in args:
return {"success": True, "stdout": "[main abc123]", "stderr": "", "returncode": 0}
elif "push" in args:
return {"success": False, "stdout": "", "stderr": "error: failed to push", "returncode": 1}
return {"success": True, "stdout": "", "stderr": "", "returncode": 0}
mock_git.side_effect = git_side_effect
result = self.sync.exhale()
self.assertFalse(result)
self.assertEqual(self.sync.stats["failed_exhales"], 1)
self.assertEqual(self.sync.stats["last_error"], "error: failed to push")
@patch.object(BreathingSync, '_run_git_command')
def test_exhale_nothing_to_commit(self, mock_git):
"""Exhale когда нечего коммитить"""
def git_side_effect(args):
if "status" in args:
return {"success": True, "stdout": "M data/users.json", "stderr": "", "returncode": 0}
elif "commit" in args:
return {"success": False, "stdout": "", "stderr": "nothing to commit", "returncode": 1}
return {"success": True, "stdout": "", "stderr": "", "returncode": 0}
mock_git.side_effect = git_side_effect
result = self.sync.exhale()
# Должно быть успешно (нечего коммитить — нормально)
self.assertTrue(result)
class TestBreathingSyncBreathe(unittest.TestCase):
"""Тесты breathe (полный цикл дыхания)"""
def setUp(self):
"""Создаём BreathingSync перед каждым тестом"""
self.sync = BreathingSync()
# ═══════════════════════════════════════════════════════════════════════
# BREATHE (ЦИКЛ)
# ═══════════════════════════════════════════════════════════════════════
@patch.object(BreathingSync, 'exhale')
@patch.object(BreathingSync, 'inhale')
def test_breathe_both_success(self, mock_inhale, mock_exhale):
"""Breathe когда оба успешны"""
mock_inhale.return_value = True
mock_exhale.return_value = True
result = self.sync.breathe()
self.assertTrue(result["inhale"])
self.assertTrue(result["exhale"])
mock_inhale.assert_called_once()
mock_exhale.assert_called_once()
@patch.object(BreathingSync, 'exhale')
@patch.object(BreathingSync, 'inhale')
def test_breathe_inhale_fails(self, mock_inhale, mock_exhale):
"""Breathe когда inhale не удался"""
mock_inhale.return_value = False
mock_exhale.return_value = True
result = self.sync.breathe()
self.assertFalse(result["inhale"])
self.assertTrue(result["exhale"])
@patch.object(BreathingSync, 'exhale')
@patch.object(BreathingSync, 'inhale')
def test_breathe_exhale_fails(self, mock_inhale, mock_exhale):
"""Breathe когда exhale не удался"""
mock_inhale.return_value = True
mock_exhale.return_value = False
result = self.sync.breathe()
self.assertTrue(result["inhale"])
self.assertFalse(result["exhale"])
@patch.object(BreathingSync, 'exhale')
@patch.object(BreathingSync, 'inhale')
def test_breathe_both_fail(self, mock_inhale, mock_exhale):
"""Breathe когда оба не удались"""
mock_inhale.return_value = False
mock_exhale.return_value = False
result = self.sync.breathe()
self.assertFalse(result["inhale"])
self.assertFalse(result["exhale"])
@patch.object(BreathingSync, 'exhale')
@patch.object(BreathingSync, 'inhale')
def test_breathe_order(self, mock_inhale, mock_exhale):
"""Breathe выполняется в правильном порядке: inhale → exhale"""
mock_inhale.return_value = True
mock_exhale.return_value = True
# Используем side_effect чтобы проверить порядок
call_order = []
mock_inhale.side_effect = lambda: call_order.append("inhale") or True
mock_exhale.side_effect = lambda: call_order.append("exhale") or True
self.sync.breathe()
self.assertEqual(call_order, ["inhale", "exhale"])
class TestBreathingSyncControl(unittest.TestCase):
"""Тесты управления синхронизацией"""
# ═══════════════════════════════════════════════════════════════════════
# CONTROL
# ═══════════════════════════════════════════════════════════════════════
def test_stop(self):
"""stop() останавливает синхронизацию"""
sync = BreathingSync()
self.assertFalse(sync._stop_event.is_set())
self.assertFalse(sync._running)
sync._running = True
sync.stop()
self.assertTrue(sync._stop_event.is_set())
self.assertFalse(sync._running)
def test_stop_idempotent(self):
"""Множественный stop() безопасен"""
sync = BreathingSync()
sync.stop()
sync.stop()
sync.stop()
self.assertTrue(sync._stop_event.is_set())
def test_get_stats(self):
"""get_stats() возвращает статистику"""
sync = BreathingSync()
stats = sync.get_stats()
self.assertIsInstance(stats, dict)
self.assertIn("total_inhales", stats)
self.assertIn("total_exhales", stats)
self.assertIn("failed_inhales", stats)
self.assertIn("failed_exhales", stats)
self.assertIn("repo_path", stats)
self.assertIn("interval_sec", stats)
self.assertIn("is_running", stats)
def test_get_stats_values(self):
"""get_stats() содержит правильные значения"""
sync = BreathingSync()
sync.stats["total_inhales"] = 10
sync.stats["total_exhales"] = 8
sync._running = True
stats = sync.get_stats()
self.assertEqual(stats["total_inhales"], 10)
self.assertEqual(stats["total_exhales"], 8)
self.assertTrue(stats["is_running"])
self.assertEqual(stats["interval_sec"], 12)
class TestBreathingSyncLoop(unittest.TestCase):
"""Тесты async цикла синхронизации"""
# ═══════════════════════════════════════════════════════════════════════
# ASYNC LOOP
# ═══════════════════════════════════════════════════════════════════════
@patch.object(BreathingSync, 'breathe')
async def test_run_breathing_loop_basic(self, mock_breathe):
"""Базовый тест цикла"""
mock_breathe.return_value = {"inhale": True, "exhale": True}
sync = BreathingSync()
# Запускаем цикл на 0.2 секунды
task = asyncio.create_task(
sync.run_breathing_loop(interval=0.1, only_when_master=False)
)
await asyncio.sleep(0.3)
sync.stop()
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Должно быть хотя бы 1 вызов breathe
self.assertGreater(mock_breathe.call_count, 0)
@patch.object(BreathingSync, 'breathe')
async def test_run_breathing_loop_only_when_master(self, mock_breathe):
"""Цикл работает только когда узел — мастер"""
mock_breathe.return_value = {"inhale": True, "exhale": True}
sync = BreathingSync()
is_master = False
def check_master():
return is_master
# Запускаем цикл
task = asyncio.create_task(
sync.run_breathing_loop(
interval=0.1,
only_when_master=True,
is_master_func=check_master
)
)
# Ждём немного (не мастер)
await asyncio.sleep(0.3)
call_count_not_master = mock_breathe.call_count
# Становимся мастером
is_master = True
await asyncio.sleep(0.3)
call_count_as_master = mock_breathe.call_count
sync.stop()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Когда не мастер — breathe не вызывается (или очень редко)
self.assertEqual(call_count_not_master, 0)
# Когда мастер — breathe вызывается
self.assertGreater(call_count_as_master, 0)
class TestBreathingSyncSingleton(unittest.TestCase):
"""Тесты singleton паттерна"""
# ═══════════════════════════════════════════════════════════════════════
# SINGLETON
# ═══════════════════════════════════════════════════════════════════════
def test_get_breathing_sync_returns_instance(self):
"""get_breathing_sync() возвращает экземпляр"""
sync = get_breathing_sync()
self.assertIsNotNone(sync)
self.assertIsInstance(sync, BreathingSync)
def test_get_breathing_sync_singleton(self):
"""get_breathing_sync() возвращает один и тот же экземпляр"""
sync1 = get_breathing_sync()
sync2 = get_breathing_sync()
self.assertIs(sync1, sync2)
def test_singleton_shared_state(self):
"""Singleton делит состояние между вызовами"""
sync1 = get_breathing_sync()
sync1.stats["total_inhales"] = 42
sync2 = get_breathing_sync()
# Должен быть тот же объект с тем же состоянием
self.assertEqual(sync2.stats["total_inhales"], 42)
# ═══════════════════════════════════════════════════════════════════════════
# RUN TESTS
# ═══════════════════════════════════════════════════════════════════════════
if __name__ == '__main__':
# Запускаем с verbose output
unittest.main(verbosity=2)