OPEN SOURCE
software/tests/test_pericope.py
Read on this site and download the files you need.
Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.
← Back to the open-source guide
1"""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 (16ChunkTranslationContext,17ChunkTranslationOutput,18Pericope,19PericopeIndex,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."""29return Pericope(30id="john-1-prologue",31title="The Prologue: The Word Became Flesh",32title_ja="序文:言葉は肉となった",33start_chapter=1,34start_verse=1,35end_chapter=1,36end_verse=5,37verses=["1:1", "1:2", "1:3", "1:4", "1:5"],38theme="The divine Logos, pre-existence",39auto_generated=False,40)414243@pytest.fixture44def sample_pericope_index(sample_pericope: Pericope) -> PericopeIndex:45"""Create a sample pericope index."""46return PericopeIndex(47book="john",48name="Gospel of John",49pericopes=[50sample_pericope,51Pericope(52id="john-1-baptist",53title="John the Baptist",54start_chapter=1,55start_verse=6,56end_chapter=1,57end_verse=8,58verses=["1:6", "1:7", "1:8"],59auto_generated=False,60),61],62)636465@pytest.fixture66def temp_pericope_data(tmp_path: Path) -> Path:67"""Create temporary pericope data file."""68pericopes_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}113pericope_path = tmp_path / "pericopes.json"114with pericope_path.open("w", encoding="utf-8") as f:115json.dump(pericopes_data, f)116return pericope_path117118119@pytest.fixture120def temp_data_dir(tmp_path: Path, temp_pericope_data: Path) -> Path:121"""Create temporary data directory with test data."""122sources_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 verses132greek_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}142with (sources_dir / "greek" / "john.json").open("w") as f:143json.dump(greek_data, f)144145# KJV data146kjv_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}156with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:157json.dump(kjv_data, f)158159# Book metadata160book_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}171with (tmp_path / "metadata" / "john" / "book_context.json").open("w") as f:172json.dump(book_metadata, f)173174# Chapter summaries175chapter_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}183with (tmp_path / "metadata" / "john" / "chapter_summaries.json").open("w") as f:184json.dump(chapter_summaries, f)185186# Glossary187glossary = {188"terms": [189{190"original": "λόγος",191"transliteration": "logos",192"japanese": "言葉",193"english": "word",194"notes": "The Word",195}196]197}198with (tmp_path / "metadata" / "glossary.json").open("w") as f:199json.dump(glossary, f)200201# Copy pericope data to metadata folder202import shutil203204shutil.copy(temp_pericope_data, tmp_path / "metadata" / "pericopes.json")205206return tmp_path207208209class TestPericopeModel:210"""Tests for Pericope model."""211212def test_pericope_creation(self, sample_pericope: Pericope) -> None:213"""Test creating a pericope."""214assert sample_pericope.id == "john-1-prologue"215assert sample_pericope.verse_count == 5216assert sample_pericope.is_single_chapter is True217218def test_pericope_contains(self, sample_pericope: Pericope) -> None:219"""Test checking if verse is in pericope."""220assert sample_pericope.contains(1, 1) is True221assert sample_pericope.contains(1, 5) is True222assert sample_pericope.contains(1, 6) is False223assert sample_pericope.contains(2, 1) is False224225def test_pericope_display_range_single_chapter(226self, sample_pericope: Pericope227) -> None:228"""Test display range for single chapter."""229assert sample_pericope.get_display_range() == "1:1-5"230231def test_pericope_display_range_multi_chapter(self) -> None:232"""Test display range spanning chapters."""233pericope = Pericope(234id="test",235start_chapter=1,236start_verse=10,237end_chapter=2,238end_verse=5,239verses=["1:10", "1:11", "2:1", "2:2", "2:3", "2:4", "2:5"],240)241assert pericope.get_display_range() == "1:10-2:5"242243def test_pericope_get_verse_refs(self, sample_pericope: Pericope) -> None:244"""Test getting verse references."""245refs = sample_pericope.get_verse_refs("john")246assert len(refs) == 5247assert refs[0].book == "john"248assert refs[0].chapter == 1249assert refs[0].verse == 1250251252class TestPericopeIndex:253"""Tests for PericopeIndex model."""254255def test_find_pericope(self, sample_pericope_index: PericopeIndex) -> None:256"""Test finding pericope by verse."""257pericope = sample_pericope_index.find_pericope(1, 1)258assert pericope is not None259assert pericope.id == "john-1-prologue"260261def test_find_pericope_not_found(262self, sample_pericope_index: PericopeIndex263) -> None:264"""Test finding non-existent verse."""265pericope = sample_pericope_index.find_pericope(2, 1)266assert pericope is None267268def test_get_pericope_by_id(self, sample_pericope_index: PericopeIndex) -> None:269"""Test getting pericope by ID."""270pericope = sample_pericope_index.get_pericope_by_id("john-1-prologue")271assert pericope is not None272assert pericope.title == "The Prologue: The Word Became Flesh"273274def test_get_pericope_by_id_not_found(275self, sample_pericope_index: PericopeIndex276) -> None:277"""Test getting non-existent pericope."""278pericope = sample_pericope_index.get_pericope_by_id("nonexistent")279assert pericope is None280281def test_get_chapter_pericopes(282self, sample_pericope_index: PericopeIndex283) -> None:284"""Test getting pericopes for a chapter."""285pericopes = sample_pericope_index.get_chapter_pericopes(1)286assert len(pericopes) == 2287288289class TestPericopeManager:290"""Tests for PericopeManager."""291292def test_load_pericopes(self, temp_pericope_data: Path) -> None:293"""Test loading pericope data."""294manager = PericopeManager(temp_pericope_data)295manager.load()296297assert manager.has_pericope_data("john")298assert manager.has_pericope_data("titus")299assert not manager.has_pericope_data("romans")300301def test_get_pericope(self, temp_pericope_data: Path) -> None:302"""Test getting pericope for verse."""303manager = PericopeManager(temp_pericope_data)304305pericope = manager.get_pericope("john", 1, 1)306assert pericope is not None307assert pericope.id == "john-1-prologue"308309def test_get_pericope_not_found(self, temp_pericope_data: Path) -> None:310"""Test getting pericope for verse not in data."""311manager = PericopeManager(temp_pericope_data)312313pericope = manager.get_pericope("john", 5, 1)314assert pericope is None315316def test_get_pericope_by_id(self, temp_pericope_data: Path) -> None:317"""Test getting pericope by ID."""318manager = PericopeManager(temp_pericope_data)319320result = manager.get_pericope_by_id("john-1-prologue")321assert result is not None322book, pericope = result323assert book == "john"324assert pericope.title == "The Prologue"325326def test_get_book_pericopes(self, temp_pericope_data: Path) -> None:327"""Test getting all pericopes for a book."""328manager = PericopeManager(temp_pericope_data)329330pericopes = manager.get_book_pericopes("john")331assert len(pericopes) == 2332333def test_get_chapter_pericopes(self, temp_pericope_data: Path) -> None:334"""Test getting pericopes for a chapter."""335manager = PericopeManager(temp_pericope_data)336337pericopes = manager.get_chapter_pericopes("john", 1)338assert len(pericopes) == 2339340def test_get_available_books(self, temp_pericope_data: Path) -> None:341"""Test getting available books."""342manager = PericopeManager(temp_pericope_data)343344books = manager.get_available_books()345assert "john" in books346assert "titus" in books347348def test_get_statistics(self, temp_pericope_data: Path) -> None:349"""Test getting statistics."""350manager = PericopeManager(temp_pericope_data)351352stats = manager.get_statistics()353assert stats["books_with_data"] == 2354assert stats["total_pericopes"] == 3355assert stats["total_verses_covered"] == 12356357def test_len(self, temp_pericope_data: Path) -> None:358"""Test len() returns total pericopes."""359manager = PericopeManager(temp_pericope_data)360assert len(manager) == 3361362def test_reload(self, temp_pericope_data: Path) -> None:363"""Test reload from file."""364manager = PericopeManager(temp_pericope_data)365manager.load()366367# Modify file368with temp_pericope_data.open("r") as f:369data = json.load(f)370data["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})378with temp_pericope_data.open("w") as f:379json.dump(data, f)380381manager.reload()382pericopes = manager.get_book_pericopes("john")383assert len(pericopes) == 3384385def test_missing_file(self, tmp_path: Path) -> None:386"""Test handling missing pericope file."""387manager = PericopeManager(tmp_path / "nonexistent.json")388manager.load()389390assert not manager.has_pericope_data("john")391assert manager.get_book_pericopes("john") == []392393394class TestChunkTranslationOutput:395"""Tests for ChunkTranslationOutput model."""396397def test_creation(self) -> None:398"""Test creating chunk output."""399output = ChunkTranslationOutput(400pericope_id="john-1-prologue",401book="john",402translations={"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"},403model="claude-sonnet-4-20250514",404)405assert output.verse_count == 2406407def test_get_verse_translation(self) -> None:408"""Test getting verse translation."""409output = ChunkTranslationOutput(410pericope_id="john-1-prologue",411book="john",412translations={"1:1": "初めに言葉があった"},413model="claude-sonnet-4-20250514",414)415assert output.get_verse_translation(1, 1) == "初めに言葉があった"416assert output.get_verse_translation(1, 2) is None417418419class TestChunkTranslationContext:420"""Tests for ChunkTranslationContext model."""421422def test_creation(self, sample_pericope: Pericope) -> None:423"""Test creating chunk context."""424verses = [425Verse(426reference=VerseReference(book="john", chapter=1, verse=1),427greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος",428),429Verse(430reference=VerseReference(book="john", chapter=1, verse=2),431greek_text="οὗτος ἦν ἐν ἀρχῇ",432),433]434435context = ChunkTranslationContext(436pericope=sample_pericope,437book="john",438verses=verses,439)440441assert context.verse_refs == sample_pericope.verses442assert len(context.source_texts) == 2443444def test_get_combined_source_text(self, sample_pericope: Pericope) -> None:445"""Test getting combined source text."""446verses = [447Verse(448reference=VerseReference(book="john", chapter=1, verse=1),449greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος",450),451Verse(452reference=VerseReference(book="john", chapter=1, verse=2),453greek_text="οὗτος ἦν ἐν ἀρχῇ",454),455]456457context = ChunkTranslationContext(458pericope=sample_pericope,459book="john",460verses=verses,461)462463combined = context.get_combined_source_text()464assert "[1:1]" in combined465assert "[1:2]" in combined466assert "λόγος" in combined467468469class TestContextBuilderChunk:470"""Tests for ContextBuilder chunk methods."""471472def test_build_chunk_context(self, temp_data_dir: Path) -> None:473"""Test building chunk context."""474loader = DataLoader(temp_data_dir)475glossary = GlossaryManager(loader.load_glossary())476pericope_manager = PericopeManager(477temp_data_dir / "metadata" / "pericopes.json"478)479builder = ContextBuilder(480loader, glossary, pericope_manager=pericope_manager481)482483pericope = pericope_manager.get_pericope("john", 1, 1)484assert pericope is not None485486context = builder.build_chunk_context("john", pericope)487488assert len(context.verses) == 5489assert context.book == "john"490assert context.pericope.id == "john-1-prologue"491492def test_build_chunk_context_by_id(self, temp_data_dir: Path) -> None:493"""Test building chunk context by pericope ID."""494loader = DataLoader(temp_data_dir)495glossary = GlossaryManager(loader.load_glossary())496pericope_manager = PericopeManager(497temp_data_dir / "metadata" / "pericopes.json"498)499builder = ContextBuilder(500loader, glossary, pericope_manager=pericope_manager501)502503context = builder.build_chunk_context_by_id("john-1-prologue")504505assert context is not None506assert len(context.verses) == 5507508def test_build_chunk_context_by_id_not_found(self, temp_data_dir: Path) -> None:509"""Test building chunk context for non-existent ID."""510loader = DataLoader(temp_data_dir)511glossary = GlossaryManager(loader.load_glossary())512pericope_manager = PericopeManager(513temp_data_dir / "metadata" / "pericopes.json"514)515builder = ContextBuilder(516loader, glossary, pericope_manager=pericope_manager517)518519context = builder.build_chunk_context_by_id("nonexistent")520assert context is None521522def test_build_chunk_context_for_verse(self, temp_data_dir: Path) -> None:523"""Test building chunk context for a verse."""524loader = DataLoader(temp_data_dir)525glossary = GlossaryManager(loader.load_glossary())526pericope_manager = PericopeManager(527temp_data_dir / "metadata" / "pericopes.json"528)529builder = ContextBuilder(530loader, glossary, pericope_manager=pericope_manager531)532533ref = VerseReference(book="john", chapter=1, verse=3)534context = builder.build_chunk_context_for_verse(ref)535536assert context is not None537assert context.pericope.id == "john-1-prologue"538539def test_chunk_context_includes_glossary(self, temp_data_dir: Path) -> None:540"""Test chunk context includes glossary terms."""541loader = DataLoader(temp_data_dir)542glossary = GlossaryManager(loader.load_glossary())543pericope_manager = PericopeManager(544temp_data_dir / "metadata" / "pericopes.json"545)546builder = ContextBuilder(547loader, glossary, pericope_manager=pericope_manager548)549550context = builder.build_chunk_context_by_id("john-1-prologue")551552assert context is not None553# Should find λόγος in verse 1554assert len(context.glossary_terms) > 0555556557class TestPromptBuilderChunk:558"""Tests for PromptBuilder chunk methods."""559560def test_build_chunk_prompt(self, temp_data_dir: Path) -> None:561"""Test building chunk prompt."""562loader = DataLoader(temp_data_dir)563glossary = GlossaryManager(loader.load_glossary())564pericope_manager = PericopeManager(565temp_data_dir / "metadata" / "pericopes.json"566)567context_builder = ContextBuilder(568loader, glossary, pericope_manager=pericope_manager569)570prompt_builder = PromptBuilder(glossary)571572context = context_builder.build_chunk_context_by_id("john-1-prologue")573assert context is not None574575prompt = prompt_builder.build_chunk_prompt(context)576577assert "PERICOPE" in prompt578assert "John" in prompt579assert "SOURCE TEXTS" in prompt580assert "JSON" in prompt581582def test_get_chunk_system_prompt(self) -> None:583"""Test getting chunk system prompt."""584glossary = GlossaryManager()585prompt_builder = PromptBuilder(glossary)586587system_prompt = prompt_builder.get_chunk_system_prompt()588589assert "CHUNK TRANSLATION MODE" in system_prompt590assert "PERICOPE" in system_prompt591592593class TestTranslatorChunk:594"""Tests for Translator chunk methods."""595596def test_parse_chunk_response_valid(self, temp_data_dir: Path) -> None:597"""Test parsing valid JSON response."""598loader = DataLoader(temp_data_dir)599glossary = GlossaryManager(loader.load_glossary())600storage = Storage(temp_data_dir)601pericope_manager = PericopeManager(602temp_data_dir / "metadata" / "pericopes.json"603)604context_builder = ContextBuilder(605loader, glossary, pericope_manager=pericope_manager606)607prompt_builder = PromptBuilder(glossary)608translator = Translator(context_builder, prompt_builder, storage)609610response = '{"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}'611expected = ["1:1", "1:2"]612613translations = translator._parse_chunk_response(response, expected)614615assert translations["1:1"] == "初めに言葉があった"616assert translations["1:2"] == "この言葉は神と共にあった"617618def test_parse_chunk_response_with_markdown(self, temp_data_dir: Path) -> None:619"""Test parsing response with markdown code blocks."""620loader = DataLoader(temp_data_dir)621glossary = GlossaryManager(loader.load_glossary())622storage = Storage(temp_data_dir)623context_builder = ContextBuilder(loader, glossary)624prompt_builder = PromptBuilder(glossary)625translator = Translator(context_builder, prompt_builder, storage)626627response = '''```json628{"1:1": "初めに言葉があった", "1:2": "この言葉は神と共にあった"}629```'''630expected = ["1:1", "1:2"]631632translations = translator._parse_chunk_response(response, expected)633634assert translations["1:1"] == "初めに言葉があった"635636def test_parse_chunk_response_missing_verse(self, temp_data_dir: Path) -> None:637"""Test parsing response with missing verse."""638loader = DataLoader(temp_data_dir)639glossary = GlossaryManager(loader.load_glossary())640storage = Storage(temp_data_dir)641context_builder = ContextBuilder(loader, glossary)642prompt_builder = PromptBuilder(glossary)643translator = Translator(context_builder, prompt_builder, storage)644645response = '{"1:1": "初めに言葉があった"}'646expected = ["1:1", "1:2"]647648with pytest.raises(ChunkParsingError, match="Missing translations"):649translator._parse_chunk_response(response, expected)650651def test_parse_chunk_response_invalid_json(self, temp_data_dir: Path) -> None:652"""Test parsing invalid JSON response."""653loader = DataLoader(temp_data_dir)654glossary = GlossaryManager(loader.load_glossary())655storage = Storage(temp_data_dir)656context_builder = ContextBuilder(loader, glossary)657prompt_builder = PromptBuilder(glossary)658translator = Translator(context_builder, prompt_builder, storage)659660response = "This is not JSON"661expected = ["1:1"]662663with pytest.raises(ChunkParsingError, match="Failed to parse"):664translator._parse_chunk_response(response, expected)665666@patch("src.pipeline.translator.subprocess.run")667def test_translate_chunk(668self, mock_run: MagicMock, temp_data_dir: Path669) -> None:670"""Test translating a chunk."""671# Mock Claude response672mock_run.return_value = MagicMock(673returncode=0,674stderr="",675stdout='{"1:1": "初めに言葉があった", "1:2": "この言葉は", "1:3": "すべてのものは", "1:4": "命があった", "1:5": "光は闇の中に"}',676)677678loader = DataLoader(temp_data_dir)679glossary = GlossaryManager(loader.load_glossary())680storage = Storage(temp_data_dir)681pericope_manager = PericopeManager(682temp_data_dir / "metadata" / "pericopes.json"683)684context_builder = ContextBuilder(685loader, glossary, pericope_manager=pericope_manager686)687prompt_builder = PromptBuilder(glossary)688translator = Translator(context_builder, prompt_builder, storage)689690pericope = pericope_manager.get_pericope("john", 1, 1)691assert pericope is not None692693output = translator.translate_chunk("john", pericope, skip_existing=False)694695assert output.verse_count == 5696assert output.pericope_id == "john-1-prologue"697assert "1:1" in output.translations698699@patch("src.pipeline.translator.subprocess.run")700def test_translate_chunk_by_id(701self, mock_run: MagicMock, temp_data_dir: Path702) -> None:703"""Test translating chunk by ID."""704mock_run.return_value = MagicMock(705returncode=0,706stderr="",707stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}',708)709710loader = DataLoader(temp_data_dir)711glossary = GlossaryManager(loader.load_glossary())712storage = Storage(temp_data_dir)713pericope_manager = PericopeManager(714temp_data_dir / "metadata" / "pericopes.json"715)716context_builder = ContextBuilder(717loader, glossary, pericope_manager=pericope_manager718)719prompt_builder = PromptBuilder(glossary)720translator = Translator(context_builder, prompt_builder, storage)721722output = translator.translate_chunk_by_id(723"john-1-prologue", skip_existing=False724)725726assert output is not None727assert output.pericope_id == "john-1-prologue"728729def test_translate_chunk_by_id_not_found(self, temp_data_dir: Path) -> None:730"""Test translating non-existent pericope."""731loader = DataLoader(temp_data_dir)732glossary = GlossaryManager(loader.load_glossary())733storage = Storage(temp_data_dir)734pericope_manager = PericopeManager(735temp_data_dir / "metadata" / "pericopes.json"736)737context_builder = ContextBuilder(738loader, glossary, pericope_manager=pericope_manager739)740prompt_builder = PromptBuilder(glossary)741translator = Translator(context_builder, prompt_builder, storage)742743output = translator.translate_chunk_by_id("nonexistent")744745assert output is None746747def test_get_existing_chunk_translations(self, temp_data_dir: Path) -> None:748"""Test getting existing translations for a chunk."""749loader = DataLoader(temp_data_dir)750glossary = GlossaryManager(loader.load_glossary())751storage = Storage(temp_data_dir)752pericope_manager = PericopeManager(753temp_data_dir / "metadata" / "pericopes.json"754)755context_builder = ContextBuilder(756loader, glossary, pericope_manager=pericope_manager757)758prompt_builder = PromptBuilder(glossary)759translator = Translator(context_builder, prompt_builder, storage)760761# Save a translation762from src.models.verse import TranslationOutput763764output = TranslationOutput(765reference=VerseReference(book="john", chapter=1, verse=1),766text="初めに言葉があった",767model="test",768)769storage.save_translation(output)770771pericope = pericope_manager.get_pericope("john", 1, 1)772assert pericope is not None773774existing = translator._get_existing_chunk_translations("john", pericope)775776assert "1:1" in existing777assert existing["1:1"] == "初めに言葉があった"778779def test_translate_chunk_skip_existing(self, temp_data_dir: Path) -> None:780"""Test translate_chunk returns existing translations when all exist."""781loader = DataLoader(temp_data_dir)782glossary = GlossaryManager(loader.load_glossary())783storage = Storage(temp_data_dir)784pericope_manager = PericopeManager(785temp_data_dir / "metadata" / "pericopes.json"786)787context_builder = ContextBuilder(788loader, glossary, pericope_manager=pericope_manager789)790prompt_builder = PromptBuilder(glossary)791translator = Translator(context_builder, prompt_builder, storage)792793# Save all translations for the pericope794from src.models.verse import TranslationOutput795796for i in range(1, 6): # verses 1-5797output = TranslationOutput(798reference=VerseReference(book="john", chapter=1, verse=i),799text=f"既存の翻訳 {i}",800model="test",801)802storage.save_translation(output)803804pericope = pericope_manager.get_pericope("john", 1, 1)805assert pericope is not None806807# Should return existing without calling Claude808output = translator.translate_chunk("john", pericope, skip_existing=True)809810assert output.coherence_note == "Loaded from existing translations"811assert output.verse_count == 5812813@patch("src.pipeline.translator.subprocess.run")814@patch("src.pipeline.translator.time.sleep")815def test_translate_chapter_by_pericopes(816self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path817) -> None:818"""Test translating a chapter by pericopes."""819# Mock Claude responses for both pericopes820mock_run.side_effect = [821MagicMock(822returncode=0,823stderr="",824stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}',825),826MagicMock(827returncode=0,828stderr="",829stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}',830),831]832833loader = DataLoader(temp_data_dir)834glossary = GlossaryManager(loader.load_glossary())835storage = Storage(temp_data_dir)836pericope_manager = PericopeManager(837temp_data_dir / "metadata" / "pericopes.json"838)839context_builder = ContextBuilder(840loader, glossary, pericope_manager=pericope_manager841)842prompt_builder = PromptBuilder(glossary)843translator = Translator(context_builder, prompt_builder, storage)844845progress_calls = []846847def progress_callback(current, total, pericope):848progress_calls.append((current, total, pericope.id))849850outputs = translator.translate_chapter_by_pericopes(851"john", 1, skip_existing=False, progress_callback=progress_callback852)853854assert len(outputs) == 2855assert len(progress_calls) == 2856# Verify sleep was called between pericopes857mock_sleep.assert_called()858859@patch("src.pipeline.translator.subprocess.run")860def test_translate_chapter_by_pericopes_no_pericope_data(861self, 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 translation865mock_run.side_effect = [866MagicMock(returncode=0, stderr="", stdout="翻訳1"),867MagicMock(returncode=0, stderr="", stdout="翻訳2"),868MagicMock(returncode=0, stderr="", stdout="翻訳3"),869]870871loader = DataLoader(temp_data_dir)872glossary = GlossaryManager(loader.load_glossary())873storage = Storage(temp_data_dir)874# Use empty pericope manager (no data for chapter 5)875pericope_manager = PericopeManager(876temp_data_dir / "metadata" / "pericopes.json"877)878context_builder = ContextBuilder(879loader, glossary, pericope_manager=pericope_manager880)881prompt_builder = PromptBuilder(glossary)882translator = Translator(context_builder, prompt_builder, storage)883884# Chapter 5 has no pericope data885outputs = translator.translate_chapter_by_pericopes(886"john", 5, skip_existing=False887)888889# Should fallback to verse-by-verse890assert len(outputs) == 1891assert "fallback" in outputs[0].pericope_id892assert "Verse-by-verse fallback" in (outputs[0].coherence_note or "")893894@patch("src.pipeline.translator.subprocess.run")895def test_translate_chunk_with_tm(896self, mock_run: MagicMock, temp_data_dir: Path897) -> None:898"""Test chunk translation with translation memory."""899mock_run.return_value = MagicMock(900returncode=0,901stderr="",902stdout='{"1:1": "初めに", "1:2": "これは", "1:3": "すべて", "1:4": "命が", "1:5": "光は"}',903)904905from src.context.translation_memory import TranslationMemoryManager906907loader = DataLoader(temp_data_dir)908glossary = GlossaryManager(loader.load_glossary())909storage = Storage(temp_data_dir)910tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")911pericope_manager = PericopeManager(912temp_data_dir / "metadata" / "pericopes.json"913)914context_builder = ContextBuilder(915loader, glossary, pericope_manager=pericope_manager916)917prompt_builder = PromptBuilder(glossary, translation_memory=tm)918translator = Translator(919context_builder, prompt_builder, storage, translation_memory=tm920)921922pericope = pericope_manager.get_pericope("john", 1, 1)923assert pericope is not None924925output = translator.translate_chunk("john", pericope, skip_existing=False)926927assert output.verse_count == 5928# Verify segments were added to TM929assert len(tm) > 0930931def test_chunk_tm_matches_no_source(self, temp_data_dir: Path) -> None:932"""Test _get_chunk_tm_matches returns None when no source text."""933loader = DataLoader(temp_data_dir)934glossary = GlossaryManager(loader.load_glossary())935storage = Storage(temp_data_dir)936pericope_manager = PericopeManager(937temp_data_dir / "metadata" / "pericopes.json"938)939context_builder = ContextBuilder(940loader, glossary, pericope_manager=pericope_manager941)942prompt_builder = PromptBuilder(glossary)943944from src.context.translation_memory import TranslationMemoryManager945946tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")947translator = Translator(948context_builder, prompt_builder, storage, translation_memory=tm949)950951# Create context with empty verses952context = ChunkTranslationContext(953pericope=Pericope(954id="test",955start_chapter=1,956start_verse=1,957end_chapter=1,958end_verse=1,959verses=["1:1"],960),961book="test",962verses=[963Verse(964reference=VerseReference(book="test", chapter=1, verse=1),965# No greek or hebrew text966)967],968)969970matches = translator._get_chunk_tm_matches(context)971assert matches is None972973974class TestPromptBuilderChunkEdgeCases:975"""Edge case tests for PromptBuilder chunk methods."""976977def test_build_chunk_prompt_no_theme(self) -> None:978"""Test chunk prompt without theme."""979glossary = GlossaryManager()980prompt_builder = PromptBuilder(glossary)981982pericope = Pericope(983id="test",984start_chapter=1,985start_verse=1,986end_chapter=1,987end_verse=2,988verses=["1:1", "1:2"],989theme=None, # No theme990)991992context = ChunkTranslationContext(993pericope=pericope,994book="test",995verses=[996Verse(997reference=VerseReference(book="test", chapter=1, verse=1),998greek_text="test text",999),1000Verse(1001reference=VerseReference(book="test", chapter=1, verse=2),1002greek_text="more text",1003),1004],1005)10061007prompt = prompt_builder.build_chunk_prompt(context)1008assert "PERICOPE" in prompt1009assert "Theme:" not in prompt # No theme line10101011def test_build_chunk_prompt_with_context(self, temp_data_dir: Path) -> None:1012"""Test chunk prompt with preceding/following context."""1013loader = DataLoader(temp_data_dir)1014glossary = GlossaryManager(loader.load_glossary())1015pericope_manager = PericopeManager(1016temp_data_dir / "metadata" / "pericopes.json"1017)1018context_builder = ContextBuilder(1019loader, glossary, pericope_manager=pericope_manager1020)1021prompt_builder = PromptBuilder(glossary)10221023# Get second pericope which should have preceding context1024pericope = pericope_manager.get_pericope("john", 1, 6)1025assert pericope is not None10261027context = context_builder.build_chunk_context("john", pericope)1028prompt = prompt_builder.build_chunk_prompt(context)10291030# Should have preceding context from first pericope1031assert "Preceding context" in prompt10321033def test_chunk_context_no_preceding_for_first_verse(1034self, temp_data_dir: Path1035) -> None:1036"""Test no preceding context for first verse."""1037loader = DataLoader(temp_data_dir)1038glossary = GlossaryManager(loader.load_glossary())1039pericope_manager = PericopeManager(1040temp_data_dir / "metadata" / "pericopes.json"1041)1042context_builder = ContextBuilder(1043loader, glossary, pericope_manager=pericope_manager1044)10451046pericope = pericope_manager.get_pericope("john", 1, 1)1047assert pericope is not None10481049context = context_builder.build_chunk_context("john", pericope)10501051# First verse should have no preceding context1052assert len(context.preceding_verses) == 0105310541055class TestPericopeManagerEdgeCases:1056"""Edge case tests for PericopeManager."""10571058def test_get_book_index(self, temp_pericope_data: Path) -> None:1059"""Test getting book index."""1060manager = PericopeManager(temp_pericope_data)10611062index = manager.get_book_index("john")1063assert index is not None1064assert index.book == "john"10651066def test_get_book_index_not_found(self, temp_pericope_data: Path) -> None:1067"""Test getting book index for non-existent book."""1068manager = PericopeManager(temp_pericope_data)10691070index = manager.get_book_index("nonexistent")1071assert index is None10721073def test_data_path_property(self, temp_pericope_data: Path) -> None:1074"""Test data_path property."""1075manager = PericopeManager(temp_pericope_data)1076assert manager.data_path == temp_pericope_data107710781079class TestTranslatorChunkEdgeCases:1080"""Edge case tests for Translator chunk methods."""10811082def test_parse_chunk_response_not_dict(self, temp_data_dir: Path) -> None:1083"""Test parsing response that is not a dict."""1084loader = DataLoader(temp_data_dir)1085glossary = GlossaryManager(loader.load_glossary())1086storage = Storage(temp_data_dir)1087context_builder = ContextBuilder(loader, glossary)1088prompt_builder = PromptBuilder(glossary)1089translator = Translator(context_builder, prompt_builder, storage)10901091response = '["1:1", "1:2"]' # Valid JSON but not a dict1092expected = ["1:1"]10931094# Arrays are not dicts, so the robust parser fails to extract a valid dict1095with pytest.raises(ChunkParsingError, match="Failed to parse"):1096translator._parse_chunk_response(response, expected)10971098def test_chunk_tm_matches_no_verses(self, temp_data_dir: Path) -> None:1099"""Test _get_chunk_tm_matches with empty verses list."""1100loader = DataLoader(temp_data_dir)1101glossary = GlossaryManager(loader.load_glossary())1102storage = Storage(temp_data_dir)1103context_builder = ContextBuilder(loader, glossary)1104prompt_builder = PromptBuilder(glossary)11051106from src.context.translation_memory import TranslationMemoryManager11071108tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1109translator = Translator(1110context_builder, prompt_builder, storage, translation_memory=tm1111)11121113# Context with no verses1114context = ChunkTranslationContext(1115pericope=Pericope(1116id="test",1117start_chapter=1,1118start_verse=1,1119end_chapter=1,1120end_verse=1,1121verses=["1:1"],1122),1123book="test",1124verses=[], # Empty verses list1125)11261127matches = translator._get_chunk_tm_matches(context)1128assert matches is None11291130def test_chunk_tm_matches_no_combined_text(self, temp_data_dir: Path) -> None:1131"""Test _get_chunk_tm_matches when combined text is empty."""1132loader = DataLoader(temp_data_dir)1133glossary = GlossaryManager(loader.load_glossary())1134storage = Storage(temp_data_dir)1135context_builder = ContextBuilder(loader, glossary)1136prompt_builder = PromptBuilder(glossary)11371138from src.context.translation_memory import TranslationMemoryManager11391140tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1141translator = Translator(1142context_builder, prompt_builder, storage, translation_memory=tm1143)11441145# Context with verse but no source text1146context = ChunkTranslationContext(1147pericope=Pericope(1148id="test",1149start_chapter=1,1150start_verse=1,1151end_chapter=1,1152end_verse=1,1153verses=["1:1"],1154),1155book="test",1156verses=[1157Verse(1158reference=VerseReference(book="test", chapter=1, verse=1),1159english_kjv="Test verse", # Only English, no Greek/Hebrew1160)1161],1162)11631164matches = translator._get_chunk_tm_matches(context)1165assert matches is None11661167@patch("src.pipeline.translator.subprocess.run")1168@patch("src.pipeline.translator.time.sleep")1169def test_translate_chapter_by_pericopes_with_tm_save(1170self, mock_sleep: MagicMock, mock_run: MagicMock, temp_data_dir: Path1171) -> None:1172"""Test TM save after translating chapter by pericopes."""1173mock_run.side_effect = [1174MagicMock(1175returncode=0,1176stderr="",1177stdout='{"1:1": "v1", "1:2": "v2", "1:3": "v3", "1:4": "v4", "1:5": "v5"}',1178),1179MagicMock(1180returncode=0,1181stderr="",1182stdout='{"1:6": "v6", "1:7": "v7", "1:8": "v8"}',1183),1184]11851186from src.context.translation_memory import TranslationMemoryManager11871188loader = DataLoader(temp_data_dir)1189glossary = GlossaryManager(loader.load_glossary())1190storage = Storage(temp_data_dir)1191tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1192pericope_manager = PericopeManager(1193temp_data_dir / "metadata" / "pericopes.json"1194)1195context_builder = ContextBuilder(1196loader, glossary, pericope_manager=pericope_manager1197)1198prompt_builder = PromptBuilder(glossary, translation_memory=tm)1199translator = Translator(1200context_builder, prompt_builder, storage, translation_memory=tm1201)12021203outputs = translator.translate_chapter_by_pericopes(1204"john", 1, skip_existing=False1205)12061207assert len(outputs) == 21208# Verify TM was saved (segments should have been added)1209assert len(tm) > 012101211def test_add_chunk_to_tm_no_target(self, temp_data_dir: Path) -> None:1212"""Test _add_chunk_to_tm when target translation is missing."""1213loader = DataLoader(temp_data_dir)1214glossary = GlossaryManager(loader.load_glossary())1215storage = Storage(temp_data_dir)1216pericope_manager = PericopeManager(1217temp_data_dir / "metadata" / "pericopes.json"1218)1219context_builder = ContextBuilder(1220loader, glossary, pericope_manager=pericope_manager1221)1222prompt_builder = PromptBuilder(glossary)12231224from src.context.translation_memory import TranslationMemoryManager12251226tm = TranslationMemoryManager(temp_data_dir / "memory" / "segments.json")1227translator = Translator(1228context_builder, prompt_builder, storage, translation_memory=tm1229)12301231# Create context1232context = ChunkTranslationContext(1233pericope=Pericope(1234id="test",1235start_chapter=1,1236start_verse=1,1237end_chapter=1,1238end_verse=2,1239verses=["1:1", "1:2"],1240),1241book="test",1242verses=[1243Verse(1244reference=VerseReference(book="test", chapter=1, verse=1),1245greek_text="Test text",1246),1247Verse(1248reference=VerseReference(book="test", chapter=1, verse=2),1249greek_text="More text",1250),1251],1252)12531254# Translations missing 1:21255translations = {"1:1": "翻訳1"}12561257# Should not error, just skip missing1258translator._add_chunk_to_tm(context, translations)1259assert len(tm) == 1 # Only 1:1 added126012611262class TestBuilderChunkEdgeCases:1263"""Edge case tests for ContextBuilder chunk methods."""12641265def test_build_chunk_context_no_verse_returns(1266self, temp_data_dir: Path1267) -> None:1268"""Test chunk context when pericope not found for verse."""1269loader = DataLoader(temp_data_dir)1270glossary = GlossaryManager(loader.load_glossary())1271pericope_manager = PericopeManager(1272temp_data_dir / "metadata" / "pericopes.json"1273)1274context_builder = ContextBuilder(1275loader, glossary, pericope_manager=pericope_manager1276)12771278# Chapter 10 has no pericope data1279ref = VerseReference(book="john", chapter=10, verse=1)1280context = context_builder.build_chunk_context_for_verse(ref)12811282assert context is None128312841285class TestPromptBuilderChunkFormatting:1286"""Tests for prompt formatting with various data."""12871288def test_format_tm_matches_empty(self) -> None:1289"""Test formatting empty TM matches."""1290glossary = GlossaryManager()1291prompt_builder = PromptBuilder(glossary)12921293result = prompt_builder._format_tm_matches([])1294assert result == ""12951296def test_build_chunk_prompt_with_tm_matches(self) -> None:1297"""Test chunk prompt includes TM matches when provided."""1298from src.models.translation_memory import TMMatch, TMSegment12991300glossary = GlossaryManager()1301prompt_builder = PromptBuilder(glossary)13021303pericope = Pericope(1304id="test",1305start_chapter=1,1306start_verse=1,1307end_chapter=1,1308end_verse=2,1309verses=["1:1", "1:2"],1310)13111312context = ChunkTranslationContext(1313pericope=pericope,1314book="test",1315verses=[1316Verse(1317reference=VerseReference(book="test", chapter=1, verse=1),1318greek_text="test text",1319),1320Verse(1321reference=VerseReference(book="test", chapter=1, verse=2),1322greek_text="more text",1323),1324],1325)13261327tm_matches = [1328TMMatch(1329segment=TMSegment(1330id="seg1",1331source_text="test source",1332source_normalized="test source",1333target_text="テスト翻訳",1334reference=VerseReference(book="test", chapter=1, verse=1),1335),1336similarity=0.85,1337match_type="fuzzy",1338)1339]13401341prompt = prompt_builder.build_chunk_prompt(context, tm_matches=tm_matches)1342assert "TRANSLATION MEMORY" in prompt1343assert "85%" in prompt13441345def test_build_chunk_prompt_with_hebrew(self) -> None:1346"""Test chunk prompt with Hebrew source text."""1347glossary = GlossaryManager()1348prompt_builder = PromptBuilder(glossary)13491350pericope = Pericope(1351id="test",1352start_chapter=1,1353start_verse=1,1354end_chapter=1,1355end_verse=1,1356verses=["1:1"],1357)13581359context = ChunkTranslationContext(1360pericope=pericope,1361book="genesis",1362verses=[1363Verse(1364reference=VerseReference(book="genesis", chapter=1, verse=1),1365hebrew_text="בְּרֵאשִׁית בָּרָא אֱלֹהִים", # Hebrew text1366),1367],1368)13691370prompt = prompt_builder.build_chunk_prompt(context)1371assert "בְּרֵאשִׁית" in prompt13721373def test_build_chunk_prompt_with_japanese_refs(self, temp_data_dir: Path) -> None:1374"""Test chunk prompt with Japanese reference translations."""1375glossary = GlossaryManager()1376prompt_builder = PromptBuilder(glossary)13771378pericope = Pericope(1379id="test",1380start_chapter=1,1381start_verse=1,1382end_chapter=1,1383end_verse=1,1384verses=["1:1"],1385)13861387context = ChunkTranslationContext(1388pericope=pericope,1389book="test",1390verses=[1391Verse(1392reference=VerseReference(book="test", chapter=1, verse=1),1393greek_text="test",1394japanese_meiji="明治訳",1395japanese_taisho="大正訳",1396),1397],1398)13991400prompt = prompt_builder.build_chunk_prompt(context)1401assert "明治元訳" in prompt1402assert "大正改訳" in prompt140314041405class TestPericopeManagerMissingBook:1406"""Tests for PericopeManager with missing book data."""14071408def test_get_pericope_missing_book(self, temp_pericope_data: Path) -> None:1409"""Test get_pericope returns None for missing book."""1410manager = PericopeManager(temp_pericope_data)14111412pericope = manager.get_pericope("nonexistent", 1, 1)1413assert pericope is None14141415def test_get_chapter_pericopes_missing_book(1416self, temp_pericope_data: Path1417) -> None:1418"""Test get_chapter_pericopes returns [] for missing book."""1419manager = PericopeManager(temp_pericope_data)14201421pericopes = manager.get_chapter_pericopes("nonexistent", 1)1422assert pericopes == []1423