"""Final coverage tests - part 2.""" import asyncio import json import pytest from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from src.context.builder import ContextBuilder from src.context.glossary import GlossaryManager from src.context.cross_references import CrossReferenceManager, CrossReference, ReferenceType from src.context.pericope_generator import PericopeGenerator from src.data.loader import DataLoader from src.data.storage import Storage from src.exceptions import ClaudeAPIError, RateLimitError from src.models.verse import VerseReference from src.pipeline.prompt import PromptBuilder from src.pipeline.translator import Translator from src.utils.retry import RetryConfig, RetryState @pytest.fixture def temp_data_dir(tmp_path: Path) -> Path: """Create temporary data directory.""" sources_dir = tmp_path / "sources" (sources_dir / "greek").mkdir(parents=True) (sources_dir / "english" / "kjv").mkdir(parents=True) (tmp_path / "metadata").mkdir(parents=True) (tmp_path / "output").mkdir(parents=True) greek_data = { "1:1": {"text": "Ἐν ἀρχῇ", "translit": "En archē"}, "1:2": {"text": "οὗτος ἦν", "translit": "houtos ēn"}, } with (sources_dir / "greek" / "john.json").open("w") as f: json.dump(greek_data, f) kjv_data = {"1:1": "In the beginning", "1:2": "The same was"} with (sources_dir / "english" / "kjv" / "john.json").open("w") as f: json.dump(kjv_data, f) return tmp_path class TestTranslatorAsyncRetry: """Tests for async retry logic in translator.""" @patch("src.pipeline.translator.asyncio.create_subprocess_exec") @pytest.mark.asyncio async def test_call_claude_async_rate_limit_retry( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test async Claude call retries on rate limit.""" loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) retry_config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) translator = Translator( context_builder, prompt_builder, storage, retry_config=retry_config, ) call_count = 0 async def create_process(*args, **kwargs): nonlocal call_count call_count += 1 mock_process = AsyncMock() if call_count < 3: mock_process.returncode = 1 mock_process.communicate.return_value = (b"", b"rate limit exceeded") else: mock_process.returncode = 0 mock_process.communicate.return_value = ("成功。".encode(), b"") return mock_process mock_subprocess.side_effect = create_process result = await translator._call_claude_async("system", "user") assert result == "成功。" assert call_count == 3 @patch("src.pipeline.translator.asyncio.create_subprocess_exec") @pytest.mark.asyncio async def test_call_claude_async_timeout( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test async Claude call handles timeout.""" loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator( context_builder, prompt_builder, storage, retry_config=RetryConfig(max_attempts=1, retryable_exceptions=()), ) async def timeout_process(*args, **kwargs): mock_process = AsyncMock() mock_process.communicate.side_effect = asyncio.TimeoutError() return mock_process mock_subprocess.side_effect = timeout_process with pytest.raises(ClaudeAPIError, match="timed out"): await translator._call_claude_async("system", "user") class TestRetryStateEdgeCases: """Edge case tests for RetryState.""" def test_reset_clears_all_state(self) -> None: """Test reset clears attempt count and exception.""" config = RetryConfig(max_attempts=3, base_delay=0.001) state = RetryState(config) # Set some state state.attempt = 2 state.last_exception = RateLimitError("test") state.reset() assert state.attempt == 0 assert state.last_exception is None class TestCrossReferenceManagerEdgeCases: """Additional cross-reference edge cases.""" def test_find_references_by_type_no_match(self) -> None: """Test finding references by type when none match.""" manager = CrossReferenceManager() manager.add_reference( CrossReference( source_book="john", source_chapter=1, source_verse=1, target_book="genesis", target_chapter=1, target_verse_start=1, reference_type=ReferenceType.ALLUSION, ) ) # Look for QUOTATION but only ALLUSION exists found = manager.find_references_by_type("john", 1, 1, ReferenceType.QUOTATION) assert len(found) == 0 def test_get_parallel_passages_empty(self) -> None: """Test getting parallel passages when none exist.""" manager = CrossReferenceManager() manager.add_reference( CrossReference( source_book="john", source_chapter=1, source_verse=1, target_book="genesis", target_chapter=1, target_verse_start=1, reference_type=ReferenceType.ALLUSION, ) ) parallels = manager.get_parallel_passages("john", 1, 1) assert len(parallels) == 0 def test_get_quotation_sources_empty(self) -> None: """Test getting quotation sources when none exist.""" manager = CrossReferenceManager() manager.add_reference( CrossReference( source_book="john", source_chapter=1, source_verse=1, target_book="genesis", target_chapter=1, target_verse_start=1, reference_type=ReferenceType.THEMATIC, ) ) quotes = manager.get_quotation_sources("john", 1, 1) assert len(quotes) == 0 class TestPericopeGeneratorMerge: """Tests for pericope merge functionality.""" def test_merge_small_pericopes(self, tmp_path: Path) -> None: """Test merging small pericopes into larger ones.""" # Was a fixed /tmp/test_pericope_merge (via a no-op `with Path(...)`), # which concurrent test runs on one machine share and race on. greek_dir = tmp_path / "sources" / "greek" greek_dir.mkdir(parents=True, exist_ok=True) # Create data with many small paragraph breaks greek_data = { "1:1": {"text": "¶ verse1"}, "1:2": {"text": "¶ verse2"}, "1:3": {"text": "¶ verse3"}, "1:4": {"text": "verse4"}, "1:5": {"text": "verse5"}, } with (greek_dir / "test.json").open("w") as f: json.dump(greek_data, f) generator = PericopeGenerator(tmp_path) # With min_size=3, small pericopes should be merged index = generator.generate_pericopes("test", min_size=3) # Should have fewer pericopes due to merging assert len(index.pericopes) <= 2 class TestValidationPathSafety: """Tests for path safety validation.""" def test_sanitize_book_already_normalized(self) -> None: """Test sanitizing already normalized book name.""" from src.utils.validation import sanitize_book_name_for_path result = sanitize_book_name_for_path("matthew") assert result == "matthew" def test_sanitize_book_with_alias(self) -> None: """Test sanitizing book alias.""" from src.utils.validation import sanitize_book_name_for_path result = sanitize_book_name_for_path("jn") assert result == "john"