"""Tests for translation pipeline.""" import json import pytest from pathlib import Path from unittest.mock import 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 ClaudeAPIError from src.models.verse import ( BookMetadata, ChapterSummary, GlossaryTerm, TranslationContext, Verse, VerseReference, ) from src.pipeline.prompt import SYSTEM_PROMPT, PromptBuilder from src.pipeline.translator import Translator @pytest.fixture def glossary_terms() -> list[GlossaryTerm]: """Create test glossary terms.""" return [ GlossaryTerm( original="λόγος", transliteration="logos", japanese="言葉", english="word", notes="The Word", ), ] @pytest.fixture def sample_verse() -> Verse: """Create a sample verse.""" return Verse( reference=VerseReference(book="john", chapter=1, verse=1), greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος", greek_translit="En archē ēn ho logos", japanese_meiji="太初に言あり", japanese_taisho="元始に言あり", english_kjv="In the beginning was the Word", english_asv="In the beginning was the Word", ) @pytest.fixture def sample_context(sample_verse: Verse, glossary_terms: list[GlossaryTerm]) -> TranslationContext: """Create a sample translation context.""" return TranslationContext( target_verse=sample_verse, preceding_verses=[], following_verses=[ Verse( reference=VerseReference(book="john", chapter=1, verse=2), english_kjv="The same was in the beginning with God", ) ], chapter_summary=ChapterSummary( book="john", chapter=1, summary="The prologue", key_events=[], key_characters=[], key_themes=[], ), book_metadata=BookMetadata( book="john", name_japanese="ヨハネによる福音書", name_english="Gospel of John", testament="NT", author="John", themes=["love"], description="The fourth Gospel", chapter_count=21, verse_count=879, ), glossary_terms=glossary_terms, ) @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 TestPromptBuilder: """Tests for PromptBuilder.""" def test_get_system_prompt(self, glossary_terms: list[GlossaryTerm]) -> None: """Test getting system prompt.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.get_system_prompt() assert "expert Bible translator" in prompt assert "新共同訳" in prompt def test_build_prompt( self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm] ) -> None: """Test building a translation prompt.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(sample_context) # Check all sections are present assert "SOURCE TEXT:" in prompt assert "REFERENCE TRANSLATIONS:" in prompt assert "CONTEXT:" in prompt assert "GLOSSARY" in prompt assert "TRANSLATE:" in prompt def test_build_prompt_with_greek( self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt includes Greek text.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(sample_context) assert "Greek:" in prompt assert "Ἐν ἀρχῇ" in prompt assert "Romanization:" in prompt def test_build_prompt_with_references( self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt includes reference translations.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(sample_context) assert "明治元訳" in prompt assert "KJV:" in prompt def test_build_prompt_with_book_metadata( self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt includes book metadata.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(sample_context) assert "Gospel of John" in prompt assert "ヨハネによる福音書" in prompt def test_build_prompt_with_following_verses( self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt includes following verses.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(sample_context) assert "Following verses:" in prompt def test_build_prompt_hebrew_verse( self, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt with Hebrew text.""" verse = Verse( reference=VerseReference(book="genesis", chapter=1, verse=1), hebrew_text="בְּרֵאשִׁית", hebrew_translit="bereshit", english_kjv="In the beginning", ) context = TranslationContext(target_verse=verse) glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(context) assert "Hebrew:" in prompt assert "בְּרֵאשִׁית" in prompt def test_build_prompt_no_metadata( self, sample_verse: Verse, glossary_terms: list[GlossaryTerm] ) -> None: """Test prompt without metadata.""" context = TranslationContext( target_verse=sample_verse, book_metadata=None, chapter_summary=None, ) glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) prompt = builder.build_prompt(context) # Should still work without metadata assert "TRANSLATE:" in prompt def test_get_best_text_prefers_english( self, glossary_terms: list[GlossaryTerm] ) -> None: """Test _get_best_text prefers English.""" verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), english_kjv="KJV text", japanese_meiji="Meiji text", greek_text="Greek text", ) glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) text = builder._get_best_text(verse) assert text == "KJV text" def test_get_best_text_fallback_order( self, glossary_terms: list[GlossaryTerm] ) -> None: """Test _get_best_text fallback order.""" glossary = GlossaryManager(glossary_terms) builder = PromptBuilder(glossary) # Test ASV fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), english_asv="ASV text", ) assert builder._get_best_text(verse) == "ASV text" # Test Japanese fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), japanese_taisho="Taisho text", ) assert builder._get_best_text(verse) == "Taisho text" # Test Meiji fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), japanese_meiji="Meiji text", ) assert builder._get_best_text(verse) == "Meiji text" # Test Greek fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), greek_text="Greek text", ) assert builder._get_best_text(verse) == "Greek text" # Test Hebrew fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), hebrew_text="Hebrew text", ) assert builder._get_best_text(verse) == "Hebrew text" # Test empty fallback verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), ) assert builder._get_best_text(verse) == "[text not available]" class TestTranslator: """Tests for Translator.""" def test_init(self, temp_data_dir: Path) -> None: """Test initializing translator.""" 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) from src.pipeline.translator import DEFAULT_MODEL assert translator.model == DEFAULT_MODEL def test_translate_verse_skip_existing(self, temp_data_dir: Path) -> None: """Test translate_verse skips existing translations.""" 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) # Save an existing translation from src.models.verse import TranslationOutput ref = VerseReference(book="john", chapter=1, verse=1) existing = TranslationOutput( reference=ref, text="Existing translation", model="test", ) storage.save_translation(existing) # Should return existing without calling CLI result = translator.translate_verse(ref, skip_existing=True) assert result.text == "Existing translation" @patch("src.pipeline.translator.subprocess.run") def test_translate_verse_calls_claude( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translate_verse calls Claude CLI for new verses.""" 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.run 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 == "初めに言葉があった。" mock_run.assert_called_once() @patch("src.pipeline.translator.subprocess.run") def test_call_claude_raises_on_error( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test _call_claude raises ClaudeAPIError on CLI failure.""" 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.run to return error mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="API error" ) with pytest.raises(ClaudeAPIError): translator._call_claude("system", "user") def test_get_sources_used(self, temp_data_dir: Path) -> None: """Test _get_sources_used returns correct sources.""" 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) verse = Verse( reference=VerseReference(book="john", chapter=1, verse=1), greek_text="Greek", english_kjv="KJV", japanese_meiji="Meiji", ) context = TranslationContext(target_verse=verse) sources = translator._get_sources_used(context) assert "greek" in sources assert "kjv" in sources assert "meiji" in sources def test_get_sources_used_hebrew(self, temp_data_dir: Path) -> None: """Test _get_sources_used includes Hebrew.""" 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) verse = Verse( reference=VerseReference(book="genesis", chapter=1, verse=1), hebrew_text="Hebrew", japanese_taisho="Taisho", english_asv="ASV", ) context = TranslationContext(target_verse=verse) sources = translator._get_sources_used(context) assert "hebrew" in sources assert "taisho" in sources assert "asv" in sources @patch("src.pipeline.translator.subprocess.run") def test_translate_verse_parses_furigana( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translate_verse parses furigana from Claude response.""" 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.run returning text with furigana notation 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) # Check plain text has furigana stripped assert result.text == "神は愛です。" # Check original text with furigana is preserved assert result.text_with_furigana == "神[かみ]は愛[あい]です。" # Check annotations are correctly extracted assert len(result.furigana_annotations) == 2 assert result.furigana_annotations[0]["base_text"] == "神" assert result.furigana_annotations[0]["reading"] == "かみ" assert result.furigana_annotations[1]["base_text"] == "愛" assert result.furigana_annotations[1]["reading"] == "あい" @patch("src.pipeline.translator.subprocess.run") def test_translate_verse_no_furigana( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translate_verse handles text without furigana.""" 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.run returning text without furigana 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 == "初めに言葉があった。" assert result.text_with_furigana == "初めに言葉があった。" assert result.furigana_annotations == [] def test_save_chunk_translations_parses_furigana( self, temp_data_dir: Path ) -> None: """Test _save_chunk_translations parses furigana from chunk output.""" 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) # Call _save_chunk_translations with furigana text translations = { "1:1": "神[かみ]は愛[あい]です。", "1:2": "言葉[ことば]は神[かみ]と共[とも]にあった。", } translator._save_chunk_translations("john", translations) # Load and verify loaded = storage.load_translation( VerseReference(book="john", chapter=1, verse=1) ) assert loaded is not None assert loaded.text == "神は愛です。" assert loaded.text_with_furigana == "神[かみ]は愛[あい]です。" assert len(loaded.furigana_annotations) == 2 loaded2 = storage.load_translation( VerseReference(book="john", chapter=1, verse=2) ) assert loaded2 is not None assert loaded2.text == "言葉は神と共にあった。" assert len(loaded2.furigana_annotations) == 3 class TestTranslatorProtoArchive: """Tests for translator proto archive functionality.""" def test_enable_proto_archive_default_false(self, temp_data_dir: Path) -> None: """Test enable_proto_archive is False by default.""" 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) assert translator.enable_proto_archive is False assert storage.enable_archive is False def test_enable_proto_archive_init(self, temp_data_dir: Path) -> None: """Test enable_proto_archive can be set in init.""" 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, enable_proto_archive=True ) assert translator.enable_proto_archive is True assert storage.enable_archive is True def test_enable_proto_archive_property_setter(self, temp_data_dir: Path) -> None: """Test enable_proto_archive property setter.""" 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) assert translator.enable_proto_archive is False translator.enable_proto_archive = True assert translator.enable_proto_archive is True assert storage.enable_archive is True translator.enable_proto_archive = False assert translator.enable_proto_archive is False assert storage.enable_archive is False