公開前の確認用サイト
日常訳NICHIJOYAKUEnglish ↗

OPEN SOURCE

software/tests/test_translator_extended.py

このサイトで読み、必要なファイルを保存できます。

日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。

← オープンソースの案内へ

software/tests/test_translator_extended.py403 行このファイルを保存 ↓

ZIPに含まれるものと同じ公開ソースです。行番号から、その行へのリンクを保存できます。

ファイルの確認情報SHA-256 18605d13ec30e4604e482d345303794c242200af57e10d5f4560a7b41526572e
1"""Extended tests for translator to improve coverage on retry logic, quality gate, etc."""23import json4import pytest5from pathlib import Path6from unittest.mock import AsyncMock, MagicMock, patch78from src.context.builder import ContextBuilder9from src.context.glossary import GlossaryManager10from src.data.loader import DataLoader11from src.data.storage import Storage12from src.exceptions import (13    ChunkParsingError,14    ClaudeAPIError,15    QualityThresholdError,16    RateLimitError,17)18from src.models.verse import (19    TranslationContext,20    Verse,21    VerseReference,22)23from src.pipeline.prompt import PromptBuilder24from src.pipeline.translator import Translator25from src.utils.retry import RetryConfig262728@pytest.fixture29def temp_data_dir(tmp_path: Path) -> Path:30    """Create temporary data directory."""31    sources_dir = tmp_path / "sources"32    (sources_dir / "greek").mkdir(parents=True)33    (sources_dir / "english" / "kjv").mkdir(parents=True)34    (sources_dir / "japanese" / "meiji").mkdir(parents=True)35    (sources_dir / "japanese" / "taisho").mkdir(parents=True)36    (sources_dir / "english" / "asv").mkdir(parents=True)37    (tmp_path / "metadata").mkdir(parents=True)38    (tmp_path / "output").mkdir(parents=True)39    (tmp_path / "progress").mkdir(parents=True)4041    # Add test data42    greek_data = {43        "1:1": {"text": "Ἐν ἀρχῇ", "translit": "En archē"},44        "1:2": {"text": "οὗτος ἦν", "translit": "houtos ēn"},45    }46    with (sources_dir / "greek" / "john.json").open("w") as f:47        json.dump(greek_data, f)4849    kjv_data = {"1:1": "In the beginning", "1:2": "The same was"}50    with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:51        json.dump(kjv_data, f)5253    return tmp_path545556class TestTranslatorRetryLogic:57    """Tests for retry logic in translator."""5859    @patch("src.pipeline.translator.subprocess.run")60    def test_call_claude_retries_on_rate_limit(61        self, mock_run: MagicMock, temp_data_dir: Path62    ) -> None:63        """Test _call_claude retries on rate limit error."""64        loader = DataLoader(temp_data_dir)65        storage = Storage(temp_data_dir)66        glossary = GlossaryManager()67        context_builder = ContextBuilder(loader, glossary)68        prompt_builder = PromptBuilder(glossary)6970        retry_config = RetryConfig(71            max_attempts=3,72            base_delay=0.001,73            jitter=False,74            retryable_exceptions=(RateLimitError,),75        )76        translator = Translator(77            context_builder, prompt_builder, storage, retry_config=retry_config78        )7980        # First two calls fail with rate limit, third succeeds81        mock_run.side_effect = [82            MagicMock(returncode=1, stdout="", stderr="rate limit exceeded"),83            MagicMock(returncode=1, stdout="", stderr="rate limit exceeded"),84            MagicMock(returncode=0, stdout="翻訳結果", stderr=""),85        ]8687        result = translator._call_claude("system", "user")88        assert result == "翻訳結果"89        assert mock_run.call_count == 39091    @patch("src.pipeline.translator.subprocess.run")92    def test_call_claude_exhausts_retries(93        self, mock_run: MagicMock, temp_data_dir: Path94    ) -> None:95        """Test _call_claude raises after exhausting retries."""96        loader = DataLoader(temp_data_dir)97        storage = Storage(temp_data_dir)98        glossary = GlossaryManager()99        context_builder = ContextBuilder(loader, glossary)100        prompt_builder = PromptBuilder(glossary)101102        retry_config = RetryConfig(103            max_attempts=2,104            base_delay=0.001,105            jitter=False,106            retryable_exceptions=(RateLimitError,),107        )108        translator = Translator(109            context_builder, prompt_builder, storage, retry_config=retry_config110        )111112        # All calls fail with rate limit113        mock_run.return_value = MagicMock(114            returncode=1, stdout="", stderr="rate limit exceeded"115        )116117        with pytest.raises(RateLimitError):118            translator._call_claude("system", "user")119        assert mock_run.call_count == 2120121    @patch("src.pipeline.translator.asyncio.create_subprocess_exec")122    @pytest.mark.asyncio123    async def test_call_claude_async_retries_on_rate_limit(124        self, mock_subprocess: AsyncMock, temp_data_dir: Path125    ) -> None:126        """Test _call_claude_async retries on rate limit error."""127        loader = DataLoader(temp_data_dir)128        storage = Storage(temp_data_dir)129        glossary = GlossaryManager()130        context_builder = ContextBuilder(loader, glossary)131        prompt_builder = PromptBuilder(glossary)132133        retry_config = RetryConfig(134            max_attempts=3,135            base_delay=0.001,136            jitter=False,137            retryable_exceptions=(RateLimitError,),138        )139        translator = Translator(140            context_builder, prompt_builder, storage, retry_config=retry_config141        )142143        call_count = 0144145        async def create_process(*args, **kwargs):146            nonlocal call_count147            call_count += 1148            mock_process = AsyncMock()149            if call_count < 3:150                mock_process.returncode = 1151                mock_process.communicate.return_value = (b"", b"rate limit exceeded")152            else:153                mock_process.returncode = 0154                mock_process.communicate.return_value = ("非同期翻訳。".encode(), b"")155            return mock_process156157        mock_subprocess.side_effect = create_process158159        result = await translator._call_claude_async("system", "user")160        assert result == "非同期翻訳。"161        assert call_count == 3162163164class TestTranslatorQualityGate:165    """Tests for quality gate in translator."""166167    @patch("src.pipeline.translator.subprocess.run")168    def test_quality_gate_passes(169        self, mock_run: MagicMock, temp_data_dir: Path170    ) -> None:171        """Test translation passes quality gate."""172        loader = DataLoader(temp_data_dir)173        storage = Storage(temp_data_dir)174        glossary = GlossaryManager()175        context_builder = ContextBuilder(loader, glossary)176        prompt_builder = PromptBuilder(glossary)177178        translator = Translator(179            context_builder,180            prompt_builder,181            storage,182            quality_threshold=70.0,183            enforce_quality_gate=True,184        )185186        # Mock translation187        mock_run.return_value = MagicMock(188            returncode=0, stdout="翻訳テスト。", stderr=""189        )190191        ref = VerseReference(book="john", chapter=1, verse=1)192        result = translator.translate_verse(ref, skip_existing=False)193194        assert result.text == "翻訳テスト。"195196    @patch("src.pipeline.translator.subprocess.run")197    def test_check_quality_gate_not_enforced(198        self, mock_run: MagicMock, temp_data_dir: Path199    ) -> None:200        """Test quality gate is not enforced when disabled."""201        loader = DataLoader(temp_data_dir)202        storage = Storage(temp_data_dir)203        glossary = GlossaryManager()204        context_builder = ContextBuilder(loader, glossary)205        prompt_builder = PromptBuilder(glossary)206207        translator = Translator(208            context_builder,209            prompt_builder,210            storage,211            quality_threshold=70.0,212            enforce_quality_gate=False,  # Disabled213        )214215        # Mock translation216        mock_run.return_value = MagicMock(217            returncode=0, stdout="翻訳テスト。", stderr=""218        )219220        ref = VerseReference(book="john", chapter=1, verse=1)221        # Should succeed even without quality check222        result = translator.translate_verse(ref, skip_existing=False)223        assert result.text == "翻訳テスト。"224225226class TestTranslatorJSONParsing:227    """Tests for JSON parsing edge cases."""228229    def test_parse_chunk_response_with_text_before_json(230        self, temp_data_dir: Path231    ) -> None:232        """Test parsing response with text before JSON."""233        loader = DataLoader(temp_data_dir)234        glossary = GlossaryManager()235        storage = Storage(temp_data_dir)236        context_builder = ContextBuilder(loader, glossary)237        prompt_builder = PromptBuilder(glossary)238        translator = Translator(context_builder, prompt_builder, storage)239240        response = 'Here is the translation:\n{"1:1": "初めに言葉があった"}'241        expected = ["1:1"]242243        translations = translator._parse_chunk_response(response, expected)244        assert translations["1:1"] == "初めに言葉があった"245246    def test_parse_chunk_response_nested_json(self, temp_data_dir: Path) -> None:247        """Test parsing response with nested JSON object."""248        loader = DataLoader(temp_data_dir)249        glossary = GlossaryManager()250        storage = Storage(temp_data_dir)251        context_builder = ContextBuilder(loader, glossary)252        prompt_builder = PromptBuilder(glossary)253        translator = Translator(context_builder, prompt_builder, storage)254255        # JSON with escape characters256        response = '{"1:1": "言葉は\\n神と共に"}'257        expected = ["1:1"]258259        translations = translator._parse_chunk_response(response, expected)260        assert translations["1:1"] == "言葉は\n神と共に"261262    def test_extract_from_code_block_with_json_label(263        self, temp_data_dir: Path264    ) -> None:265        """Test extracting JSON from code block with json label."""266        loader = DataLoader(temp_data_dir)267        glossary = GlossaryManager()268        storage = Storage(temp_data_dir)269        context_builder = ContextBuilder(loader, glossary)270        prompt_builder = PromptBuilder(glossary)271        translator = Translator(context_builder, prompt_builder, storage)272273        # Single code block with json label274        response = '''```json275{"1:1": "翻訳結果"}276```'''277        expected = ["1:1"]278279        translations = translator._parse_chunk_response(response, expected)280        assert translations["1:1"] == "翻訳結果"281282    def test_extract_json_by_brackets_with_escape(283        self, temp_data_dir: Path284    ) -> None:285        """Test JSON extraction with escaped characters."""286        loader = DataLoader(temp_data_dir)287        glossary = GlossaryManager()288        storage = Storage(temp_data_dir)289        context_builder = ContextBuilder(loader, glossary)290        prompt_builder = PromptBuilder(glossary)291        translator = Translator(context_builder, prompt_builder, storage)292293        # Text before and quotes in JSON294        text = 'Result: {"1:1": "He said \\"Hello\\""}'295        result = translator._extract_json_by_brackets(text)296        assert result == '{"1:1": "He said \\"Hello\\""}'297298299class TestTranslatorTimeout:300    """Tests for subprocess timeout."""301302    @patch("src.pipeline.translator.subprocess.run")303    def test_subprocess_timeout(304        self, mock_run: MagicMock, temp_data_dir: Path305    ) -> None:306        """Test subprocess timeout is passed correctly."""307        import subprocess308309        loader = DataLoader(temp_data_dir)310        storage = Storage(temp_data_dir)311        glossary = GlossaryManager()312        context_builder = ContextBuilder(loader, glossary)313        prompt_builder = PromptBuilder(glossary)314315        translator = Translator(316            context_builder,317            prompt_builder,318            storage,319            subprocess_timeout=30,  # Custom timeout320        )321322        mock_run.return_value = MagicMock(323            returncode=0, stdout="翻訳結果", stderr=""324        )325326        translator._call_claude("system", "user")327328        # Verify timeout was passed329        call_kwargs = mock_run.call_args[1]330        assert call_kwargs["timeout"] == 30331332333class TestTranslatorValidateTranslations:334    """Tests for translation validation."""335336    def test_validate_translations_missing_verse(self, temp_data_dir: Path) -> None:337        """Test validation fails on missing verse."""338        loader = DataLoader(temp_data_dir)339        glossary = GlossaryManager()340        storage = Storage(temp_data_dir)341        context_builder = ContextBuilder(loader, glossary)342        prompt_builder = PromptBuilder(glossary)343        translator = Translator(context_builder, prompt_builder, storage)344345        translations = {"1:1": "翻訳"}346        expected_verses = ["1:1", "1:2"]347348        with pytest.raises(ChunkParsingError, match="Missing translations"):349            translator._validate_translations(translations, expected_verses, "original")350351    def test_validate_translations_empty_translation(352        self, temp_data_dir: Path353    ) -> None:354        """Test validation handles empty translation values."""355        loader = DataLoader(temp_data_dir)356        glossary = GlossaryManager()357        storage = Storage(temp_data_dir)358        context_builder = ContextBuilder(loader, glossary)359        prompt_builder = PromptBuilder(glossary)360        translator = Translator(context_builder, prompt_builder, storage)361362        translations = {"1:1": "翻訳", "1:2": ""}363        expected_verses = ["1:1", "1:2"]364365        # Empty string should still pass validation (content validation is separate)366        result = translator._validate_translations(translations, expected_verses, "original")367        assert result == translations368369370class TestTranslatorCustomModel:371    """Tests for custom model configuration."""372373    @patch("src.pipeline.translator.subprocess.run")374    def test_custom_model(self, mock_run: MagicMock, temp_data_dir: Path) -> None:375        """Test using custom model."""376        loader = DataLoader(temp_data_dir)377        storage = Storage(temp_data_dir)378        glossary = GlossaryManager()379        context_builder = ContextBuilder(loader, glossary)380        prompt_builder = PromptBuilder(glossary)381382        translator = Translator(383            context_builder,384            prompt_builder,385            storage,386            model="claude-opus-4-5",387        )388389        mock_run.return_value = MagicMock(390            returncode=0, stdout="翻訳結果", stderr=""391        )392393        ref = VerseReference(book="john", chapter=1, verse=1)394        result = translator.translate_verse(ref, skip_existing=False)395396        assert result.model == "claude-opus-4-5"397398        # Verify model was passed in command399        call_args = mock_run.call_args[0][0]400        assert "--model" in call_args401        model_idx = call_args.index("--model")402        assert call_args[model_idx + 1] == "claude-opus-4-5"403