"""Tests for pericope-based (chunk) translation.""" import json import pytest from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch from src.context.builder import ContextBuilder from src.context.glossary import GlossaryManager from src.context.pericope import PericopeManager from src.data.loader import DataLoader from src.data.storage import Storage from src.exceptions import ChunkParsingError from src.models.pericope import ( ChunkTranslationContext, ChunkTranslationOutput, Pericope, PericopeIndex, ) from src.models.verse import GlossaryTerm, VerseReference, Verse from src.pipeline.prompt import PromptBuilder from src.pipeline.translator import Translator @pytest.fixture def sample_pericope() -> Pericope: """Create a sample pericope.""" return Pericope( id="john-1-prologue", title="The Prologue: The Word Became Flesh", title_ja="序文:言葉は肉となった", start_chapter=1, start_verse=1, end_chapter=1, end_verse=5, verses=["1:1", "1:2", "1:3", "1:4", "1:5"], theme="The divine Logos, pre-existence", auto_generated=False, ) @pytest.fixture def sample_pericope_index(sample_pericope: Pericope) -> PericopeIndex: """Create a sample pericope index.""" return PericopeIndex( book="john", name="Gospel of John", pericopes=[ sample_pericope, Pericope( id="john-1-baptist", title="John the Baptist", start_chapter=1, start_verse=6, end_chapter=1, end_verse=8, verses=["1:6", "1:7", "1:8"], auto_generated=False, ), ], ) @pytest.fixture def temp_pericope_data(tmp_path: Path) -> Path: """Create temporary pericope data file.""" pericopes_data = { "version": "1.0", "source": "Test data", "books": { "john": { "name": "Gospel of John", "pericopes": [ { "id": "john-1-prologue", "title": "The Prologue", "title_ja": "序文", "start_chapter": 1, "start_verse": 1, "end_chapter": 1, "end_verse": 5, "verses": ["1:1", "1:2", "1:3", "1:4", "1:5"], "theme": "The divine Logos", }, { "id": "john-1-baptist", "title": "John the Baptist", "start_chapter": 1, "start_verse": 6, "end_chapter": 1, "end_verse": 8, "verses": ["1:6", "1:7", "1:8"], }, ], }, "titus": { "name": "Epistle to Titus", "pericopes": [ { "id": "titus-1-greeting", "title": "Greeting", "start_chapter": 1, "start_verse": 1, "end_chapter": 1, "end_verse": 4, "verses": ["1:1", "1:2", "1:3", "1:4"], }, ], }, }, } pericope_path = tmp_path / "pericopes.json" with pericope_path.open("w", encoding="utf-8") as f: json.dump(pericopes_data, f) return pericope_path @pytest.fixture def temp_data_dir(tmp_path: Path, temp_pericope_data: Path) -> Path: """Create temporary data directory with test data.""" sources_dir = tmp_path / "sources" (sources_dir / "greek").mkdir(parents=True) (sources_dir / "japanese" / "meiji").mkdir(parents=True) (sources_dir / "japanese" / "taisho").mkdir(parents=True) (sources_dir / "english" / "kjv").mkdir(parents=True) (sources_dir / "english" / "asv").mkdir(parents=True) (tmp_path / "metadata" / "john").mkdir(parents=True) (tmp_path / "output" / "john").mkdir(parents=True) # Greek data with 8 verses greek_data = { "1:1": {"text": "Ἐν ἀρχῇ ἦν ὁ λόγος", "translit": "En archē ēn ho logos"}, "1:2": {"text": "οὗτος ἦν ἐν ἀρχῇ", "translit": "houtos ēn en archē"}, "1:3": {"text": "πάντα δι' αὐτοῦ ἐγένετο", "translit": "panta di autou egeneto"}, "1:4": {"text": "ἐν αὐτῷ ζωὴ ἦν", "translit": "en autō zōē ēn"}, "1:5": {"text": "τὸ φῶς ἐν τῇ σκοτίᾳ", "translit": "to phōs en tē skotia"}, "1:6": {"text": "Ἐγένετο ἄνθρωπος", "translit": "Egeneto anthropos"}, "1:7": {"text": "οὗτος ἦλθεν εἰς μαρτυρίαν", "translit": "houtos ēlthen eis marturian"}, "1:8": {"text": "οὐκ ἦν ἐκεῖνος τὸ φῶς", "translit": "ouk ēn ekeinos to phōs"}, } with (sources_dir / "greek" / "john.json").open("w") as f: json.dump(greek_data, f) # KJV data kjv_data = { "1:1": "In the beginning was the Word", "1:2": "The same was in the beginning with God", "1:3": "All things were made by him", "1:4": "In him was life", "1:5": "And the light shineth in darkness", "1:6": "There was a man sent from God", "1:7": "The same came for a witness", "1:8": "He was not that Light", } with (sources_dir / "english" / "kjv" / "john.json").open("w") as f: json.dump(kjv_data, f) # Book metadata book_metadata = { "book": "john", "name_japanese": "ヨハネによる福音書", "name_english": "Gospel of John", "testament": "NT", "author": "John the Apostle", "themes": ["love", "light"], "description": "The fourth Gospel", "chapter_count": 21, "verse_count": 879, } with (tmp_path / "metadata" / "john" / "book_context.json").open("w") as f: json.dump(book_metadata, f) # Chapter summaries chapter_summaries = { "1": { "summary": "The prologue introduces Jesus as the Word", "key_events": ["Word becomes flesh"], "key_characters": ["Jesus", "John"], "key_themes": ["Light", "Word"], } } with (tmp_path / "metadata" / "john" / "chapter_summaries.json").open("w") as f: json.dump(chapter_summaries, f) # Glossary glossary = { "terms": [ { "original": "λόγος", "transliteration": "logos", "japanese": "言葉", "english": "word", "notes": "The Word", } ] } with (tmp_path / "metadata" / "glossary.json").open("w") as f: json.dump(glossary, f) # Copy pericope data to metadata folder import shutil shutil.copy(temp_pericope_data, tmp_path / "metadata" / "pericopes.json") return tmp_path class TestPericopeModel: """Tests for Pericope model.""" def test_pericope_creation(self, sample_pericope: Pericope) -> None: """Test creating a pericope.""" assert sample_pericope.id == "john-1-prologue" assert sample_pericope.verse_count == 5 assert sample_pericope.is_single_chapter is True def test_pericope_contains(self, sample_pericope: Pericope) -> None: """Test checking if verse is in pericope.""" assert sample_pericope.contains(1, 1) is True assert sample_pericope.contains(1, 5) is True assert sample_pericope.contains(1, 6) is False assert sample_pericope.contains(2, 1) is False def test_pericope_display_range_single_chapter( self, sample_pericope: Pericope ) -> None: """Test display range for single chapter.""" assert sample_pericope.get_display_range() == "1:1-5" def test_pericope_display_range_multi_chapter(self) -> None: """Test display range spanning chapters.""" pericope = Pericope( id="test", start_chapter=1, start_verse=10, end_chapter=2, end_verse=5, verses=["1:10", "1:11", "2:1", "2:2", "2:3", "2:4", "2:5"], ) assert pericope.get_display_range() == "1:10-2:5" def test_pericope_get_verse_refs(self, sample_pericope: Pericope) -> None: """Test getting verse references.""" refs = sample_pericope.get_verse_refs("john") assert len(refs) == 5 assert refs[0].book == "john" assert refs[0].chapter == 1 assert refs[0].verse == 1 class TestPericopeIndex: """Tests for PericopeIndex model.""" def test_find_pericope(self, sample_pericope_index: PericopeIndex) -> None: """Test finding pericope by verse.""" pericope = sample_pericope_index.find_pericope(1, 1) assert pericope is not None assert pericope.id == "john-1-prologue" def test_find_pericope_not_found( self, sample_pericope_index: PericopeIndex ) -> None: """Test finding non-existent verse.""" pericope = sample_pericope_index.find_pericope(2, 1) assert pericope is None def test_get_pericope_by_id(self, sample_pericope_index: PericopeIndex) -> None: """Test getting pericope by ID.""" pericope = sample_pericope_index.get_pericope_by_id("john-1-prologue") assert pericope is not None assert pericope.title == "The Prologue: The Word Became Flesh" def test_get_pericope_by_id_not_found( self, sample_pericope_index: PericopeIndex ) -> None: """Test getting non-existent pericope.""" pericope = sample_pericope_index.get_pericope_by_id("nonexistent") assert pericope is None def test_get_chapter_pericopes( self, sample_pericope_index: PericopeIndex ) -> None: """Test getting pericopes for a chapter.""" pericopes = sample_pericope_index.get_chapter_pericopes(1) assert len(pericopes) == 2 class TestPericopeManager: """Tests for PericopeManager.""" def test_load_pericopes(self, temp_pericope_data: Path) -> None: """Test loading pericope data.""" manager = PericopeManager(temp_pericope_data) manager.load() assert manager.has_pericope_data("john") assert manager.has_pericope_data("titus") assert not manager.has_pericope_data("romans") def test_get_pericope(self, temp_pericope_data: Path) -> None: """Test getting pericope for verse.""" manager = PericopeManager(temp_pericope_data) pericope = manager.get_pericope("john", 1, 1) assert pericope is not None assert pericope.id == "john-1-prologue" def test_get_pericope_not_found(self, temp_pericope_data: Path) -> None: """Test getting pericope for verse not in data.""" manager = PericopeManager(temp_pericope_data) pericope = manager.get_pericope("john", 5, 1) assert pericope is None def test_get_pericope_by_id(self, temp_pericope_data: Path) -> None: """Test getting pericope by ID.""" manager = PericopeManager(temp_pericope_data) result = manager.get_pericope_by_id("john-1-prologue") assert result is not None book, pericope = result assert book == "john" assert pericope.title == "The Prologue" def test_get_book_pericopes(self, temp_pericope_data: Path) -> None: """Test getting all pericopes for a book.""" manager = PericopeManager(temp_pericope_data) pericopes = manager.get_book_pericopes("john") assert len(pericopes) == 2 def test_get_chapter_pericopes(self, temp_pericope_data: Path) -> None: """Test getting pericopes for a chapter.""" manager = PericopeManager(temp_pericope_data) pericopes = manager.get_chapter_pericopes("john", 1) assert len(pericopes) == 2 def test_get_available_books(self, temp_pericope_data: Path) -> None: """Test getting available books.""" manager = PericopeManager(temp_pericope_data) books = manager.get_available_books() assert "john" in books assert "titus" in books def test_get_statistics(self, temp_pericope_data: Path) -> None: """Test getting statistics.""" manager = PericopeManager(temp_pericope_data) stats = manager.get_statistics() assert stats["books_with_data"] == 2 assert stats["total_pericopes"] == 3 assert stats["total_verses_covered"] == 12 def test_len(self, temp_pericope_data: Path) -> None: """Test len() returns total pericopes.""" manager = PericopeManager(temp_pericope_data) assert len(manager) == 3 def test_reload(self, temp_pericope_data: Path) -> None: """Test reload from file.""" manager = PericopeManager(temp_pericope_data) manager.load() # Modify file with temp_pericope_data.open("r") as f: data = json.load(f) data["books"]["john"]["pericopes"].append({ "id": "john-2-new", "start_chapter": 2, "start_verse": 1, "end_chapter": 2, "end_verse": 5, "verses": ["2:1", "2:2", "2:3", "2:4", "2:5"], }) with temp_pericope_data.open("w") as f: json.dump(data, f) manager.reload() pericopes = manager.get_book_pericopes("john") assert len(pericopes) == 3 def test_missing_file(self, tmp_path: Path) -> None: """Test handling missing pericope file.""" manager = PericopeManager(tmp_path / "nonexistent.json") manager.load() assert not manager.has_pericope_data("john") assert manager.get_book_pericopes("john") == [] class TestChunkTranslationOutput: """Tests for ChunkTranslationOutput model.""" def test_creation(self) -> None: """Test creating chunk output.""" output = ChunkTranslationOutput( pericope_id="john-1-prologue", book="john", translations={"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}, model="claude-sonnet-4-20250514", ) assert output.verse_count == 2 def test_get_verse_translation(self) -> None: """Test getting verse translation.""" output = ChunkTranslationOutput( pericope_id="john-1-prologue", book="john", translations={"1:1": "初めに言葉があった"}, model="claude-sonnet-4-20250514", ) assert output.get_verse_translation(1, 1) == "初めに言葉があった" assert output.get_verse_translation(1, 2) is None class TestChunkTranslationContext: """Tests for ChunkTranslationContext model.""" def test_creation(self, sample_pericope: Pericope) -> None: """Test creating chunk context.""" verses = [ Verse( reference=VerseReference(book="john", chapter=1, verse=1), greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος", ), Verse( reference=VerseReference(book="john", chapter=1, verse=2), greek_text="οὗτος ἦν ἐν ἀρχῇ", ), ] context = ChunkTranslationContext( pericope=sample_pericope, book="john", verses=verses, ) assert context.verse_refs == sample_pericope.verses assert len(context.source_texts) == 2 def test_get_combined_source_text(self, sample_pericope: Pericope) -> None: """Test getting combined source text.""" verses = [ Verse( reference=VerseReference(book="john", chapter=1, verse=1), greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος", ), Verse( reference=VerseReference(book="john", chapter=1, verse=2), greek_text="οὗτος ἦν ἐν ἀρχῇ", ), ] context = ChunkTranslationContext( pericope=sample_pericope, book="john", verses=verses, ) combined = context.get_combined_source_text() assert "[1:1]" in combined assert "[1:2]" in combined assert "λόγος" in combined class TestContextBuilderChunk: """Tests for ContextBuilder chunk methods.""" def test_build_chunk_context(self, temp_data_dir: Path) -> None: """Test building chunk context.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None context = builder.build_chunk_context("john", pericope) assert len(context.verses) == 5 assert context.book == "john" assert context.pericope.id == "john-1-prologue" def test_build_chunk_context_by_id(self, temp_data_dir: Path) -> None: """Test building chunk context by pericope ID.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) context = builder.build_chunk_context_by_id("john-1-prologue") assert context is not None assert len(context.verses) == 5 def test_build_chunk_context_by_id_not_found(self, temp_data_dir: Path) -> None: """Test building chunk context for non-existent ID.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) context = builder.build_chunk_context_by_id("nonexistent") assert context is None def test_build_chunk_context_for_verse(self, temp_data_dir: Path) -> None: """Test building chunk context for a verse.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) ref = VerseReference(book="john", chapter=1, verse=3) context = builder.build_chunk_context_for_verse(ref) assert context is not None assert context.pericope.id == "john-1-prologue" def test_chunk_context_includes_glossary(self, temp_data_dir: Path) -> None: """Test chunk context includes glossary terms.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) context = builder.build_chunk_context_by_id("john-1-prologue") assert context is not None # Should find λόγος in verse 1 assert len(context.glossary_terms) > 0 class TestPromptBuilderChunk: """Tests for PromptBuilder chunk methods.""" def test_build_chunk_prompt(self, temp_data_dir: Path) -> None: """Test building chunk prompt.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) context = context_builder.build_chunk_context_by_id("john-1-prologue") assert context is not None prompt = prompt_builder.build_chunk_prompt(context) assert "PERICOPE" in prompt assert "John" in prompt assert "SOURCE TEXTS" in prompt assert "JSON" in prompt def test_get_chunk_system_prompt(self) -> None: """Test getting chunk system prompt.""" glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) system_prompt = prompt_builder.get_chunk_system_prompt() assert "CHUNK TRANSLATION MODE" in system_prompt assert "PERICOPE" in system_prompt class TestTranslatorChunk: """Tests for Translator chunk methods.""" def test_parse_chunk_response_valid(self, temp_data_dir: Path) -> None: """Test parsing valid JSON response.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) response = '{"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}' expected = ["1:1", "1:2"] translations = translator._parse_chunk_response(response, expected) assert translations["1:1"] == "初めに言葉があった" assert translations["1:2"] == "この言葉は神と共にあった" def test_parse_chunk_response_with_markdown(self, temp_data_dir: Path) -> None: """Test parsing response with markdown code blocks.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) response = '''```json {"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"} ```''' expected = ["1:1", "1:2"] translations = translator._parse_chunk_response(response, expected) assert translations["1:1"] == "初めに言葉があった" def test_parse_chunk_response_missing_verse(self, temp_data_dir: Path) -> None: """Test parsing response with missing verse.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) response = '{"1:1": "初めに言葉があった"}' expected = ["1:1", "1:2"] with pytest.raises(ChunkParsingError, match="Missing translations"): translator._parse_chunk_response(response, expected) def test_parse_chunk_response_invalid_json(self, temp_data_dir: Path) -> None: """Test parsing invalid JSON response.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) response = "This is not JSON" expected = ["1:1"] with pytest.raises(ChunkParsingError, match="Failed to parse"): translator._parse_chunk_response(response, expected) @patch("src.pipeline.translator.subprocess.run") def test_translate_chunk( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translating a chunk.""" # Mock Claude response mock_run.return_value = MagicMock( returncode=0, stderr="", stdout='{"1:1": "初めに言葉があった", "1:2": "この言葉は", "1:3": "すべてのものは", "1:4": "命があった", "1:5": "光は闇の中に"}', ) loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None output = translator.translate_chunk("john", pericope, skip_existing=False) assert output.verse_count == 5 assert output.pericope_id == "john-1-prologue" assert "1:1" in output.translations @patch("src.pipeline.translator.subprocess.run") def test_translate_chunk_by_id( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translating chunk by ID.""" mock_run.return_value = MagicMock( returncode=0, stderr="", stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}', ) loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) output = translator.translate_chunk_by_id( "john-1-prologue", skip_existing=False ) assert output is not None assert output.pericope_id == "john-1-prologue" def test_translate_chunk_by_id_not_found(self, temp_data_dir: Path) -> None: """Test translating non-existent pericope.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) output = translator.translate_chunk_by_id("nonexistent") assert output is None def test_get_existing_chunk_translations(self, temp_data_dir: Path) -> None: """Test getting existing translations for a chunk.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Save a translation from src.models.verse import TranslationOutput output = TranslationOutput( reference=VerseReference(book="john", chapter=1, verse=1), text="初めに言葉があった", model="test", ) storage.save_translation(output) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None existing = translator._get_existing_chunk_translations("john", pericope) assert "1:1" in existing assert existing["1:1"] == "初めに言葉があった" def test_translate_chunk_skip_existing(self, temp_data_dir: Path) -> None: """Test translate_chunk returns existing translations when all exist.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Save all translations for the pericope from src.models.verse import TranslationOutput for i in range(1, 6): # verses 1-5 output = TranslationOutput( reference=VerseReference(book="john", chapter=1, verse=i), text=f"既存の翻訳 {i}", model="test", ) storage.save_translation(output) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None # Should return existing without calling Claude output = translator.translate_chunk("john", pericope, skip_existing=True) assert output.coherence_note == "Loaded from existing translations" assert output.verse_count == 5 @patch("src.pipeline.translator.subprocess.run") @patch("src.pipeline.translator.time.sleep") def test_translate_chapter_by_pericopes( self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test translating a chapter by pericopes.""" # Mock Claude responses for both pericopes mock_run.side_effect = [ MagicMock( returncode=0, stderr="", stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}', ), MagicMock( returncode=0, stderr="", stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}', ), ] loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) progress_calls = [] def progress_callback(current, total, pericope): progress_calls.append((current, total, pericope.id)) outputs = translator.translate_chapter_by_pericopes( "john", 1, skip_existing=False, progress_callback=progress_callback ) assert len(outputs) == 2 assert len(progress_calls) == 2 # Verify sleep was called between pericopes mock_sleep.assert_called() @patch("src.pipeline.translator.subprocess.run") def test_translate_chapter_by_pericopes_no_pericope_data( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test fallback to verse-by-verse when no pericope data.""" # Mock responses for verse-by-verse translation mock_run.side_effect = [ MagicMock(returncode=0, stderr="", stdout="翻訳1"), MagicMock(returncode=0, stderr="", stdout="翻訳2"), MagicMock(returncode=0, stderr="", stdout="翻訳3"), ] loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) # Use empty pericope manager (no data for chapter 5) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) # Chapter 5 has no pericope data outputs = translator.translate_chapter_by_pericopes( "john", 5, skip_existing=False ) # Should fallback to verse-by-verse assert len(outputs) == 1 assert "fallback" in outputs[0].pericope_id assert "Verse-by-verse fallback" in (outputs[0].coherence_note or "") @patch("src.pipeline.translator.subprocess.run") def test_translate_chunk_with_tm( self, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test chunk translation with translation memory.""" mock_run.return_value = MagicMock( returncode=0, stderr="", stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}', ) from src.context.translation_memory import TranslationMemoryManager loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary, translation_memory=tm) translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None output = translator.translate_chunk("john", pericope, skip_existing=False) assert output.verse_count == 5 # Verify segments were added to TM assert len(tm) > 0 def test_chunk_tm_matches_no_source(self, temp_data_dir: Path) -> None: """Test _get_chunk_tm_matches returns None when no source text.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) from src.context.translation_memory import TranslationMemoryManager tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) # Create context with empty verses context = ChunkTranslationContext( pericope=Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=["1:1"], ), book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), # No greek or hebrew text ) ], ) matches = translator._get_chunk_tm_matches(context) assert matches is None class TestPromptBuilderChunkEdgeCases: """Edge case tests for PromptBuilder chunk methods.""" def test_build_chunk_prompt_no_theme(self) -> None: """Test chunk prompt without theme.""" glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) pericope = Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=2, verses=["1:1", "1:2"], theme=None, # No theme ) context = ChunkTranslationContext( pericope=pericope, book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), greek_text="test text", ), Verse( reference=VerseReference(book="test", chapter=1, verse=2), greek_text="more text", ), ], ) prompt = prompt_builder.build_chunk_prompt(context) assert "PERICOPE" in prompt assert "Theme:" not in prompt # No theme line def test_build_chunk_prompt_with_context(self, temp_data_dir: Path) -> None: """Test chunk prompt with preceding/following context.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) # Get second pericope which should have preceding context pericope = pericope_manager.get_pericope("john", 1, 6) assert pericope is not None context = context_builder.build_chunk_context("john", pericope) prompt = prompt_builder.build_chunk_prompt(context) # Should have preceding context from first pericope assert "Preceding context" in prompt def test_chunk_context_no_preceding_for_first_verse( self, temp_data_dir: Path ) -> None: """Test no preceding context for first verse.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) pericope = pericope_manager.get_pericope("john", 1, 1) assert pericope is not None context = context_builder.build_chunk_context("john", pericope) # First verse should have no preceding context assert len(context.preceding_verses) == 0 class TestPericopeManagerEdgeCases: """Edge case tests for PericopeManager.""" def test_get_book_index(self, temp_pericope_data: Path) -> None: """Test getting book index.""" manager = PericopeManager(temp_pericope_data) index = manager.get_book_index("john") assert index is not None assert index.book == "john" def test_get_book_index_not_found(self, temp_pericope_data: Path) -> None: """Test getting book index for non-existent book.""" manager = PericopeManager(temp_pericope_data) index = manager.get_book_index("nonexistent") assert index is None def test_data_path_property(self, temp_pericope_data: Path) -> None: """Test data_path property.""" manager = PericopeManager(temp_pericope_data) assert manager.data_path == temp_pericope_data class TestTranslatorChunkEdgeCases: """Edge case tests for Translator chunk methods.""" def test_parse_chunk_response_not_dict(self, temp_data_dir: Path) -> None: """Test parsing response that is not a dict.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) translator = Translator(context_builder, prompt_builder, storage) response = '["1:1", "1:2"]' # Valid JSON but not a dict expected = ["1:1"] # Arrays are not dicts, so the robust parser fails to extract a valid dict with pytest.raises(ChunkParsingError, match="Failed to parse"): translator._parse_chunk_response(response, expected) def test_chunk_tm_matches_no_verses(self, temp_data_dir: Path) -> None: """Test _get_chunk_tm_matches with empty verses list.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) from src.context.translation_memory import TranslationMemoryManager tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) # Context with no verses context = ChunkTranslationContext( pericope=Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=["1:1"], ), book="test", verses=[], # Empty verses list ) matches = translator._get_chunk_tm_matches(context) assert matches is None def test_chunk_tm_matches_no_combined_text(self, temp_data_dir: Path) -> None: """Test _get_chunk_tm_matches when combined text is empty.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) context_builder = ContextBuilder(loader, glossary) prompt_builder = PromptBuilder(glossary) from src.context.translation_memory import TranslationMemoryManager tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) # Context with verse but no source text context = ChunkTranslationContext( pericope=Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=["1:1"], ), book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), english_kjv="Test verse", # Only English, no Greek/Hebrew ) ], ) matches = translator._get_chunk_tm_matches(context) assert matches is None @patch("src.pipeline.translator.subprocess.run") @patch("src.pipeline.translator.time.sleep") def test_translate_chapter_by_pericopes_with_tm_save( self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path ) -> None: """Test TM save after translating chapter by pericopes.""" mock_run.side_effect = [ MagicMock( returncode=0, stderr="", stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}', ), MagicMock( returncode=0, stderr="", stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}', ), ] from src.context.translation_memory import TranslationMemoryManager loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary, translation_memory=tm) translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) outputs = translator.translate_chapter_by_pericopes( "john", 1, skip_existing=False ) assert len(outputs) == 2 # Verify TM was saved (segments should have been added) assert len(tm) > 0 def test_add_chunk_to_tm_no_target(self, temp_data_dir: Path) -> None: """Test _add_chunk_to_tm when target translation is missing.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) storage = Storage(temp_data_dir) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) prompt_builder = PromptBuilder(glossary) from src.context.translation_memory import TranslationMemoryManager tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json") translator = Translator( context_builder, prompt_builder, storage, translation_memory=tm ) # Create context context = ChunkTranslationContext( pericope=Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=2, verses=["1:1", "1:2"], ), book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), greek_text="Test text", ), Verse( reference=VerseReference(book="test", chapter=1, verse=2), greek_text="More text", ), ], ) # Translations missing 1:2 translations = {"1:1": "翻訳1"} # Should not error, just skip missing translator._add_chunk_to_tm(context, translations) assert len(tm) == 1 # Only 1:1 added class TestBuilderChunkEdgeCases: """Edge case tests for ContextBuilder chunk methods.""" def test_build_chunk_context_no_verse_returns( self, temp_data_dir: Path ) -> None: """Test chunk context when pericope not found for verse.""" loader = DataLoader(temp_data_dir) glossary = GlossaryManager(loader.load_glossary()) pericope_manager = PericopeManager( temp_data_dir / "metadata" / "pericopes.json" ) context_builder = ContextBuilder( loader, glossary, pericope_manager=pericope_manager ) # Chapter 10 has no pericope data ref = VerseReference(book="john", chapter=10, verse=1) context = context_builder.build_chunk_context_for_verse(ref) assert context is None class TestPromptBuilderChunkFormatting: """Tests for prompt formatting with various data.""" def test_format_tm_matches_empty(self) -> None: """Test formatting empty TM matches.""" glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) result = prompt_builder._format_tm_matches([]) assert result == "" def test_build_chunk_prompt_with_tm_matches(self) -> None: """Test chunk prompt includes TM matches when provided.""" from src.models.translation_memory import TMMatch, TMSegment glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) pericope = Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=2, verses=["1:1", "1:2"], ) context = ChunkTranslationContext( pericope=pericope, book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), greek_text="test text", ), Verse( reference=VerseReference(book="test", chapter=1, verse=2), greek_text="more text", ), ], ) tm_matches = [ TMMatch( segment=TMSegment( id="seg1", source_text="test source", source_normalized="test source", target_text="テスト翻訳", reference=VerseReference(book="test", chapter=1, verse=1), ), similarity=0.85, match_type="fuzzy", ) ] prompt = prompt_builder.build_chunk_prompt(context, tm_matches=tm_matches) assert "TRANSLATION MEMORY" in prompt assert "85%" in prompt def test_build_chunk_prompt_with_hebrew(self) -> None: """Test chunk prompt with Hebrew source text.""" glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) pericope = Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=["1:1"], ) context = ChunkTranslationContext( pericope=pericope, book="genesis", verses=[ Verse( reference=VerseReference(book="genesis", chapter=1, verse=1), hebrew_text="בְּרֵאשִׁית בָּרָא אֱלֹהִים", # Hebrew text ), ], ) prompt = prompt_builder.build_chunk_prompt(context) assert "בְּרֵאשִׁית" in prompt def test_build_chunk_prompt_with_japanese_refs(self, temp_data_dir: Path) -> None: """Test chunk prompt with Japanese reference translations.""" glossary = GlossaryManager() prompt_builder = PromptBuilder(glossary) pericope = Pericope( id="test", start_chapter=1, start_verse=1, end_chapter=1, end_verse=1, verses=["1:1"], ) context = ChunkTranslationContext( pericope=pericope, book="test", verses=[ Verse( reference=VerseReference(book="test", chapter=1, verse=1), greek_text="test", japanese_meiji="明治訳", japanese_taisho="大正訳", ), ], ) prompt = prompt_builder.build_chunk_prompt(context) assert "明治元訳" in prompt assert "大正改訳" in prompt class TestPericopeManagerMissingBook: """Tests for PericopeManager with missing book data.""" def test_get_pericope_missing_book(self, temp_pericope_data: Path) -> None: """Test get_pericope returns None for missing book.""" manager = PericopeManager(temp_pericope_data) pericope = manager.get_pericope("nonexistent", 1, 1) assert pericope is None def test_get_chapter_pericopes_missing_book( self, temp_pericope_data: Path ) -> None: """Test get_chapter_pericopes returns [] for missing book.""" manager = PericopeManager(temp_pericope_data) pericopes = manager.get_chapter_pericopes("nonexistent", 1) assert pericopes == []