"""Final coverage tests to reach 98% coverage.""" 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.data.loader import DataLoader from src.data.storage import Storage from src.exceptions import ( ChunkParsingError, ClaudeAPIError, QualityThresholdError, RateLimitError, ) from src.models.evaluation import VerseEvaluation, EvaluationScore from src.models.glossary import EnhancedGlossaryTerm 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, retry_with_backoff, async_retry_with_backoff from src.utils.validation import parse_reference_string, is_valid_book from src.exceptions import InvalidReferenceError @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 TestRetryDecoratorEdgeCases: """Tests for retry decorator edge cases.""" def test_retry_decorator_default_config(self) -> None: """Test retry decorator with default config.""" call_count = 0 @retry_with_backoff() def successful_func(): nonlocal call_count call_count += 1 return "success" result = successful_func() assert result == "success" assert call_count == 1 def test_retry_decorator_with_on_retry(self) -> None: """Test retry decorator calls on_retry callback.""" retry_calls = [] config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) def on_retry(exc, attempt): retry_calls.append((str(exc), attempt)) @retry_with_backoff(config, on_retry=on_retry) def failing_then_success(): if len(retry_calls) < 1: raise RateLimitError("Rate limit") return "success" result = failing_then_success() assert result == "success" assert len(retry_calls) == 1 @pytest.mark.asyncio async def test_async_retry_decorator_default_config(self) -> None: """Test async retry decorator with default config.""" call_count = 0 @async_retry_with_backoff() async def successful_func(): nonlocal call_count call_count += 1 return "success" result = await successful_func() assert result == "success" assert call_count == 1 @pytest.mark.asyncio async def test_async_retry_with_callback(self) -> None: """Test async retry decorator calls on_retry callback.""" retry_calls = [] config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) def on_retry(exc, attempt): retry_calls.append((str(exc), attempt)) @async_retry_with_backoff(config, on_retry=on_retry) async def failing_then_success(): if len(retry_calls) < 1: raise RateLimitError("Rate limit") return "success" result = await failing_then_success() assert result == "success" assert len(retry_calls) == 1 class TestRetryStateEdgeCases: """Tests for RetryState edge cases.""" @pytest.mark.asyncio async def test_async_wait_with_rate_limit_retry_after(self) -> None: """Test async_wait uses retry_after from rate limit error.""" config = RetryConfig( base_delay=10.0, jitter=False, retryable_exceptions=(RateLimitError,), ) state = RetryState(config) error = RateLimitError("Rate limit", retry_after=0.001) state.should_retry(error) import time start = time.time() await state.async_wait() elapsed = time.time() - start assert elapsed < 1.0 class TestValidationEdgeCases: """Tests for validation edge cases.""" def test_parse_reference_invalid_format_no_colon(self) -> None: """Test parsing reference without colon separator.""" with pytest.raises(InvalidReferenceError, match="Expected format"): parse_reference_string("john 1") def test_parse_reference_invalid_chapter(self) -> None: """Test parsing reference with non-numeric chapter.""" with pytest.raises(InvalidReferenceError): parse_reference_string("john abc:1") def test_is_valid_book_empty(self) -> None: """Test is_valid_book with empty string.""" assert is_valid_book("") is False class TestGlossaryAddTermEdgeCases: """Tests for GlossaryManager add_term edge cases.""" def test_add_term_with_all_morphological_fields(self) -> None: """Test adding term with all morphological fields.""" manager = GlossaryManager() term = EnhancedGlossaryTerm( original="λόγος", transliteration="logos", japanese="言葉", english="word", lemma="λογος", strongs_number="G3056", inflected_forms=["λόγον", "λόγου"], ) manager.add_term(term) # Should be findable by all indexes assert manager.get_by_original("λόγος") is not None assert manager.get_by_lemma("λογος") is not None assert manager.get_by_strongs("G3056") is not None assert manager.get_by_any_form("λόγον") is not None def test_add_term_rebuilds_match_pattern(self) -> None: """Test that adding term rebuilds the match pattern.""" manager = GlossaryManager() term = EnhancedGlossaryTerm( original="λόγος", transliteration="logos", japanese="言葉", english="word", ) manager.add_term(term) # Pattern should now include the term found = manager.find_terms_in_text("καὶ ὁ λόγος ἦν") assert len(found) == 1 class TestTranslatorQualityGateEdgeCases: """Tests for translator quality gate edge cases.""" @patch("src.pipeline.translator.subprocess.run") def test_translate_with_evaluator( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translation with auto-evaluation.""" 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, quality_threshold=70.0, enforce_quality_gate=False, ) mock_run.return_value = MagicMock( returncode=0, stdout="翻訳テスト。", stderr="" ) ref = VerseReference(book="john", chapter=1, verse=1) result = translator.translate_verse(ref, skip_existing=False) assert result is not None assert result.text == "翻訳テスト。" class TestTranslatorJSONExtractionEdgeCases: """Tests for JSON extraction edge cases.""" def test_extract_json_from_position_invalid(self, temp_data_dir: Path) -> None: """Test extracting JSON from invalid position.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager() storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Text without valid JSON object text = "This is just text with no JSON" result = translator._extract_json_from_position(text, 0) assert result is None def test_extract_json_from_position_nested(self, temp_data_dir: Path) -> None: """Test extracting nested JSON object.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager() storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Nested JSON text = '{"outer": {"inner": "value"}}' result = translator._extract_json_from_position(text, 0) assert result is not None assert "outer" in result def test_try_parse_json_not_dict(self, temp_data_dir: Path) -> None: """Test _try_parse_json returns None for non-dict JSON.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager() storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Valid JSON but not a dict result = translator._try_parse_json('["list", "items"]') assert result is None # Valid JSON string result = translator._try_parse_json('"just a string"') assert result is None class TestTranslatorAsyncEdgeCases: """Tests for translator async edge cases.""" @patch("src.pipeline.translator.asyncio.create_subprocess_exec") @pytest.mark.asyncio async def test_call_claude_async_success( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test successful async Claude call.""" 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, ) mock_process = AsyncMock() mock_process.returncode = 0 mock_process.communicate.return_value = ("非同期翻訳。".encode(), b"") mock_subprocess.return_value = mock_process result = await translator._call_claude_async("system", "user") assert result == "非同期翻訳。" class TestTranslatorCallClaudeEdgeCases: """Tests for _call_claude edge cases.""" @patch("src.pipeline.translator.subprocess.run") def test_call_claude_with_timeout( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude passes timeout correctly.""" 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, subprocess_timeout=45, ) mock_run.return_value = MagicMock( returncode=0, stdout="翻訳結果", stderr="" ) translator._call_claude("system", "user") # Verify timeout was passed call_kwargs = mock_run.call_args[1] assert call_kwargs["timeout"] == 45 @patch("src.pipeline.translator.subprocess.run") def test_call_claude_network_error( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude handles network errors.""" 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) mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="Network connection failed" ) with pytest.raises(ClaudeAPIError, match="Network error"): translator._call_claude("system", "user") class TestGlossaryFindTermsWithPositions: """Tests for find_terms_in_text_with_positions.""" def test_find_terms_with_positions(self) -> None: """Test finding terms with their positions.""" terms = [ EnhancedGlossaryTerm( original="λόγος", transliteration="logos", japanese="言葉", english="word", ) ] manager = GlossaryManager(terms) text = "καὶ ὁ λόγος ἦν" found = manager.find_terms_in_text_with_positions(text) assert len(found) == 1 term, start, end = found[0] assert term.original == "λόγος" assert start == 6 # Position of λόγος in text assert end == 11 def test_find_terms_with_positions_empty(self) -> None: """Test finding terms with positions when none found.""" terms = [ EnhancedGlossaryTerm( original="λόγος", transliteration="logos", japanese="言葉", english="word", ) ] manager = GlossaryManager(terms) text = "no terms here" found = manager.find_terms_in_text_with_positions(text) assert len(found) == 0 class TestValidationSanitizePathEdgeCases: """Tests for sanitize_book_name_for_path edge cases.""" def test_sanitize_path_invalid_characters(self) -> None: """Test sanitize path rejects invalid path characters.""" from src.utils.validation import sanitize_book_name_for_path # Valid book names should pass result = sanitize_book_name_for_path("john") assert result == "john" class TestTranslatorTimeoutEdgeCases: """Tests for translator timeout handling.""" @patch("src.pipeline.translator.subprocess.run") def test_call_claude_timeout_expired( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude handles TimeoutExpired.""" import subprocess loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) # Disable retries to test exception directly translator = Translator( context_builder, prompt_builder, storage, subprocess_timeout=1, retry_config=RetryConfig(max_attempts=1, retryable_exceptions=()), ) # Mock timeout mock_run.side_effect = subprocess.TimeoutExpired("claude", 1) with pytest.raises(ClaudeAPIError, match="timed out"): translator._call_claude("system", "user") @patch("src.pipeline.translator.subprocess.run") def test_call_claude_file_not_found( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude handles FileNotFoundError (CLI not installed).""" 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) # Mock file not found mock_run.side_effect = FileNotFoundError("claude not found") from src.exceptions import CLINotFoundError with pytest.raises(CLINotFoundError): translator._call_claude("system", "user") class TestValidateReferenceExists: """Tests for validate_reference_exists function.""" def test_validate_reference_exists_not_found(self, temp_data_dir: Path) -> None: """Test validate_reference_exists raises when verse not found.""" from src.utils.validation import validate_reference_exists from src.exceptions import VerseNotFoundError loader = DataLoader(temp_data_dir) # Verse 99:99 doesn't exist in our test data with pytest.raises(VerseNotFoundError): validate_reference_exists(loader, "john", 99, 99) def test_validate_reference_exists_success(self, temp_data_dir: Path) -> None: """Test validate_reference_exists passes for existing verse.""" from src.utils.validation import validate_reference_exists loader = DataLoader(temp_data_dir) # Verse 1:1 exists in our test data validate_reference_exists(loader, "john", 1, 1) # Should not raise class TestCrossReferenceEdgeCases: """Additional tests for cross-reference edge cases.""" def test_cross_reference_source_ref_property(self) -> None: """Test CrossReference source_ref property.""" from src.context.cross_references import CrossReference ref = CrossReference( source_book="john", source_chapter=3, source_verse=16, target_book="genesis", target_chapter=1, target_verse_start=1, ) assert ref.source_ref == "john 3:16" def test_cross_reference_target_ref_with_range(self) -> None: """Test CrossReference target_ref with verse range.""" from src.context.cross_references import CrossReference ref = CrossReference( source_book="john", source_chapter=1, source_verse=1, target_book="genesis", target_chapter=1, target_verse_start=1, target_verse_end=5, ) assert ref.target_ref == "genesis 1:1-5" class TestPericopeGeneratorEdgeCases: """Additional tests for pericope generator edge cases.""" def test_generate_and_save(self, temp_data_dir: Path) -> None: """Test generate_and_save creates output file.""" from src.context.pericope_generator import PericopeGenerator # Add Greek data greek_dir = temp_data_dir / "sources" / "greek" greek_data = { "1:1": {"text": "¶ Ἐν ἀρχῇ ἦν"}, "1:2": {"text": "οὗτος ἦν ἐν"}, "1:3": {"text": "¶ πάντα δι᾽ αὐτοῦ"}, } with (greek_dir / "testbook.json").open("w") as f: json.dump(greek_data, f) generator = PericopeGenerator(temp_data_dir) index = generator.generate_and_save("testbook") assert index is not None # Check file was created output_path = temp_data_dir / "metadata" / "pericopes_generated.json" assert output_path.exists()