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