"""Final coverage tests for remaining gaps.""" import asyncio import json import subprocess import time import pytest from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import AsyncMock, MagicMock, patch from src.context.builder import ContextBuilder from src.context.cross_references import CrossReference, CrossReferenceManager, ReferenceType from src.context.glossary import GlossaryManager from src.context.pericope_generator import PericopeGenerator from src.data.loader import DataLoader from src.data.storage import Storage from src.evaluation.evaluator import TranslationEvaluator from src.evaluation.storage import EvaluationStorage from src.exceptions import ( ClaudeAPIError, CLINotFoundError, EvaluationError, QualityThresholdError, RateLimitError, TranslationError, ) from src.models.evaluation import EvaluationScore, VerseEvaluation from src.models.verse import VerseReference from src.pipeline.prompt import PromptBuilder from src.pipeline.translator import Translator from src.utils.retry import ( async_retry_with_backoff, retry_with_backoff, 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 TestRetryDecoratorRateLimitRetryAfter: """Tests for retry decorator using rate limit retry_after.""" def test_sync_retry_uses_rate_limit_retry_after(self) -> None: """Test sync retry decorator uses RateLimitError.retry_after.""" config = RetryConfig( max_attempts=3, base_delay=10.0, # Very long base delay jitter=False, retryable_exceptions=(RateLimitError,), ) call_count = 0 times = [] @retry_with_backoff(config) def func_with_retry() -> str: nonlocal call_count call_count += 1 times.append(time.time()) if call_count < 2: # Short retry_after should override base_delay raise RateLimitError("Rate limit", retry_after=0.001) return "success" result = func_with_retry() assert result == "success" assert call_count == 2 # Verify delay was short (not 10 seconds) elapsed = times[1] - times[0] assert elapsed < 1.0 @pytest.mark.asyncio async def test_async_retry_uses_rate_limit_retry_after(self) -> None: """Test async retry decorator uses RateLimitError.retry_after.""" config = RetryConfig( max_attempts=3, base_delay=10.0, # Very long base delay jitter=False, retryable_exceptions=(RateLimitError,), ) call_count = 0 times = [] @async_retry_with_backoff(config) async def async_func_with_retry() -> str: nonlocal call_count call_count += 1 times.append(time.time()) if call_count < 2: raise RateLimitError("Rate limit", retry_after=0.001) return "async success" result = await async_func_with_retry() assert result == "async success" assert call_count == 2 elapsed = times[1] - times[0] assert elapsed < 1.0 class TestRetryDecoratorEdgeCases: """Tests for edge cases in retry decorators.""" def test_sync_retry_no_exception_captured(self) -> None: """Test sync retry handles no exception scenario.""" # This tests line 109 - but the code path is unreachable in normal operation # since last_exception is always set when retrying. We test success case. config = RetryConfig(max_attempts=1, retryable_exceptions=()) @retry_with_backoff(config) def always_succeeds() -> str: return "success" result = always_succeeds() assert result == "success" @pytest.mark.asyncio async def test_async_retry_no_exception_captured(self) -> None: """Test async retry handles no exception scenario.""" config = RetryConfig(max_attempts=1, retryable_exceptions=()) @async_retry_with_backoff(config) async def async_always_succeeds() -> str: return "async success" result = await async_always_succeeds() assert result == "async success" class TestRetryStateNonRateLimitWait: """Tests for RetryState wait with non-RateLimitError.""" def test_wait_with_non_rate_limit_error(self) -> None: """Test wait() uses get_delay when not RateLimitError.""" config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(ConnectionError,), ) state = RetryState(config) # Trigger with non-RateLimitError state.should_retry(ConnectionError("Connection failed")) start = time.time() state.wait() elapsed = time.time() - start assert elapsed < 1.0 assert state.attempt == 1 @pytest.mark.asyncio async def test_async_wait_with_non_rate_limit_error(self) -> None: """Test async_wait() uses get_delay when not RateLimitError.""" config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(ConnectionError,), ) state = RetryState(config) state.should_retry(ConnectionError("Connection failed")) start = time.time() await state.async_wait() elapsed = time.time() - start assert elapsed < 1.0 assert state.attempt == 1 class TestTranslatorTimeoutRetry: """Tests for translator timeout retry paths.""" @patch("src.pipeline.translator.subprocess.run") def test_call_claude_timeout_retry_logging( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test sync Claude call logs and retries on timeout.""" 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=(ClaudeAPIError,), # Timeout is converted to ClaudeAPIError ) translator = Translator( context_builder, prompt_builder, storage, retry_config=retry_config, subprocess_timeout=1, ) call_count = 0 def timeout_then_succeed(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: raise subprocess.TimeoutExpired(cmd="claude", timeout=1) return MagicMock(returncode=0, stdout="成功", stderr="") mock_run.side_effect = timeout_then_succeed result = translator._call_claude("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_retry_logging( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test async Claude call logs and retries on timeout.""" 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=(ClaudeAPIError,), ) translator = Translator( context_builder, prompt_builder, storage, retry_config=retry_config, ) call_count = 0 async def timeout_then_succeed(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: raise asyncio.TimeoutError() mock_process = AsyncMock() mock_process.returncode = 0 mock_process.communicate.return_value = ("成功".encode(), b"") return mock_process mock_subprocess.side_effect = timeout_then_succeed 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_file_not_found( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test async Claude call raises CLINotFoundError.""" 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_subprocess.side_effect = FileNotFoundError("claude not found") with pytest.raises(CLINotFoundError): await translator._call_claude_async("system", "user") class TestTranslatorQualityGate: """Tests for translator quality gate enforcement.""" @patch("src.pipeline.translator.subprocess.run") def test_quality_gate_overall_fails( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test quality gate fails when overall score too low.""" from datetime import datetime loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) # Create evaluator mock without spec to allow arbitrary method calls mock_evaluator = MagicMock() mock_evaluator.build_verse_prompt.return_value = "eval prompt" # This evaluation will fail the overall score threshold low_score_eval = VerseEvaluation( verse_number=1, evaluator_model="test", timestamp=datetime.utcnow(), scores={ "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=95.0, confidence=90.0, reasoning="Good", ), "source_fidelity": EvaluationScore( dimension="source_fidelity", score=40.0, confidence=90.0, reasoning="Poor", ), "naturalness": EvaluationScore( dimension="naturalness", score=40.0, confidence=90.0, reasoning="Poor", ), "readability": EvaluationScore( dimension="readability", score=40.0, confidence=90.0, reasoning="Poor", ), "glossary_consistency": EvaluationScore( dimension="glossary_consistency", score=40.0, confidence=90.0, reasoning="Poor", ), "literary_quality": EvaluationScore( dimension="literary_quality", score=40.0, confidence=90.0, reasoning="Poor", ), }, flags=[], notes=None, ) mock_evaluator.create_verse_evaluation.return_value = low_score_eval translator = Translator( context_builder, prompt_builder, storage, quality_threshold=70.0, enforce_quality_gate=True, auto_evaluate=True, evaluator=mock_evaluator, ) # Mock translation call to succeed mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="") # Also need to mock the second _call_claude for evaluation # The first call translates, second call evaluates translation_response = "翻訳" eval_json = json.dumps({ "theological_accuracy": {"score": 95, "confidence": 90, "reasoning": "Good"}, "source_fidelity": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "naturalness": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "readability": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "glossary_consistency": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "literary_quality": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "style_adherence": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "formality_level": {"score": 40, "confidence": 90, "reasoning": "Poor"}, "archaic_avoidance": {"score": 40, "confidence": 90, "reasoning": "Poor"}, }) mock_run.side_effect = [ MagicMock(returncode=0, stdout=translation_response, stderr=""), MagicMock(returncode=0, stdout=eval_json, stderr=""), ] ref = VerseReference(book="john", chapter=1, verse=1) with pytest.raises(QualityThresholdError, match="failed quality gate"): translator.translate_verse(ref, skip_existing=False) @patch("src.pipeline.translator.subprocess.run") def test_quality_gate_theological_fails( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test quality gate fails when theological accuracy too low.""" from datetime import datetime loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) mock_evaluator = MagicMock() mock_evaluator.build_verse_prompt.return_value = "eval prompt" # Evaluation with low theological accuracy low_theo_eval = VerseEvaluation( verse_number=1, evaluator_model="test", timestamp=datetime.utcnow(), scores={ "theological_accuracy": EvaluationScore( dimension="theological_accuracy", score=70.0, # Below 85% theological threshold confidence=90.0, reasoning="Concern", ), "source_fidelity": EvaluationScore( dimension="source_fidelity", score=90.0, confidence=90.0, reasoning="Good", ), "naturalness": EvaluationScore( dimension="naturalness", score=90.0, confidence=90.0, reasoning="Good", ), "readability": EvaluationScore( dimension="readability", score=90.0, confidence=90.0, reasoning="Good", ), "glossary_consistency": EvaluationScore( dimension="glossary_consistency", score=90.0, confidence=90.0, reasoning="Good", ), "literary_quality": EvaluationScore( dimension="literary_quality", score=90.0, confidence=90.0, reasoning="Good", ), }, flags=["theological_concern"], notes=None, ) mock_evaluator.create_verse_evaluation.return_value = low_theo_eval translator = Translator( context_builder, prompt_builder, storage, quality_threshold=70.0, enforce_quality_gate=True, auto_evaluate=True, evaluator=mock_evaluator, ) translation_response = "翻訳" eval_json = json.dumps({ "theological_accuracy": {"score": 70, "confidence": 90, "reasoning": "Concern"}, "source_fidelity": {"score": 90, "confidence": 90, "reasoning": "Good"}, "naturalness": {"score": 90, "confidence": 90, "reasoning": "Good"}, "readability": {"score": 90, "confidence": 90, "reasoning": "Good"}, "glossary_consistency": {"score": 90, "confidence": 90, "reasoning": "Good"}, "literary_quality": {"score": 90, "confidence": 90, "reasoning": "Good"}, "style_adherence": {"score": 90, "confidence": 90, "reasoning": "Good"}, "formality_level": {"score": 90, "confidence": 90, "reasoning": "Good"}, "archaic_avoidance": {"score": 90, "confidence": 90, "reasoning": "Good"}, }) mock_run.side_effect = [ MagicMock(returncode=0, stdout=translation_response, stderr=""), MagicMock(returncode=0, stdout=eval_json, stderr=""), ] ref = VerseReference(book="john", chapter=1, verse=1) with pytest.raises(QualityThresholdError, match="theological accuracy"): translator.translate_verse(ref, skip_existing=False) @patch("src.pipeline.translator.subprocess.run") def test_evaluation_error_with_quality_gate_enforced( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test evaluation error raises when quality gate enforced.""" loader = DataLoader(temp_data_dir) storage = Storage(temp_data_dir) glossary = GlossaryManager() context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) # Evaluator that throws an exception mock_evaluator = MagicMock() mock_evaluator.build_verse_prompt.side_effect = Exception("Evaluation failed") translator = Translator( context_builder, prompt_builder, storage, enforce_quality_gate=True, auto_evaluate=True, evaluator=mock_evaluator, ) mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="") ref = VerseReference(book="john", chapter=1, verse=1) with pytest.raises(EvaluationError, match="Evaluation failed"): translator.translate_verse(ref, skip_existing=False) class TestCrossReferenceManagerEdgeCases: """Tests for CrossReferenceManager edge cases.""" def test_load_with_no_data_dir(self) -> None: """Test loading when data_dir is None.""" manager = CrossReferenceManager() # No data_dir manager._load_cross_references() # Should return early assert len(manager) == 0 def test_save_with_no_data_dir_raises(self) -> None: """Test save raises ValueError when no data_dir.""" manager = CrossReferenceManager() # No data_dir manager.add_reference( CrossReference( source_book="john", source_chapter=1, source_verse=1, target_book="genesis", target_chapter=1, target_verse_start=1, ) ) with pytest.raises(ValueError, match="No data directory"): manager.save() def test_find_references_to_verse_with_range(self) -> None: """Test finding references targeting a verse within a range.""" manager = CrossReferenceManager() # Reference with a range target manager.add_reference( CrossReference( source_book="matthew", source_chapter=4, source_verse=4, target_book="deuteronomy", target_chapter=8, target_verse_start=3, target_verse_end=5, # Range ) ) # Should find when querying start verse found = manager.find_references_to_verse("deuteronomy", 8, 3) assert len(found) == 1 class TestPericopeGeneratorEdgeCases: """Tests for PericopeGenerator edge cases.""" def test_update_pericope_range_empty_verses(self) -> None: """Test _update_pericope_range returns early for empty verses.""" with TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) (data_dir / "sources" / "greek").mkdir(parents=True) generator = PericopeGenerator(data_dir) from src.models.pericope import Pericope pericope = Pericope( id="test-1", name="Test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=[], # Empty ) # Should return early without error generator._update_pericope_range(pericope) # Verses still empty assert pericope.verses == [] def test_generate_and_save_loads_existing_file(self) -> None: """Test generate_and_save loads existing pericopes file.""" with TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) greek_dir = data_dir / "sources" / "greek" greek_dir.mkdir(parents=True) metadata_dir = data_dir / "metadata" metadata_dir.mkdir(parents=True) # Create Greek data greek_data = { "1:1": {"text": "¶ Ἐν ἀρχῇ"}, "1:2": {"text": "οὗτος ἦν"}, } with (greek_dir / "john.json").open("w") as f: json.dump(greek_data, f) # Pre-existing pericopes file existing = { "matthew": { "name": "matthew", "pericopes": [], "auto_generated": True, } } output_path = metadata_dir / "pericopes_generated.json" with output_path.open("w") as f: json.dump(existing, f) generator = PericopeGenerator(data_dir) index = generator.generate_and_save("john") # Should have merged with existing with output_path.open("r") as f: data = json.load(f) assert "matthew" in data assert "john" in data def test_generate_all_books_with_no_books(self) -> None: """Test generate_all_books scans directory when books is None.""" with TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) greek_dir = data_dir / "sources" / "greek" greek_dir.mkdir(parents=True) # Create multiple book files with enough verses to form pericopes for book in ["john", "mark"]: greek_data = { "1:1": {"text": "¶ Test verse 1"}, "1:2": {"text": "Test verse 2"}, "1:3": {"text": "Test verse 3"}, "1:4": {"text": "¶ Test verse 4"}, "1:5": {"text": "Test verse 5"}, } with (greek_dir / f"{book}.json").open("w") as f: json.dump(greek_data, f) generator = PericopeGenerator(data_dir) result = generator.generate_all_books() # No books specified # Should find at least one book with pericopes assert len(result) >= 1 def test_generate_all_books_no_greek_dir(self) -> None: """Test generate_all_books with no Greek directory.""" with TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) # No greek directory created generator = PericopeGenerator(data_dir) result = generator.generate_all_books() # No books assert result == {} def test_generate_all_books_empty_pericope_index(self) -> None: """Test generate_all_books skips empty pericope indexes.""" with TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) greek_dir = data_dir / "sources" / "greek" greek_dir.mkdir(parents=True) # Create book with no paragraph markers greek_data = {} # Empty book with (greek_dir / "philemon.json").open("w") as f: json.dump(greek_data, f) generator = PericopeGenerator(data_dir) result = generator.generate_all_books(books=["philemon"]) # Empty index should not be included assert "philemon" not in result class TestRetryOnRetryCallback: """Tests for on_retry callback in retry decorators.""" def test_sync_on_retry_callback_called(self) -> None: """Test on_retry callback is called during sync retry.""" config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(ValueError,), ) callback_calls = [] def on_retry(exc: Exception, attempt: int) -> None: callback_calls.append((str(exc), attempt)) call_count = 0 @retry_with_backoff(config, on_retry=on_retry) def func_with_callback() -> str: nonlocal call_count call_count += 1 if call_count < 3: raise ValueError(f"Attempt {call_count}") return "done" result = func_with_callback() assert result == "done" assert len(callback_calls) == 2 assert callback_calls[0] == ("Attempt 1", 0) assert callback_calls[1] == ("Attempt 2", 1) @pytest.mark.asyncio async def test_async_on_retry_callback_called(self) -> None: """Test on_retry callback is called during async retry.""" config = RetryConfig( max_attempts=3, base_delay=0.001, jitter=False, retryable_exceptions=(ValueError,), ) callback_calls = [] def on_retry(exc: Exception, attempt: int) -> None: callback_calls.append((str(exc), attempt)) call_count = 0 @async_retry_with_backoff(config, on_retry=on_retry) async def async_func_with_callback() -> str: nonlocal call_count call_count += 1 if call_count < 3: raise ValueError(f"Attempt {call_count}") return "async done" result = await async_func_with_callback() assert result == "async done" assert len(callback_calls) == 2