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

OPEN SOURCE

software/tests/test_pericope.py

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

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

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

software/tests/test_pericope.py1423 行このファイルを保存 ↓

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

ファイルの確認情報SHA-256 c60c568aa6678edf0ed5f3dddcc1700b0637fbd95e9115afca11a121e1c06c7d
1"""Tests for pericope-based (chunk) translation."""23import json4import pytest5from datetime import datetime6from pathlib import Path7from unittest.mock import MagicMock, patch89from src.context.builder import ContextBuilder10from src.context.glossary import GlossaryManager11from src.context.pericope import PericopeManager12from src.data.loader import DataLoader13from src.data.storage import Storage14from src.exceptions import ChunkParsingError15from src.models.pericope import (16    ChunkTranslationContext,17    ChunkTranslationOutput,18    Pericope,19    PericopeIndex,20)21from src.models.verse import GlossaryTerm, VerseReference, Verse22from src.pipeline.prompt import PromptBuilder23from src.pipeline.translator import Translator242526@pytest.fixture27def sample_pericope() -> Pericope:28    """Create a sample pericope."""29    return Pericope(30        id="john-1-prologue",31        title="The Prologue: The Word Became Flesh",32        title_ja="序文:言葉は肉となった",33        start_chapter=1,34        start_verse=1,35        end_chapter=1,36        end_verse=5,37        verses=["1:1", "1:2", "1:3", "1:4", "1:5"],38        theme="The divine Logos, pre-existence",39        auto_generated=False,40    )414243@pytest.fixture44def sample_pericope_index(sample_pericope: Pericope) -> PericopeIndex:45    """Create a sample pericope index."""46    return PericopeIndex(47        book="john",48        name="Gospel of John",49        pericopes=[50            sample_pericope,51            Pericope(52                id="john-1-baptist",53                title="John the Baptist",54                start_chapter=1,55                start_verse=6,56                end_chapter=1,57                end_verse=8,58                verses=["1:6", "1:7", "1:8"],59                auto_generated=False,60            ),61        ],62    )636465@pytest.fixture66def temp_pericope_data(tmp_path: Path) -> Path:67    """Create temporary pericope data file."""68    pericopes_data = {69        "version": "1.0",70        "source": "Test data",71        "books": {72            "john": {73                "name": "Gospel of John",74                "pericopes": [75                    {76                        "id": "john-1-prologue",77                        "title": "The Prologue",78                        "title_ja": "序文",79                        "start_chapter": 1,80                        "start_verse": 1,81                        "end_chapter": 1,82                        "end_verse": 5,83                        "verses": ["1:1", "1:2", "1:3", "1:4", "1:5"],84                        "theme": "The divine Logos",85                    },86                    {87                        "id": "john-1-baptist",88                        "title": "John the Baptist",89                        "start_chapter": 1,90                        "start_verse": 6,91                        "end_chapter": 1,92                        "end_verse": 8,93                        "verses": ["1:6", "1:7", "1:8"],94                    },95                ],96            },97            "titus": {98                "name": "Epistle to Titus",99                "pericopes": [100                    {101                        "id": "titus-1-greeting",102                        "title": "Greeting",103                        "start_chapter": 1,104                        "start_verse": 1,105                        "end_chapter": 1,106                        "end_verse": 4,107                        "verses": ["1:1", "1:2", "1:3", "1:4"],108                    },109                ],110            },111        },112    }113    pericope_path = tmp_path / "pericopes.json"114    with pericope_path.open("w", encoding="utf-8") as f:115        json.dump(pericopes_data, f)116    return pericope_path117118119@pytest.fixture120def temp_data_dir(tmp_path: Path, temp_pericope_data: Path) -> Path:121    """Create temporary data directory with test data."""122    sources_dir = tmp_path / "sources"123    (sources_dir / "greek").mkdir(parents=True)124    (sources_dir / "japanese" / "meiji").mkdir(parents=True)125    (sources_dir / "japanese" / "taisho").mkdir(parents=True)126    (sources_dir / "english" / "kjv").mkdir(parents=True)127    (sources_dir / "english" / "asv").mkdir(parents=True)128    (tmp_path / "metadata" / "john").mkdir(parents=True)129    (tmp_path / "output" / "john").mkdir(parents=True)130131    # Greek data with 8 verses132    greek_data = {133        "1:1": {"text": "Ἐν ἀρχῇ ἦν ὁ λόγος", "translit": "En archē ēn ho logos"},134        "1:2": {"text": "οὗτος ἦν ἐν ἀρχῇ", "translit": "houtos ēn en archē"},135        "1:3": {"text": "πάντα δι' αὐτοῦ ἐγένετο", "translit": "panta di autou egeneto"},136        "1:4": {"text": "ἐν αὐτῷ ζωὴ ἦν", "translit": "en autō zōē ēn"},137        "1:5": {"text": "τὸ φῶς ἐν τῇ σκοτίᾳ", "translit": "to phōs en tē skotia"},138        "1:6": {"text": "Ἐγένετο ἄνθρωπος", "translit": "Egeneto anthropos"},139        "1:7": {"text": "οὗτος ἦλθεν εἰς μαρτυρίαν", "translit": "houtos ēlthen eis marturian"},140        "1:8": {"text": "οὐκ ἦν ἐκεῖνος τὸ φῶς", "translit": "ouk ēn ekeinos to phōs"},141    }142    with (sources_dir / "greek" / "john.json").open("w") as f:143        json.dump(greek_data, f)144145    # KJV data146    kjv_data = {147        "1:1": "In the beginning was the Word",148        "1:2": "The same was in the beginning with God",149        "1:3": "All things were made by him",150        "1:4": "In him was life",151        "1:5": "And the light shineth in darkness",152        "1:6": "There was a man sent from God",153        "1:7": "The same came for a witness",154        "1:8": "He was not that Light",155    }156    with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:157        json.dump(kjv_data, f)158159    # Book metadata160    book_metadata = {161        "book": "john",162        "name_japanese": "ヨハネによる福音書",163        "name_english": "Gospel of John",164        "testament": "NT",165        "author": "John the Apostle",166        "themes": ["love", "light"],167        "description": "The fourth Gospel",168        "chapter_count": 21,169        "verse_count": 879,170    }171    with (tmp_path / "metadata" / "john" / "book_context.json").open("w") as f:172        json.dump(book_metadata, f)173174    # Chapter summaries175    chapter_summaries = {176        "1": {177            "summary": "The prologue introduces Jesus as the Word",178            "key_events": ["Word becomes flesh"],179            "key_characters": ["Jesus", "John"],180            "key_themes": ["Light", "Word"],181        }182    }183    with (tmp_path / "metadata" / "john" / "chapter_summaries.json").open("w") as f:184        json.dump(chapter_summaries, f)185186    # Glossary187    glossary = {188        "terms": [189            {190                "original": "λόγος",191                "transliteration": "logos",192                "japanese": "言葉",193                "english": "word",194                "notes": "The Word",195            }196        ]197    }198    with (tmp_path / "metadata" / "glossary.json").open("w") as f:199        json.dump(glossary, f)200201    # Copy pericope data to metadata folder202    import shutil203204    shutil.copy(temp_pericope_data, tmp_path / "metadata" / "pericopes.json")205206    return tmp_path207208209class TestPericopeModel:210    """Tests for Pericope model."""211212    def test_pericope_creation(self, sample_pericope: Pericope) -> None:213        """Test creating a pericope."""214        assert sample_pericope.id == "john-1-prologue"215        assert sample_pericope.verse_count == 5216        assert sample_pericope.is_single_chapter is True217218    def test_pericope_contains(self, sample_pericope: Pericope) -> None:219        """Test checking if verse is in pericope."""220        assert sample_pericope.contains(1, 1) is True221        assert sample_pericope.contains(1, 5) is True222        assert sample_pericope.contains(1, 6) is False223        assert sample_pericope.contains(2, 1) is False224225    def test_pericope_display_range_single_chapter(226        self, sample_pericope: Pericope227    ) -> None:228        """Test display range for single chapter."""229        assert sample_pericope.get_display_range() == "1:1-5"230231    def test_pericope_display_range_multi_chapter(self) -> None:232        """Test display range spanning chapters."""233        pericope = Pericope(234            id="test",235            start_chapter=1,236            start_verse=10,237            end_chapter=2,238            end_verse=5,239            verses=["1:10", "1:11", "2:1", "2:2", "2:3", "2:4", "2:5"],240        )241        assert pericope.get_display_range() == "1:10-2:5"242243    def test_pericope_get_verse_refs(self, sample_pericope: Pericope) -> None:244        """Test getting verse references."""245        refs = sample_pericope.get_verse_refs("john")246        assert len(refs) == 5247        assert refs[0].book == "john"248        assert refs[0].chapter == 1249        assert refs[0].verse == 1250251252class TestPericopeIndex:253    """Tests for PericopeIndex model."""254255    def test_find_pericope(self, sample_pericope_index: PericopeIndex) -> None:256        """Test finding pericope by verse."""257        pericope = sample_pericope_index.find_pericope(1, 1)258        assert pericope is not None259        assert pericope.id == "john-1-prologue"260261    def test_find_pericope_not_found(262        self, sample_pericope_index: PericopeIndex263    ) -> None:264        """Test finding non-existent verse."""265        pericope = sample_pericope_index.find_pericope(2, 1)266        assert pericope is None267268    def test_get_pericope_by_id(self, sample_pericope_index: PericopeIndex) -> None:269        """Test getting pericope by ID."""270        pericope = sample_pericope_index.get_pericope_by_id("john-1-prologue")271        assert pericope is not None272        assert pericope.title == "The Prologue: The Word Became Flesh"273274    def test_get_pericope_by_id_not_found(275        self, sample_pericope_index: PericopeIndex276    ) -> None:277        """Test getting non-existent pericope."""278        pericope = sample_pericope_index.get_pericope_by_id("nonexistent")279        assert pericope is None280281    def test_get_chapter_pericopes(282        self, sample_pericope_index: PericopeIndex283    ) -> None:284        """Test getting pericopes for a chapter."""285        pericopes = sample_pericope_index.get_chapter_pericopes(1)286        assert len(pericopes) == 2287288289class TestPericopeManager:290    """Tests for PericopeManager."""291292    def test_load_pericopes(self, temp_pericope_data: Path) -> None:293        """Test loading pericope data."""294        manager = PericopeManager(temp_pericope_data)295        manager.load()296297        assert manager.has_pericope_data("john")298        assert manager.has_pericope_data("titus")299        assert not manager.has_pericope_data("romans")300301    def test_get_pericope(self, temp_pericope_data: Path) -> None:302        """Test getting pericope for verse."""303        manager = PericopeManager(temp_pericope_data)304305        pericope = manager.get_pericope("john", 1, 1)306        assert pericope is not None307        assert pericope.id == "john-1-prologue"308309    def test_get_pericope_not_found(self, temp_pericope_data: Path) -> None:310        """Test getting pericope for verse not in data."""311        manager = PericopeManager(temp_pericope_data)312313        pericope = manager.get_pericope("john", 5, 1)314        assert pericope is None315316    def test_get_pericope_by_id(self, temp_pericope_data: Path) -> None:317        """Test getting pericope by ID."""318        manager = PericopeManager(temp_pericope_data)319320        result = manager.get_pericope_by_id("john-1-prologue")321        assert result is not None322        book, pericope = result323        assert book == "john"324        assert pericope.title == "The Prologue"325326    def test_get_book_pericopes(self, temp_pericope_data: Path) -> None:327        """Test getting all pericopes for a book."""328        manager = PericopeManager(temp_pericope_data)329330        pericopes = manager.get_book_pericopes("john")331        assert len(pericopes) == 2332333    def test_get_chapter_pericopes(self, temp_pericope_data: Path) -> None:334        """Test getting pericopes for a chapter."""335        manager = PericopeManager(temp_pericope_data)336337        pericopes = manager.get_chapter_pericopes("john", 1)338        assert len(pericopes) == 2339340    def test_get_available_books(self, temp_pericope_data: Path) -> None:341        """Test getting available books."""342        manager = PericopeManager(temp_pericope_data)343344        books = manager.get_available_books()345        assert "john" in books346        assert "titus" in books347348    def test_get_statistics(self, temp_pericope_data: Path) -> None:349        """Test getting statistics."""350        manager = PericopeManager(temp_pericope_data)351352        stats = manager.get_statistics()353        assert stats["books_with_data"] == 2354        assert stats["total_pericopes"] == 3355        assert stats["total_verses_covered"] == 12356357    def test_len(self, temp_pericope_data: Path) -> None:358        """Test len() returns total pericopes."""359        manager = PericopeManager(temp_pericope_data)360        assert len(manager) == 3361362    def test_reload(self, temp_pericope_data: Path) -> None:363        """Test reload from file."""364        manager = PericopeManager(temp_pericope_data)365        manager.load()366367        # Modify file368        with temp_pericope_data.open("r") as f:369            data = json.load(f)370        data["books"]["john"]["pericopes"].append({371            "id": "john-2-new",372            "start_chapter": 2,373            "start_verse": 1,374            "end_chapter": 2,375            "end_verse": 5,376            "verses": ["2:1", "2:2", "2:3", "2:4", "2:5"],377        })378        with temp_pericope_data.open("w") as f:379            json.dump(data, f)380381        manager.reload()382        pericopes = manager.get_book_pericopes("john")383        assert len(pericopes) == 3384385    def test_missing_file(self, tmp_path: Path) -> None:386        """Test handling missing pericope file."""387        manager = PericopeManager(tmp_path / "nonexistent.json")388        manager.load()389390        assert not manager.has_pericope_data("john")391        assert manager.get_book_pericopes("john") == []392393394class TestChunkTranslationOutput:395    """Tests for ChunkTranslationOutput model."""396397    def test_creation(self) -> None:398        """Test creating chunk output."""399        output = ChunkTranslationOutput(400            pericope_id="john-1-prologue",401            book="john",402            translations={"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"},403            model="claude-sonnet-4-20250514",404        )405        assert output.verse_count == 2406407    def test_get_verse_translation(self) -> None:408        """Test getting verse translation."""409        output = ChunkTranslationOutput(410            pericope_id="john-1-prologue",411            book="john",412            translations={"1:1": "初めに言葉があった"},413            model="claude-sonnet-4-20250514",414        )415        assert output.get_verse_translation(1, 1) == "初めに言葉があった"416        assert output.get_verse_translation(1, 2) is None417418419class TestChunkTranslationContext:420    """Tests for ChunkTranslationContext model."""421422    def test_creation(self, sample_pericope: Pericope) -> None:423        """Test creating chunk context."""424        verses = [425            Verse(426                reference=VerseReference(book="john", chapter=1, verse=1),427                greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος",428            ),429            Verse(430                reference=VerseReference(book="john", chapter=1, verse=2),431                greek_text="οὗτος ἦν ἐν ἀρχῇ",432            ),433        ]434435        context = ChunkTranslationContext(436            pericope=sample_pericope,437            book="john",438            verses=verses,439        )440441        assert context.verse_refs == sample_pericope.verses442        assert len(context.source_texts) == 2443444    def test_get_combined_source_text(self, sample_pericope: Pericope) -> None:445        """Test getting combined source text."""446        verses = [447            Verse(448                reference=VerseReference(book="john", chapter=1, verse=1),449                greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος",450            ),451            Verse(452                reference=VerseReference(book="john", chapter=1, verse=2),453                greek_text="οὗτος ἦν ἐν ἀρχῇ",454            ),455        ]456457        context = ChunkTranslationContext(458            pericope=sample_pericope,459            book="john",460            verses=verses,461        )462463        combined = context.get_combined_source_text()464        assert "[1:1]" in combined465        assert "[1:2]" in combined466        assert "λόγος" in combined467468469class TestContextBuilderChunk:470    """Tests for ContextBuilder chunk methods."""471472    def test_build_chunk_context(self, temp_data_dir: Path) -> None:473        """Test building chunk context."""474        loader = DataLoader(temp_data_dir)475        glossary = GlossaryManager(loader.load_glossary())476        pericope_manager = PericopeManager(477            temp_data_dir / "metadata" / "pericopes.json"478        )479        builder = ContextBuilder(480            loader, glossary, pericope_manager=pericope_manager481        )482483        pericope = pericope_manager.get_pericope("john", 1, 1)484        assert pericope is not None485486        context = builder.build_chunk_context("john", pericope)487488        assert len(context.verses) == 5489        assert context.book == "john"490        assert context.pericope.id == "john-1-prologue"491492    def test_build_chunk_context_by_id(self, temp_data_dir: Path) -> None:493        """Test building chunk context by pericope ID."""494        loader = DataLoader(temp_data_dir)495        glossary = GlossaryManager(loader.load_glossary())496        pericope_manager = PericopeManager(497            temp_data_dir / "metadata" / "pericopes.json"498        )499        builder = ContextBuilder(500            loader, glossary, pericope_manager=pericope_manager501        )502503        context = builder.build_chunk_context_by_id("john-1-prologue")504505        assert context is not None506        assert len(context.verses) == 5507508    def test_build_chunk_context_by_id_not_found(self, temp_data_dir: Path) -> None:509        """Test building chunk context for non-existent ID."""510        loader = DataLoader(temp_data_dir)511        glossary = GlossaryManager(loader.load_glossary())512        pericope_manager = PericopeManager(513            temp_data_dir / "metadata" / "pericopes.json"514        )515        builder = ContextBuilder(516            loader, glossary, pericope_manager=pericope_manager517        )518519        context = builder.build_chunk_context_by_id("nonexistent")520        assert context is None521522    def test_build_chunk_context_for_verse(self, temp_data_dir: Path) -> None:523        """Test building chunk context for a verse."""524        loader = DataLoader(temp_data_dir)525        glossary = GlossaryManager(loader.load_glossary())526        pericope_manager = PericopeManager(527            temp_data_dir / "metadata" / "pericopes.json"528        )529        builder = ContextBuilder(530            loader, glossary, pericope_manager=pericope_manager531        )532533        ref = VerseReference(book="john", chapter=1, verse=3)534        context = builder.build_chunk_context_for_verse(ref)535536        assert context is not None537        assert context.pericope.id == "john-1-prologue"538539    def test_chunk_context_includes_glossary(self, temp_data_dir: Path) -> None:540        """Test chunk context includes glossary terms."""541        loader = DataLoader(temp_data_dir)542        glossary = GlossaryManager(loader.load_glossary())543        pericope_manager = PericopeManager(544            temp_data_dir / "metadata" / "pericopes.json"545        )546        builder = ContextBuilder(547            loader, glossary, pericope_manager=pericope_manager548        )549550        context = builder.build_chunk_context_by_id("john-1-prologue")551552        assert context is not None553        # Should find λόγος in verse 1554        assert len(context.glossary_terms) > 0555556557class TestPromptBuilderChunk:558    """Tests for PromptBuilder chunk methods."""559560    def test_build_chunk_prompt(self, temp_data_dir: Path) -> None:561        """Test building chunk prompt."""562        loader = DataLoader(temp_data_dir)563        glossary = GlossaryManager(loader.load_glossary())564        pericope_manager = PericopeManager(565            temp_data_dir / "metadata" / "pericopes.json"566        )567        context_builder = ContextBuilder(568            loader, glossary, pericope_manager=pericope_manager569        )570        prompt_builder = PromptBuilder(glossary)571572        context = context_builder.build_chunk_context_by_id("john-1-prologue")573        assert context is not None574575        prompt = prompt_builder.build_chunk_prompt(context)576577        assert "PERICOPE" in prompt578        assert "John" in prompt579        assert "SOURCE TEXTS" in prompt580        assert "JSON" in prompt581582    def test_get_chunk_system_prompt(self) -> None:583        """Test getting chunk system prompt."""584        glossary = GlossaryManager()585        prompt_builder = PromptBuilder(glossary)586587        system_prompt = prompt_builder.get_chunk_system_prompt()588589        assert "CHUNK TRANSLATION MODE" in system_prompt590        assert "PERICOPE" in system_prompt591592593class TestTranslatorChunk:594    """Tests for Translator chunk methods."""595596    def test_parse_chunk_response_valid(self, temp_data_dir: Path) -> None:597        """Test parsing valid JSON response."""598        loader = DataLoader(temp_data_dir)599        glossary = GlossaryManager(loader.load_glossary())600        storage = Storage(temp_data_dir)601        pericope_manager = PericopeManager(602            temp_data_dir / "metadata" / "pericopes.json"603        )604        context_builder = ContextBuilder(605            loader, glossary, pericope_manager=pericope_manager606        )607        prompt_builder = PromptBuilder(glossary)608        translator = Translator(context_builder, prompt_builder, storage)609610        response = '{"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}'611        expected = ["1:1", "1:2"]612613        translations = translator._parse_chunk_response(response, expected)614615        assert translations["1:1"] == "初めに言葉があった"616        assert translations["1:2"] == "この言葉は神と共にあった"617618    def test_parse_chunk_response_with_markdown(self, temp_data_dir: Path) -> None:619        """Test parsing response with markdown code blocks."""620        loader = DataLoader(temp_data_dir)621        glossary = GlossaryManager(loader.load_glossary())622        storage = Storage(temp_data_dir)623        context_builder = ContextBuilder(loader, glossary)624        prompt_builder = PromptBuilder(glossary)625        translator = Translator(context_builder, prompt_builder, storage)626627        response = '''```json628{"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}629```'''630        expected = ["1:1", "1:2"]631632        translations = translator._parse_chunk_response(response, expected)633634        assert translations["1:1"] == "初めに言葉があった"635636    def test_parse_chunk_response_missing_verse(self, temp_data_dir: Path) -> None:637        """Test parsing response with missing verse."""638        loader = DataLoader(temp_data_dir)639        glossary = GlossaryManager(loader.load_glossary())640        storage = Storage(temp_data_dir)641        context_builder = ContextBuilder(loader, glossary)642        prompt_builder = PromptBuilder(glossary)643        translator = Translator(context_builder, prompt_builder, storage)644645        response = '{"1:1": "初めに言葉があった"}'646        expected = ["1:1", "1:2"]647648        with pytest.raises(ChunkParsingError, match="Missing translations"):649            translator._parse_chunk_response(response, expected)650651    def test_parse_chunk_response_invalid_json(self, temp_data_dir: Path) -> None:652        """Test parsing invalid JSON response."""653        loader = DataLoader(temp_data_dir)654        glossary = GlossaryManager(loader.load_glossary())655        storage = Storage(temp_data_dir)656        context_builder = ContextBuilder(loader, glossary)657        prompt_builder = PromptBuilder(glossary)658        translator = Translator(context_builder, prompt_builder, storage)659660        response = "This is not JSON"661        expected = ["1:1"]662663        with pytest.raises(ChunkParsingError, match="Failed to parse"):664            translator._parse_chunk_response(response, expected)665666    @patch("src.pipeline.translator.subprocess.run")667    def test_translate_chunk(668        self, mock_run: MagicMock, temp_data_dir: Path669    ) -> None:670        """Test translating a chunk."""671        # Mock Claude response672        mock_run.return_value = MagicMock(673            returncode=0,674            stderr="",675            stdout='{"1:1": "初めに言葉があった", "1:2": "この言葉は", "1:3": "すべてのものは", "1:4": "命があった", "1:5": "光は闇の中に"}',676        )677678        loader = DataLoader(temp_data_dir)679        glossary = GlossaryManager(loader.load_glossary())680        storage = Storage(temp_data_dir)681        pericope_manager = PericopeManager(682            temp_data_dir / "metadata" / "pericopes.json"683        )684        context_builder = ContextBuilder(685            loader, glossary, pericope_manager=pericope_manager686        )687        prompt_builder = PromptBuilder(glossary)688        translator = Translator(context_builder, prompt_builder, storage)689690        pericope = pericope_manager.get_pericope("john", 1, 1)691        assert pericope is not None692693        output = translator.translate_chunk("john", pericope, skip_existing=False)694695        assert output.verse_count == 5696        assert output.pericope_id == "john-1-prologue"697        assert "1:1" in output.translations698699    @patch("src.pipeline.translator.subprocess.run")700    def test_translate_chunk_by_id(701        self, mock_run: MagicMock, temp_data_dir: Path702    ) -> None:703        """Test translating chunk by ID."""704        mock_run.return_value = MagicMock(705            returncode=0,706            stderr="",707            stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}',708        )709710        loader = DataLoader(temp_data_dir)711        glossary = GlossaryManager(loader.load_glossary())712        storage = Storage(temp_data_dir)713        pericope_manager = PericopeManager(714            temp_data_dir / "metadata" / "pericopes.json"715        )716        context_builder = ContextBuilder(717            loader, glossary, pericope_manager=pericope_manager718        )719        prompt_builder = PromptBuilder(glossary)720        translator = Translator(context_builder, prompt_builder, storage)721722        output = translator.translate_chunk_by_id(723            "john-1-prologue", skip_existing=False724        )725726        assert output is not None727        assert output.pericope_id == "john-1-prologue"728729    def test_translate_chunk_by_id_not_found(self, temp_data_dir: Path) -> None:730        """Test translating non-existent pericope."""731        loader = DataLoader(temp_data_dir)732        glossary = GlossaryManager(loader.load_glossary())733        storage = Storage(temp_data_dir)734        pericope_manager = PericopeManager(735            temp_data_dir / "metadata" / "pericopes.json"736        )737        context_builder = ContextBuilder(738            loader, glossary, pericope_manager=pericope_manager739        )740        prompt_builder = PromptBuilder(glossary)741        translator = Translator(context_builder, prompt_builder, storage)742743        output = translator.translate_chunk_by_id("nonexistent")744745        assert output is None746747    def test_get_existing_chunk_translations(self, temp_data_dir: Path) -> None:748        """Test getting existing translations for a chunk."""749        loader = DataLoader(temp_data_dir)750        glossary = GlossaryManager(loader.load_glossary())751        storage = Storage(temp_data_dir)752        pericope_manager = PericopeManager(753            temp_data_dir / "metadata" / "pericopes.json"754        )755        context_builder = ContextBuilder(756            loader, glossary, pericope_manager=pericope_manager757        )758        prompt_builder = PromptBuilder(glossary)759        translator = Translator(context_builder, prompt_builder, storage)760761        # Save a translation762        from src.models.verse import TranslationOutput763764        output = TranslationOutput(765            reference=VerseReference(book="john", chapter=1, verse=1),766            text="初めに言葉があった",767            model="test",768        )769        storage.save_translation(output)770771        pericope = pericope_manager.get_pericope("john", 1, 1)772        assert pericope is not None773774        existing = translator._get_existing_chunk_translations("john", pericope)775776        assert "1:1" in existing777        assert existing["1:1"] == "初めに言葉があった"778779    def test_translate_chunk_skip_existing(self, temp_data_dir: Path) -> None:780        """Test translate_chunk returns existing translations when all exist."""781        loader = DataLoader(temp_data_dir)782        glossary = GlossaryManager(loader.load_glossary())783        storage = Storage(temp_data_dir)784        pericope_manager = PericopeManager(785            temp_data_dir / "metadata" / "pericopes.json"786        )787        context_builder = ContextBuilder(788            loader, glossary, pericope_manager=pericope_manager789        )790        prompt_builder = PromptBuilder(glossary)791        translator = Translator(context_builder, prompt_builder, storage)792793        # Save all translations for the pericope794        from src.models.verse import TranslationOutput795796        for i in range(1, 6):  # verses 1-5797            output = TranslationOutput(798                reference=VerseReference(book="john", chapter=1, verse=i),799                text=f"既存の翻訳 {i}",800                model="test",801            )802            storage.save_translation(output)803804        pericope = pericope_manager.get_pericope("john", 1, 1)805        assert pericope is not None806807        # Should return existing without calling Claude808        output = translator.translate_chunk("john", pericope, skip_existing=True)809810        assert output.coherence_note == "Loaded from existing translations"811        assert output.verse_count == 5812813    @patch("src.pipeline.translator.subprocess.run")814    @patch("src.pipeline.translator.time.sleep")815    def test_translate_chapter_by_pericopes(816        self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path817    ) -> None:818        """Test translating a chapter by pericopes."""819        # Mock Claude responses for both pericopes820        mock_run.side_effect = [821            MagicMock(822                returncode=0,823                stderr="",824                stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}',825            ),826            MagicMock(827                returncode=0,828                stderr="",829                stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}',830            ),831        ]832833        loader = DataLoader(temp_data_dir)834        glossary = GlossaryManager(loader.load_glossary())835        storage = Storage(temp_data_dir)836        pericope_manager = PericopeManager(837            temp_data_dir / "metadata" / "pericopes.json"838        )839        context_builder = ContextBuilder(840            loader, glossary, pericope_manager=pericope_manager841        )842        prompt_builder = PromptBuilder(glossary)843        translator = Translator(context_builder, prompt_builder, storage)844845        progress_calls = []846847        def progress_callback(current, total, pericope):848            progress_calls.append((current, total, pericope.id))849850        outputs = translator.translate_chapter_by_pericopes(851            "john", 1, skip_existing=False, progress_callback=progress_callback852        )853854        assert len(outputs) == 2855        assert len(progress_calls) == 2856        # Verify sleep was called between pericopes857        mock_sleep.assert_called()858859    @patch("src.pipeline.translator.subprocess.run")860    def test_translate_chapter_by_pericopes_no_pericope_data(861        self, mock_run: MagicMock, temp_data_dir: Path862    ) -> None:863        """Test fallback to verse-by-verse when no pericope data."""864        # Mock responses for verse-by-verse translation865        mock_run.side_effect = [866            MagicMock(returncode=0, stderr="", stdout="翻訳1"),867            MagicMock(returncode=0, stderr="", stdout="翻訳2"),868            MagicMock(returncode=0, stderr="", stdout="翻訳3"),869        ]870871        loader = DataLoader(temp_data_dir)872        glossary = GlossaryManager(loader.load_glossary())873        storage = Storage(temp_data_dir)874        # Use empty pericope manager (no data for chapter 5)875        pericope_manager = PericopeManager(876            temp_data_dir / "metadata" / "pericopes.json"877        )878        context_builder = ContextBuilder(879            loader, glossary, pericope_manager=pericope_manager880        )881        prompt_builder = PromptBuilder(glossary)882        translator = Translator(context_builder, prompt_builder, storage)883884        # Chapter 5 has no pericope data885        outputs = translator.translate_chapter_by_pericopes(886            "john", 5, skip_existing=False887        )888889        # Should fallback to verse-by-verse890        assert len(outputs) == 1891        assert "fallback" in outputs[0].pericope_id892        assert "Verse-by-verse fallback" in (outputs[0].coherence_note or "")893894    @patch("src.pipeline.translator.subprocess.run")895    def test_translate_chunk_with_tm(896        self, mock_run: MagicMock, temp_data_dir: Path897    ) -> None:898        """Test chunk translation with translation memory."""899        mock_run.return_value = MagicMock(900            returncode=0,901            stderr="",902            stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}',903        )904905        from src.context.translation_memory import TranslationMemoryManager906907        loader = DataLoader(temp_data_dir)908        glossary = GlossaryManager(loader.load_glossary())909        storage = Storage(temp_data_dir)910        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")911        pericope_manager = PericopeManager(912            temp_data_dir / "metadata" / "pericopes.json"913        )914        context_builder = ContextBuilder(915            loader, glossary, pericope_manager=pericope_manager916        )917        prompt_builder = PromptBuilder(glossary, translation_memory=tm)918        translator = Translator(919            context_builder, prompt_builder, storage, translation_memory=tm920        )921922        pericope = pericope_manager.get_pericope("john", 1, 1)923        assert pericope is not None924925        output = translator.translate_chunk("john", pericope, skip_existing=False)926927        assert output.verse_count == 5928        # Verify segments were added to TM929        assert len(tm) > 0930931    def test_chunk_tm_matches_no_source(self, temp_data_dir: Path) -> None:932        """Test _get_chunk_tm_matches returns None when no source text."""933        loader = DataLoader(temp_data_dir)934        glossary = GlossaryManager(loader.load_glossary())935        storage = Storage(temp_data_dir)936        pericope_manager = PericopeManager(937            temp_data_dir / "metadata" / "pericopes.json"938        )939        context_builder = ContextBuilder(940            loader, glossary, pericope_manager=pericope_manager941        )942        prompt_builder = PromptBuilder(glossary)943944        from src.context.translation_memory import TranslationMemoryManager945946        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")947        translator = Translator(948            context_builder, prompt_builder, storage, translation_memory=tm949        )950951        # Create context with empty verses952        context = ChunkTranslationContext(953            pericope=Pericope(954                id="test",955                start_chapter=1,956                start_verse=1,957                end_chapter=1,958                end_verse=1,959                verses=["1:1"],960            ),961            book="test",962            verses=[963                Verse(964                    reference=VerseReference(book="test", chapter=1, verse=1),965                    # No greek or hebrew text966                )967            ],968        )969970        matches = translator._get_chunk_tm_matches(context)971        assert matches is None972973974class TestPromptBuilderChunkEdgeCases:975    """Edge case tests for PromptBuilder chunk methods."""976977    def test_build_chunk_prompt_no_theme(self) -> None:978        """Test chunk prompt without theme."""979        glossary = GlossaryManager()980        prompt_builder = PromptBuilder(glossary)981982        pericope = Pericope(983            id="test",984            start_chapter=1,985            start_verse=1,986            end_chapter=1,987            end_verse=2,988            verses=["1:1", "1:2"],989            theme=None,  # No theme990        )991992        context = ChunkTranslationContext(993            pericope=pericope,994            book="test",995            verses=[996                Verse(997                    reference=VerseReference(book="test", chapter=1, verse=1),998                    greek_text="test text",999                ),1000                Verse(1001                    reference=VerseReference(book="test", chapter=1, verse=2),1002                    greek_text="more text",1003                ),1004            ],1005        )10061007        prompt = prompt_builder.build_chunk_prompt(context)1008        assert "PERICOPE" in prompt1009        assert "Theme:" not in prompt  # No theme line10101011    def test_build_chunk_prompt_with_context(self, temp_data_dir: Path) -> None:1012        """Test chunk prompt with preceding/following context."""1013        loader = DataLoader(temp_data_dir)1014        glossary = GlossaryManager(loader.load_glossary())1015        pericope_manager = PericopeManager(1016            temp_data_dir / "metadata" / "pericopes.json"1017        )1018        context_builder = ContextBuilder(1019            loader, glossary, pericope_manager=pericope_manager1020        )1021        prompt_builder = PromptBuilder(glossary)10221023        # Get second pericope which should have preceding context1024        pericope = pericope_manager.get_pericope("john", 1, 6)1025        assert pericope is not None10261027        context = context_builder.build_chunk_context("john", pericope)1028        prompt = prompt_builder.build_chunk_prompt(context)10291030        # Should have preceding context from first pericope1031        assert "Preceding context" in prompt10321033    def test_chunk_context_no_preceding_for_first_verse(1034        self, temp_data_dir: Path1035    ) -> None:1036        """Test no preceding context for first verse."""1037        loader = DataLoader(temp_data_dir)1038        glossary = GlossaryManager(loader.load_glossary())1039        pericope_manager = PericopeManager(1040            temp_data_dir / "metadata" / "pericopes.json"1041        )1042        context_builder = ContextBuilder(1043            loader, glossary, pericope_manager=pericope_manager1044        )10451046        pericope = pericope_manager.get_pericope("john", 1, 1)1047        assert pericope is not None10481049        context = context_builder.build_chunk_context("john", pericope)10501051        # First verse should have no preceding context1052        assert len(context.preceding_verses) == 0105310541055class TestPericopeManagerEdgeCases:1056    """Edge case tests for PericopeManager."""10571058    def test_get_book_index(self, temp_pericope_data: Path) -> None:1059        """Test getting book index."""1060        manager = PericopeManager(temp_pericope_data)10611062        index = manager.get_book_index("john")1063        assert index is not None1064        assert index.book == "john"10651066    def test_get_book_index_not_found(self, temp_pericope_data: Path) -> None:1067        """Test getting book index for non-existent book."""1068        manager = PericopeManager(temp_pericope_data)10691070        index = manager.get_book_index("nonexistent")1071        assert index is None10721073    def test_data_path_property(self, temp_pericope_data: Path) -> None:1074        """Test data_path property."""1075        manager = PericopeManager(temp_pericope_data)1076        assert manager.data_path == temp_pericope_data107710781079class TestTranslatorChunkEdgeCases:1080    """Edge case tests for Translator chunk methods."""10811082    def test_parse_chunk_response_not_dict(self, temp_data_dir: Path) -> None:1083        """Test parsing response that is not a dict."""1084        loader = DataLoader(temp_data_dir)1085        glossary = GlossaryManager(loader.load_glossary())1086        storage = Storage(temp_data_dir)1087        context_builder = ContextBuilder(loader, glossary)1088        prompt_builder = PromptBuilder(glossary)1089        translator = Translator(context_builder, prompt_builder, storage)10901091        response = '["1:1", "1:2"]'  # Valid JSON but not a dict1092        expected = ["1:1"]10931094        # Arrays are not dicts, so the robust parser fails to extract a valid dict1095        with pytest.raises(ChunkParsingError, match="Failed to parse"):1096            translator._parse_chunk_response(response, expected)10971098    def test_chunk_tm_matches_no_verses(self, temp_data_dir: Path) -> None:1099        """Test _get_chunk_tm_matches with empty verses list."""1100        loader = DataLoader(temp_data_dir)1101        glossary = GlossaryManager(loader.load_glossary())1102        storage = Storage(temp_data_dir)1103        context_builder = ContextBuilder(loader, glossary)1104        prompt_builder = PromptBuilder(glossary)11051106        from src.context.translation_memory import TranslationMemoryManager11071108        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1109        translator = Translator(1110            context_builder, prompt_builder, storage, translation_memory=tm1111        )11121113        # Context with no verses1114        context = ChunkTranslationContext(1115            pericope=Pericope(1116                id="test",1117                start_chapter=1,1118                start_verse=1,1119                end_chapter=1,1120                end_verse=1,1121                verses=["1:1"],1122            ),1123            book="test",1124            verses=[],  # Empty verses list1125        )11261127        matches = translator._get_chunk_tm_matches(context)1128        assert matches is None11291130    def test_chunk_tm_matches_no_combined_text(self, temp_data_dir: Path) -> None:1131        """Test _get_chunk_tm_matches when combined text is empty."""1132        loader = DataLoader(temp_data_dir)1133        glossary = GlossaryManager(loader.load_glossary())1134        storage = Storage(temp_data_dir)1135        context_builder = ContextBuilder(loader, glossary)1136        prompt_builder = PromptBuilder(glossary)11371138        from src.context.translation_memory import TranslationMemoryManager11391140        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1141        translator = Translator(1142            context_builder, prompt_builder, storage, translation_memory=tm1143        )11441145        # Context with verse but no source text1146        context = ChunkTranslationContext(1147            pericope=Pericope(1148                id="test",1149                start_chapter=1,1150                start_verse=1,1151                end_chapter=1,1152                end_verse=1,1153                verses=["1:1"],1154            ),1155            book="test",1156            verses=[1157                Verse(1158                    reference=VerseReference(book="test", chapter=1, verse=1),1159                    english_kjv="Test verse",  # Only English, no Greek/Hebrew1160                )1161            ],1162        )11631164        matches = translator._get_chunk_tm_matches(context)1165        assert matches is None11661167    @patch("src.pipeline.translator.subprocess.run")1168    @patch("src.pipeline.translator.time.sleep")1169    def test_translate_chapter_by_pericopes_with_tm_save(1170        self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path1171    ) -> None:1172        """Test TM save after translating chapter by pericopes."""1173        mock_run.side_effect = [1174            MagicMock(1175                returncode=0,1176                stderr="",1177                stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}',1178            ),1179            MagicMock(1180                returncode=0,1181                stderr="",1182                stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}',1183            ),1184        ]11851186        from src.context.translation_memory import TranslationMemoryManager11871188        loader = DataLoader(temp_data_dir)1189        glossary = GlossaryManager(loader.load_glossary())1190        storage = Storage(temp_data_dir)1191        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1192        pericope_manager = PericopeManager(1193            temp_data_dir / "metadata" / "pericopes.json"1194        )1195        context_builder = ContextBuilder(1196            loader, glossary, pericope_manager=pericope_manager1197        )1198        prompt_builder = PromptBuilder(glossary, translation_memory=tm)1199        translator = Translator(1200            context_builder, prompt_builder, storage, translation_memory=tm1201        )12021203        outputs = translator.translate_chapter_by_pericopes(1204            "john", 1, skip_existing=False1205        )12061207        assert len(outputs) == 21208        # Verify TM was saved (segments should have been added)1209        assert len(tm) > 012101211    def test_add_chunk_to_tm_no_target(self, temp_data_dir: Path) -> None:1212        """Test _add_chunk_to_tm when target translation is missing."""1213        loader = DataLoader(temp_data_dir)1214        glossary = GlossaryManager(loader.load_glossary())1215        storage = Storage(temp_data_dir)1216        pericope_manager = PericopeManager(1217            temp_data_dir / "metadata" / "pericopes.json"1218        )1219        context_builder = ContextBuilder(1220            loader, glossary, pericope_manager=pericope_manager1221        )1222        prompt_builder = PromptBuilder(glossary)12231224        from src.context.translation_memory import TranslationMemoryManager12251226        tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1227        translator = Translator(1228            context_builder, prompt_builder, storage, translation_memory=tm1229        )12301231        # Create context1232        context = ChunkTranslationContext(1233            pericope=Pericope(1234                id="test",1235                start_chapter=1,1236                start_verse=1,1237                end_chapter=1,1238                end_verse=2,1239                verses=["1:1", "1:2"],1240            ),1241            book="test",1242            verses=[1243                Verse(1244                    reference=VerseReference(book="test", chapter=1, verse=1),1245                    greek_text="Test text",1246                ),1247                Verse(1248                    reference=VerseReference(book="test", chapter=1, verse=2),1249                    greek_text="More text",1250                ),1251            ],1252        )12531254        # Translations missing 1:21255        translations = {"1:1": "翻訳1"}12561257        # Should not error, just skip missing1258        translator._add_chunk_to_tm(context, translations)1259        assert len(tm) == 1  # Only 1:1 added126012611262class TestBuilderChunkEdgeCases:1263    """Edge case tests for ContextBuilder chunk methods."""12641265    def test_build_chunk_context_no_verse_returns(1266        self, temp_data_dir: Path1267    ) -> None:1268        """Test chunk context when pericope not found for verse."""1269        loader = DataLoader(temp_data_dir)1270        glossary = GlossaryManager(loader.load_glossary())1271        pericope_manager = PericopeManager(1272            temp_data_dir / "metadata" / "pericopes.json"1273        )1274        context_builder = ContextBuilder(1275            loader, glossary, pericope_manager=pericope_manager1276        )12771278        # Chapter 10 has no pericope data1279        ref = VerseReference(book="john", chapter=10, verse=1)1280        context = context_builder.build_chunk_context_for_verse(ref)12811282        assert context is None128312841285class TestPromptBuilderChunkFormatting:1286    """Tests for prompt formatting with various data."""12871288    def test_format_tm_matches_empty(self) -> None:1289        """Test formatting empty TM matches."""1290        glossary = GlossaryManager()1291        prompt_builder = PromptBuilder(glossary)12921293        result = prompt_builder._format_tm_matches([])1294        assert result == ""12951296    def test_build_chunk_prompt_with_tm_matches(self) -> None:1297        """Test chunk prompt includes TM matches when provided."""1298        from src.models.translation_memory import TMMatch, TMSegment12991300        glossary = GlossaryManager()1301        prompt_builder = PromptBuilder(glossary)13021303        pericope = Pericope(1304            id="test",1305            start_chapter=1,1306            start_verse=1,1307            end_chapter=1,1308            end_verse=2,1309            verses=["1:1", "1:2"],1310        )13111312        context = ChunkTranslationContext(1313            pericope=pericope,1314            book="test",1315            verses=[1316                Verse(1317                    reference=VerseReference(book="test", chapter=1, verse=1),1318                    greek_text="test text",1319                ),1320                Verse(1321                    reference=VerseReference(book="test", chapter=1, verse=2),1322                    greek_text="more text",1323                ),1324            ],1325        )13261327        tm_matches = [1328            TMMatch(1329                segment=TMSegment(1330                    id="seg1",1331                    source_text="test source",1332                    source_normalized="test source",1333                    target_text="テスト翻訳",1334                    reference=VerseReference(book="test", chapter=1, verse=1),1335                ),1336                similarity=0.85,1337                match_type="fuzzy",1338            )1339        ]13401341        prompt = prompt_builder.build_chunk_prompt(context, tm_matches=tm_matches)1342        assert "TRANSLATION MEMORY" in prompt1343        assert "85%" in prompt13441345    def test_build_chunk_prompt_with_hebrew(self) -> None:1346        """Test chunk prompt with Hebrew source text."""1347        glossary = GlossaryManager()1348        prompt_builder = PromptBuilder(glossary)13491350        pericope = Pericope(1351            id="test",1352            start_chapter=1,1353            start_verse=1,1354            end_chapter=1,1355            end_verse=1,1356            verses=["1:1"],1357        )13581359        context = ChunkTranslationContext(1360            pericope=pericope,1361            book="genesis",1362            verses=[1363                Verse(1364                    reference=VerseReference(book="genesis", chapter=1, verse=1),1365                    hebrew_text="בְּרֵאשִׁית בָּרָא אֱלֹהִים",  # Hebrew text1366                ),1367            ],1368        )13691370        prompt = prompt_builder.build_chunk_prompt(context)1371        assert "בְּרֵאשִׁית" in prompt13721373    def test_build_chunk_prompt_with_japanese_refs(self, temp_data_dir: Path) -> None:1374        """Test chunk prompt with Japanese reference translations."""1375        glossary = GlossaryManager()1376        prompt_builder = PromptBuilder(glossary)13771378        pericope = Pericope(1379            id="test",1380            start_chapter=1,1381            start_verse=1,1382            end_chapter=1,1383            end_verse=1,1384            verses=["1:1"],1385        )13861387        context = ChunkTranslationContext(1388            pericope=pericope,1389            book="test",1390            verses=[1391                Verse(1392                    reference=VerseReference(book="test", chapter=1, verse=1),1393                    greek_text="test",1394                    japanese_meiji="明治訳",1395                    japanese_taisho="大正訳",1396                ),1397            ],1398        )13991400        prompt = prompt_builder.build_chunk_prompt(context)1401        assert "明治元訳" in prompt1402        assert "大正改訳" in prompt140314041405class TestPericopeManagerMissingBook:1406    """Tests for PericopeManager with missing book data."""14071408    def test_get_pericope_missing_book(self, temp_pericope_data: Path) -> None:1409        """Test get_pericope returns None for missing book."""1410        manager = PericopeManager(temp_pericope_data)14111412        pericope = manager.get_pericope("nonexistent", 1, 1)1413        assert pericope is None14141415    def test_get_chapter_pericopes_missing_book(1416        self, temp_pericope_data: Path1417    ) -> None:1418        """Test get_chapter_pericopes returns [] for missing book."""1419        manager = PericopeManager(temp_pericope_data)14201421        pericopes = manager.get_chapter_pericopes("nonexistent", 1)1422        assert pericopes == []1423