""" Тесты для системы обработки ошибок breakshaft. Покрывают все категории ошибок: - INJECTOR_*: Ошибки регистрации инжекторов - GRAPH_*: Ошибки построения графа - CODEGEN_*: Ошибки генерации кода - RUNTIME_*: Ошибки выполнения - CONFIG_*: Ошибки конфигурации """ from dataclasses import dataclass from typing import Any, Generator from contextlib import contextmanager import pytest from breakshaft import ConvRepo from breakshaft.exceptions import ( BreakshaftError, MissingReturnType, MissingParamType, NoConversionPath, AmbiguousPath, InvalidOptions, MissingDependency, InvalidGeneratedCode, ) # ============================================================================= # Базовые типы для тестов # ============================================================================= @dataclass class A: a: int @dataclass class B: b: float @dataclass class C: c: str # ============================================================================= # INJECTOR_*: Ошибки регистрации инжекторов # ============================================================================= class TestInjectorErrors: """Тесты ошибок регистрации инжекторов.""" def test_missing_return_type(self): """INJECTOR_001: У инжектора не указан тип возврата.""" repo = ConvRepo() with pytest.raises(MissingReturnType) as exc_info: @repo.mark_injector() def int_to_a(i: int): # Нет -> A return A(i) assert exc_info.value.code == "INJECTOR_001" assert "int_to_a" in str(exc_info.value) assert "return type" in str(exc_info.value).lower() def test_missing_param_type(self): """INJECTOR_002: У параметра инжектора не указан тип.""" repo = ConvRepo() with pytest.raises(MissingParamType) as exc_info: @repo.mark_injector() def convert(value) -> A: # Нет типа у параметра return A(42) assert exc_info.value.code == "INJECTOR_002" assert "value" in str(exc_info.value) assert "convert" in str(exc_info.value) def test_missing_param_type_second_param(self): """INJECTOR_002: У второго параметра не указан тип.""" repo = ConvRepo() with pytest.raises(MissingParamType) as exc_info: @repo.mark_injector() def convert(i: int, value) -> A: # Нет типа у value return A(i) assert exc_info.value.code == "INJECTOR_002" assert "value" in str(exc_info.value) # ============================================================================= # GRAPH_*: Ошибки построения графа # ============================================================================= class TestGraphErrors: """Тесты ошибок построения графа преобразований.""" def test_no_conversion_path(self): """GRAPH_001: Невозможно построить путь преобразования.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) def consumer(dep: B) -> str: # B нельзя получить из int return str(dep.b) with pytest.raises(NoConversionPath) as exc_info: repo.get_conversion((int,), consumer) assert exc_info.value.code == "GRAPH_001" assert "int" in str(exc_info.value) assert "B" in str(exc_info.value) # Проверяем что есть контекст с missing_types assert "missing_types" in exc_info.value.context def test_no_conversion_path_shows_missing_types(self): """GRAPH_001: Ошибка показывает недостающие типы.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) @repo.mark_injector() def a_to_c(a: A) -> C: return C(str(a.a)) def consumer(dep: B) -> str: # B отсутствует return str(dep.b) with pytest.raises(NoConversionPath) as exc_info: repo.get_conversion((int,), consumer) # Проверяем что B в missing_types missing = exc_info.value.context.get("missing_types", set()) assert B in missing def test_ambiguous_path_with_multiple_consumers_in_pipeline(self): """ GRAPH_002: Найдено несколько путей в конвейере. В get_conversion библиотека автоматически выбирает путь, поэтому AmbiguousPath возникает только в специфичных случаях. """ repo = ConvRepo() @repo.mark_injector() def int_to_a_v1(i: int) -> A: return A(i * 10) @repo.mark_injector() def int_to_a_v2(i: int) -> A: return A(i + 100) def consumer1(dep: A) -> B: return B(float(dep.a)) def consumer2(dep: B) -> C: return C(str(dep.b)) # В конвейере с несколькими consumer может возникнуть неоднозначность # Проверяем что библиотека вообще работает с множественными путями fn = repo.get_conversion((int,), consumer1, force_commutative=False) result = fn(42) # Один из путей будет выбран assert isinstance(result, B) # ============================================================================= # CONFIG_*: Ошибки конфигурации # ============================================================================= class TestConfigurationErrors: """Тесты ошибок конфигурации.""" def test_invalid_options_force_async_without_allow_async(self): """CONFIG_001: force_async=True без allow_async=True.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) def consumer(dep: A) -> int: return dep.a with pytest.raises(InvalidOptions) as exc_info: repo.get_conversion( (int,), consumer, force_async=True, allow_async=False # Конфликт! ) assert exc_info.value.code == "CONFIG_001" assert "force_async" in str(exc_info.value) assert "allow_async" in str(exc_info.value) def test_invalid_options_message_is_helpful(self): """CONFIG_001: Сообщение об ошибке содержит подсказку.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) def consumer(dep: A) -> int: return dep.a with pytest.raises(InvalidOptions) as exc_info: repo.get_conversion((int,), consumer, force_async=True, allow_async=False) assert "hint" in str(exc_info.value).lower() or "requires" in str(exc_info.value).lower() # ============================================================================= # RUNTIME_*: Ошибки выполнения # ============================================================================= class TestRuntimeErrors: """Тесты ошибок выполнения.""" def test_injector_call_failed_propagates_original_error(self): """ RUNTIME_001: Ошибка при вызове инжектора пробрасывается. Примечание: В текущей реализации ошибки инжекторов пробрасываются как есть. Для перехвата и обёртывания в InjectorCallFailed нужно изменить шаблон генерации кода. """ repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i / 0) # ZeroDivisionError def consumer(dep: A) -> int: return dep.a fn = repo.get_conversion((int,), consumer) # Ошибка пробрасывается как есть with pytest.raises(ZeroDivisionError): fn(42) def test_context_manager_error_propagates_original_error(self): """ RUNTIME_002: Ошибка контекст-менеджера пробрасывается. Примечание: В текущей реализации ошибки контекст-менеджеров пробрасываются как есть. """ repo = ConvRepo() @repo.mark_injector() @contextmanager def failing_ctx(i: int) -> Generator[A, None, None]: raise ConnectionError("Failed to connect") yield A(i) def consumer(dep: A) -> int: return dep.a fn = repo.get_conversion((int,), consumer) # Ошибка пробрасывается как есть with pytest.raises(ConnectionError, match="Failed to connect"): fn(42) # ============================================================================= # CODEGEN_*: Ошибки генерации кода # ============================================================================= class TestCodegenErrors: """Тесты ошибок генерации кода.""" def test_invalid_generated_code(self): """CODEGEN_002: Сгенерированный код некорректен.""" # Этот тест сложно спровоцировать в нормальных условиях, # т.к. шаблон всегда генерирует валидный код. # Проверяем что исключение вообще существует и работает. from breakshaft.exceptions import InvalidGeneratedCode exc = InvalidGeneratedCode( source_code="def foo():\n return invalid_syntax_here @@@@", original_error="invalid syntax" ) assert exc.code == "CODEGEN_002" assert "invalid" in str(exc).lower() # ============================================================================= # Тесты общих исключений # ============================================================================= class TestBreakshaftError: """Тесты базового исключения BreakshaftError.""" def test_breakshaft_error_has_code(self): """BreakshaftError содержит код ошибки.""" exc = BreakshaftError( code="TEST_001", message="Test error" ) assert exc.code == "TEST_001" assert "TEST_001" in str(exc) def test_breakshaft_error_has_context(self): """BreakshaftError содержит контекст.""" exc = BreakshaftError( code="TEST_002", message="Test with context", context={"key": "value", "types": {A, B}} ) assert exc.context["key"] == "value" assert A in exc.context["types"] def test_breakshaft_error_has_hint(self): """BreakshaftError содержит подсказку.""" exc = BreakshaftError( code="TEST_003", message="Test with hint", hint="Try doing X instead" ) assert "hint" in str(exc).lower() or "Try" in str(exc) def test_breakshaft_error_formats_types_nicely(self): """BreakshaftError красиво форматирует типы.""" exc = BreakshaftError( code="TEST_004", message="Type error", context={"types": {A, B, C}} ) msg = str(exc) # Проверяем что имена типов присутствуют assert "A" in msg or "B" in msg or "C" in msg # ============================================================================= # Тесты MissingDependency # ============================================================================= class TestMissingDependency: """Тесты ошибки MissingDependency.""" def test_missing_dependency_empty_repo(self): """GRAPH_001: Пустой репозиторий.""" repo = ConvRepo() def consumer(dep: A) -> int: return dep.a with pytest.raises(NoConversionPath) as exc_info: repo.get_conversion((int,), consumer) assert exc_info.value.code == "GRAPH_001" def test_missing_dependency_shows_available_types(self): """GRAPH_005: Ошибка показывает доступные типы.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) def consumer(dep: B) -> float: # B недоступен return dep.b with pytest.raises(NoConversionPath) as exc_info: repo.get_conversion((int,), consumer) # Проверяем что available_types содержит A available = exc_info.value.context.get("available_types", set()) assert A in available # ============================================================================= # Тесты интеграции с существующим кодом # ============================================================================= class TestIntegrationWithExistingCode: """Тесты что новые исключения работают со старым кодом.""" def test_existing_tests_still_work(self): """Существующие тесты продолжают работать.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) @repo.mark_injector() def a_to_b(a: A) -> B: return B(float(a.a)) def consumer(dep: B) -> float: return dep.b fn = repo.get_conversion((int,), consumer) result = fn(42) assert result == 42.0 def test_exceptions_are_catchable_as_breakshaft_error(self): """Все исключения можно поймать как BreakshaftError.""" repo = ConvRepo() with pytest.raises(BreakshaftError): @repo.mark_injector() def no_return(i: int): return A(i) def test_exceptions_are_catchable_as_base_exception(self): """Исключения наследуются от Exception.""" repo = ConvRepo() with pytest.raises(Exception): @repo.mark_injector() def no_return(i: int): return A(i) # ============================================================================= # Тесты edge cases # ============================================================================= class TestEdgeCases: """Тесты граничных случаев.""" def test_no_conversion_path_with_union_types(self): """GRAPH_001: Union-типы в ошибке.""" repo = ConvRepo() @repo.mark_injector() def int_to_a(i: int) -> A: return A(i) def consumer(dep: B | C) -> str: # B и C недоступны return "test" with pytest.raises(NoConversionPath) as exc_info: repo.get_conversion((int,), consumer) assert exc_info.value.code == "GRAPH_001" def test_missing_param_type_with_default(self): """INJECTOR_002: Параметр с default но без типа.""" repo = ConvRepo() with pytest.raises(MissingParamType) as exc_info: @repo.mark_injector() def func(a = 42) -> A: # Нет типа у a return A(42) assert exc_info.value.code == "INJECTOR_002" def test_error_message_contains_function_name(self): """Сообщение об ошибке содержит имя функции.""" repo = ConvRepo() # Функция без return type def my_special_function(x: int): # Нет return type return A(x) with pytest.raises(MissingReturnType) as exc_info: repo.mark_injector()(my_special_function) # Проверяем что имя функции присутствует assert "my_special_function" in str(exc_info.value)