"""Extended tests for translator to improve coverage on retry logic, quality gate, etc.""" 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.verse import ( TranslationContext, Verse, VerseReference, ) from src.pipeline.prompt import PromptBuilder from src.pipeline.translator import Translator from src.utils.retry import RetryConfig @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) (sources_dir / "japanese" / "meiji").mkdir(parents=True) (sources_dir / "japanese" / "taisho").mkdir(parents=True) (sources_dir / "english" / "asv").mkdir(parents=True) (tmp_path / "metadata").mkdir(parents=True) (tmp_path / "output").mkdir(parents=True) (tmp_path / "progress").mkdir(parents=True) # Add test data 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 TestTranslatorRetryLogic: """Tests for retry logic in translator.""" @patch("src.pipeline.translator.subprocess.run") def test_call_claude_retries_on_rate_limit( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude retries on rate limit error.""" 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 ) # First two calls fail with rate limit, third succeeds mock_run.side_effect = [ MagicMock(returncode=1, stdout="", stderr="rate limit exceeded"), MagicMock(returncode=1, stdout="", stderr="rate limit exceeded"), MagicMock(returncode=0, stdout="翻訳結果", stderr=""), ] result = translator._call_claude("system", "user") assert result == "翻訳結果" assert mock_run.call_count == 3 @patch("src.pipeline.translator.subprocess.run") def test_call_claude_exhausts_retries( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude raises after exhausting retries.""" 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=2, base_delay=0.001, jitter=False, retryable_exceptions=(RateLimitError,), ) translator = Translator( context_builder, prompt_builder, storage, retry_config=retry_config ) # All calls fail with rate limit mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="rate limit exceeded" ) with pytest.raises(RateLimitError): translator._call_claude("system", "user") assert mock_run.call_count == 2 @patch("src.pipeline.translator.asyncio.create_subprocess_exec") @pytest.mark.asyncio async def test_call_claude_async_retries_on_rate_limit( self, mock_subprocess: AsyncMock, temp_data_dir: Path ) -> None: """Test _call_claude_async retries on rate limit error.""" 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 class TestTranslatorQualityGate: """Tests for quality gate in translator.""" @patch("src.pipeline.translator.subprocess.run") def test_quality_gate_passes( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translation passes quality gate.""" 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=True, ) # Mock translation 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.text == "翻訳テスト。" @patch("src.pipeline.translator.subprocess.run") def test_check_quality_gate_not_enforced( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test quality gate is not enforced when disabled.""" 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, # Disabled ) # Mock translation mock_run.return_value = MagicMock( returncode=0, stdout="翻訳テスト。", stderr="" ) ref = VerseReference(book="john", chapter=1, verse=1) # Should succeed even without quality check result = translator.translate_verse(ref, skip_existing=False) assert result.text == "翻訳テスト。" class TestTranslatorJSONParsing: """Tests for JSON parsing edge cases.""" def test_parse_chunk_response_with_text_before_json( self, temp_data_dir: Path ) -> None: """Test parsing response with text before 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) response = 'Here is the translation:\n{"1:1": "初めに言葉があった"}' expected = ["1:1"] translations = translator._parse_chunk_response(response, expected) assert translations["1:1"] == "初めに言葉があった" def test_parse_chunk_response_nested_json(self, temp_data_dir: Path) -> None: """Test parsing response with 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) # JSON with escape characters response = '{"1:1": "言葉は\\n神と共に"}' expected = ["1:1"] translations = translator._parse_chunk_response(response, expected) assert translations["1:1"] == "言葉は\n神と共に" def test_extract_from_code_block_with_json_label( self, temp_data_dir: Path ) -> None: """Test extracting JSON from code block with json label.""" 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) # Single code block with json label response = '''```json {"1:1": "翻訳結果"} ```''' expected = ["1:1"] translations = translator._parse_chunk_response(response, expected) assert translations["1:1"] == "翻訳結果" def test_extract_json_by_brackets_with_escape( self, temp_data_dir: Path ) -> None: """Test JSON extraction with escaped characters.""" 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 before and quotes in JSON text = 'Result: {"1:1": "He said \\"Hello\\""}' result = translator._extract_json_by_brackets(text) assert result == '{"1:1": "He said \\"Hello\\""}' class TestTranslatorTimeout: """Tests for subprocess timeout.""" @patch("src.pipeline.translator.subprocess.run") def test_subprocess_timeout( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test subprocess timeout is passed correctly.""" import subprocess 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=30, # Custom timeout ) 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"] == 30 class TestTranslatorValidateTranslations: """Tests for translation validation.""" def test_validate_translations_missing_verse(self, temp_data_dir: Path) -> None: """Test validation fails on missing verse.""" 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) translations = {"1:1": "翻訳"} expected_verses = ["1:1", "1:2"] with pytest.raises(ChunkParsingError, match="Missing translations"): translator._validate_translations(translations, expected_verses, "original") def test_validate_translations_empty_translation( self, temp_data_dir: Path ) -> None: """Test validation handles empty translation values.""" 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) translations = {"1:1": "翻訳", "1:2": ""} expected_verses = ["1:1", "1:2"] # Empty string should still pass validation (content validation is separate) result = translator._validate_translations(translations, expected_verses, "original") assert result == translations class TestTranslatorCustomModel: """Tests for custom model configuration.""" @patch("src.pipeline.translator.subprocess.run") def test_custom_model(self, mock_run: MagicMock, temp_data_dir: Path) -> None: """Test using custom model.""" 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, model="claude-opus-4-5", ) 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.model == "claude-opus-4-5" # Verify model was passed in command call_args = mock_run.call_args[0][0] assert "--model" in call_args model_idx = call_args.index("--model") assert call_args[model_idx + 1] == "claude-opus-4-5"