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

OPEN SOURCE

software/tests/test_coverage_audit_core.py

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

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

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

software/tests/test_coverage_audit_core.py718 行このファイルを保存 ↓

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

ファイルの確認情報SHA-256 6f5c6468fdc54f5da4b1f681f6ab3728e92135238f8d6c4d4c6138dc1843d8f4
1"""Tricky core behaviour found uncovered by the 2026-09 coverage audit.23Memory never-clobber, resume/checkpoint state, chunk-response hygiene,4furigana validation, morphology selection and retry edges. No model or network5is called: subprocess and the Claude call are replaced, every store lives under6tmp_path, and conftest's rail keeps the production memory unreachable.7"""89from __future__ import annotations1011import asyncio12import json13import threading14from pathlib import Path15from unittest.mock import MagicMock, patch1617import pytest1819from src.context.builder import ContextBuilder20from src.context.cross_references import CrossReference, CrossReferenceManager, create_default_cross_references21from src.context.glossary import GlossaryManager22from src.context.pericope import PericopeManager23from src.context.pericope_generator import PericopeGenerator24from src.context.translation_memory import TranslationMemoryManager25from src.data.loader import DataLoader26from src.data.migrations import load_glossary_any_version27from src.data.storage import Storage28from src.data.versification import greek_strongs, hebrew_strongs29from src.exceptions import InvalidReferenceError, QualityThresholdError, TranslationError30from src.models.evaluation import VerseEvaluation31from src.models.glossary import GlossaryTerm32from src.models.pericope import ChunkTranslationContext, Pericope33from src.models.translation_memory import MatchType, SegmentLevel, TMSegment34from src.models.verse import TranslationOutput, Verse, VerseReference35from src.orchestration.models import BookResult, JobStatus36from src.orchestration.orchestrator import JobOrchestrator37from src.orchestration.signals import GracefulShutdown38from src.pipeline.morphology import select_morphology_notes39from src.pipeline.prompt import PromptBuilder, _morphology_notes_for40from src.pipeline.translator import Translator, _sanitize_translation_output41from src.utils import validation42from src.utils.furigana import validate_furigana_text43from src.utils.retry import RetryConfig, async_retry_with_backoff, retry_with_backoff444546def _ref(book="john", chapter=1, verse=1):47    return VerseReference(book=book, chapter=chapter, verse=verse)484950# --- translation memory: never clobber ---------------------------------------------515253class TestMemoryNeverClobbered:54    def test_a_failed_write_leaves_the_store_and_no_temp_file(self, tmp_path, monkeypatch):55        path = tmp_path / "segments.json"56        first = TranslationMemoryManager(path)57        first.add_from_translation("kept", "保持", _ref())58        first.save()59        before = path.read_bytes()6061        second = TranslationMemoryManager(path)62        second.add_from_translation("new", "新", _ref(verse=2))6364        def torn_write(*args, **kwargs):65            raise OSError("disk full")6667        monkeypatch.setattr("src.context.translation_memory.json.dump", torn_write)68        with pytest.raises(OSError, match="disk full"):69            second.save()7071        assert path.read_bytes() == before72        assert not list(tmp_path.glob("*.tmp"))7374    def test_valid_json_of_the_wrong_shape_is_quarantined_not_crashed_on(self, tmp_path):75        path = tmp_path / "segments.json"76        path.write_text("[1, 2, 3]", encoding="utf-8")77        manager = TranslationMemoryManager(path)78        manager.add_from_translation("after", "後", _ref())79        manager.save()8081        (quarantined,) = tmp_path.glob("segments.corrupt.*.json")82        assert quarantined.read_text(encoding="utf-8") == "[1, 2, 3]"83        assert len(TranslationMemoryManager(path)) == 18485    def test_re_adding_a_segment_does_not_duplicate_its_reference_entry(self, tmp_path):86        manager = TranslationMemoryManager(tmp_path / "m.json")87        segment = manager.add_from_translation("same", "同じ", _ref())88        manager.add_segment(segment)89        assert manager.get_by_reference(_ref()) == [segment]9091    def test_remove_survives_a_stale_reference_index(self, tmp_path):92        manager = TranslationMemoryManager(tmp_path / "m.json")93        segment = manager.add_from_translation("gone", "消", _ref())94        manager._by_reference.clear()  # index drifted from the segment map95        assert manager.remove_segment(segment.id) is True96        assert manager.get_segment(segment.id) is None9798    def test_phrase_level_near_match_is_labelled_phrase(self, tmp_path):99        manager = TranslationMemoryManager(tmp_path / "m.json")100        manager.add_from_translation(101            "the word was with god", "言葉は神と共に", _ref(), level=SegmentLevel.PHRASE102        )103        (match,) = manager.find_matches("the word was with god always", threshold=0.7)104        assert match.match_type is MatchType.PHRASE105106    def test_segments_written_by_another_worker_mid_run_are_kept(self, tmp_path):107        # Worker A loads, worker B saves, then A saves: B's segment survives.108        path = tmp_path / "segments.json"109        a = TranslationMemoryManager(path)110        a.load()111        b = TranslationMemoryManager(path)112        b.add_from_translation("from b", "B", _ref(verse=2))113        b.save()114        a.add_from_translation("from a", "A", _ref(verse=1))115        a.save()116        final = TranslationMemoryManager(path)117        assert {s.target_text for s in final.get_all_segments()} == {"A", "B"}118119120# --- resume / checkpoint state ---------------------------------------------------------121122123@pytest.fixture124def orchestrator(tmp_path) -> JobOrchestrator:125    (tmp_path / "config").mkdir()126    return JobOrchestrator(data_dir=tmp_path, max_parallel_books=2)127128129def _verse(chapter, verse, greek="λόγος"):130    return Verse(reference=_ref("titus", chapter, verse), greek_text=greek)131132133class TestResumeKeepsWorkAlreadyOnDisk:134    def test_verses_already_on_disk_start_completed(self, orchestrator):135        job_id = orchestrator.create_job(books=["titus"])136        loader = MagicMock()137        loader.get_book_chapters.return_value = [1]138        loader.load_chapter_verses.return_value = [_verse(1, 1), _verse(1, 2), _verse(1, 3)]139        storage = MagicMock()140        storage.get_progress.return_value = {"completed": {"1": [2]}}141        with patch("src.data.loader.DataLoader", return_value=loader), \142                patch("src.data.storage.Storage", return_value=storage):143            orchestrator._initialize_all_books(job_id, ["titus"])144145        pending = orchestrator.state_store.get_pending_verses(job_id, "titus")146        assert sorted((v.chapter, v.verse) for v in pending) == [(1, 1), (1, 3)]147        book = orchestrator.state_store.get_book_job(job_id, "titus")148        assert book.total_verses == 3149150    def test_resume_requeues_previously_failed_verses(self, orchestrator):151        job_id = orchestrator.create_job(books=["titus"])152        store = orchestrator.state_store153        store.initialize_verse_jobs(job_id, "titus", [(1, 1), (1, 2)])154        store.update_verse_status(job_id, "titus", 1, 1, JobStatus.FAILED, error="Claude CLI failed:")155        store.update_job_status(job_id, JobStatus.FAILED)156157        with patch.object(orchestrator, "run", return_value="ran") as run:158            assert orchestrator.resume(job_id) == "ran"159        run.assert_called_once_with(job_id)160        assert {(v.chapter, v.verse) for v in store.get_pending_verses(job_id, "titus")} == {161            (1, 1), (1, 2),162        }163164    def test_resume_without_an_id_picks_the_latest_incomplete_job(self, orchestrator):165        job_id = orchestrator.create_job(books=["titus"])166        orchestrator.state_store.initialize_verse_jobs(job_id, "titus", [(1, 1)])167        with patch.object(orchestrator, "run", return_value="ran") as run:168            orchestrator.resume()169        run.assert_called_once_with(job_id)170171    def test_in_progress_status_does_not_touch_book_counts(self, orchestrator):172        job_id = orchestrator.create_job(books=["titus"])173        store = orchestrator.state_store174        store.initialize_verse_jobs(job_id, "titus", [(1, 1)])175        store.update_verse_status(job_id, "titus", 1, 1, JobStatus.IN_PROGRESS)176        book = store.get_book_job(job_id, "titus")177        assert (book.completed_verses, book.failed_verses) == (0, 0)178179    def test_marking_a_verse_translated_twice_is_idempotent(self, tmp_path):180        storage = Storage(tmp_path)181        storage._update_progress("john", 3, 16)182        storage._update_progress("john", 3, 16)183        storage._update_progress("john", 3, 2)184        assert storage.get_progress("john")["completed"]["3"] == [2, 16]185186    @pytest.mark.xfail(187        strict=True,188        reason=(189            "Known and recorded in the run-bible-translation skill: a book "190            "counts as success however many verses failed, so a quota-shredded "191            "run is marked COMPLETED and `resume` finds nothing to do."192        ),193    )194    def test_a_job_whose_verses_all_failed_is_not_a_success(self, orchestrator):195        job_id = orchestrator.create_job(books=["titus"])196        shredded = [BookResult(book="titus", success=True, verses_translated=0,197                               verses_failed=46, duration_seconds=1)]198        with patch.object(orchestrator, "_initialize_all_books"), \199                patch.object(orchestrator, "_run_sequential", return_value=shredded):200            orchestrator.max_parallel_books = 1201            assert orchestrator.run(job_id).success is False202203204class TestParallelCollection:205    def _run(self, orchestrator, worker):206        job_id = orchestrator.create_job(books=["titus", "philemon"])207        shutdown = GracefulShutdown()208        with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker):209            results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown)210        return sorted(results, key=lambda r: r.book), shutdown211212    def test_each_book_result_is_collected(self, orchestrator):213        def worker(job_id, book, *rest):214            return BookResult(book=book, success=True, verses_translated=3,215                              verses_failed=0, duration_seconds=0)216217        results, _ = self._run(orchestrator, worker)218        assert [(r.book, r.verses_translated) for r in results] == [("philemon", 3), ("titus", 3)]219220    def test_books_finishing_after_a_shutdown_are_reported_cancelled(self, orchestrator):221        started = threading.Barrier(2)222223        def worker(job_id, book, *rest):224            started.wait(timeout=5)225            return BookResult(book=book, success=True, verses_translated=1,226                              verses_failed=0, duration_seconds=0)227228        job_id = orchestrator.create_job(books=["titus", "philemon"])229        shutdown = GracefulShutdown()230        real_submit = __import__("concurrent.futures").futures.ThreadPoolExecutor.submit231232        def submit_then_stop(self, fn, *args, **kwargs):233            future = real_submit(self, fn, *args, **kwargs)234            if args[1] == "philemon":235                shutdown.request_stop()236            return future237238        with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker), \239                patch("concurrent.futures.ThreadPoolExecutor.submit", submit_then_stop):240            results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown)241242        assert {r.error_message for r in results} == {"Cancelled due to shutdown"}243        assert all(not r.success for r in results)244245246def test_graceful_shutdown_off_the_main_thread_installs_no_handlers():247    seen = {}248249    def body():250        with GracefulShutdown() as shutdown:251            seen["handlers"] = dict(shutdown._original_handlers)252            shutdown.request_stop()253            seen["stop"] = shutdown.should_stop254255    thread = threading.Thread(target=body)256    thread.start()257    thread.join()258    assert seen == {"handlers": {}, "stop": True}259260261# --- chunk responses -------------------------------------------------------------------262263264@pytest.fixture265def translator(tmp_path) -> Translator:266    return Translator(MagicMock(), MagicMock(), Storage(tmp_path), auto_evaluate=False)267268269class TestChunkResponseHygiene:270    def test_an_extra_following_verse_is_dropped_not_saved(self, translator):271        # The prompt shows following verses with their refs; a model that272        # translates one must not overwrite the next pericope's verse.273        reply = json.dumps({"1:1": "初めに", "1:2": "この方は", "1:3": "次の段落"}, ensure_ascii=False)274        assert translator._parse_chunk_response(reply, ["1:1", "1:2"]) == {275            "1:1": "初めに", "1:2": "この方は",276        }277278    def test_a_non_verse_key_does_not_crash_the_save(self, translator, tmp_path):279        reply = json.dumps({"1:1": "初めに", "note": None}, ensure_ascii=False)280        translations = translator._parse_chunk_response(reply, ["1:1"])281        translator._save_chunk_translations("john", translations)282        assert translator.storage.load_translation(_ref()).text == "初めに"283284    def test_fenced_reply_with_prose_after_the_fence(self, translator):285        reply = '```json\n{"1:1": "初めに"}\n```\nI kept 言 as 言葉.'286        assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": "初めに"}287288    def test_escaped_quotes_and_braces_inside_values(self, translator):289        reply = 'Here: {"1:1": "彼は\\"{光}\\"と言った"} done'290        assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": '彼は"{光}"と言った'}291292293class TestChunkResume:294    def _pericope(self):295        return Pericope(id="john-1", start_chapter=1, start_verse=1, end_chapter=1,296                        end_verse=2, verses=["1:1", "1:2"])297298    def test_progress_says_translated_but_file_is_gone(self, translator):299        translator.storage = MagicMock()300        translator.storage.is_verse_translated.return_value = True301        translator.storage.load_translation.return_value = None302        assert translator._get_existing_chunk_translations("john", self._pericope()) == {}303304    def test_seam_skips_a_preceding_verse_with_no_translation(self, translator, tmp_path):305        context = ChunkTranslationContext(306            pericope=self._pericope(), book="john",307            verses=[Verse(reference=_ref(verse=1), greek_text="Ἐν ἀρχῇ"),308                    Verse(reference=_ref(verse=2), greek_text="οὗτος")],309            preceding_verses=[Verse(reference=_ref(verse=0 + 1), greek_text="x")],310        )311        translator.context_builder.build_chunk_context.return_value = context312        translator.prompt_builder.build_chunk_prompt.return_value = "prompt"313        with patch.object(translator, "_call_claude",314                          return_value='{"1:1": "初めに", "1:2": "この方は"}'):315            translator.translate_chunk("john", self._pericope(), skip_existing=False)316        kwargs = translator.prompt_builder.build_chunk_prompt.call_args.kwargs317        assert kwargs["preceding_translations"] is None318319    @pytest.mark.xfail(320        strict=True,321        reason=(322            "A resume that requeues one verse of a pericope re-translates the "323            "whole pericope and overwrites its already-completed siblings, even "324            "with skip_existing=True. Whole-pericope coherence may be the intent; "325            "needs a decision, so it is pinned rather than changed."326        ),327    )328    def test_partial_pericope_resume_keeps_already_translated_verses(self, translator):329        translator.storage.save_translation(TranslationOutput(reference=_ref(verse=1), text="既訳", model="m"))330        context = ChunkTranslationContext(331            pericope=self._pericope(), book="john",332            verses=[Verse(reference=_ref(verse=1), greek_text="a"),333                    Verse(reference=_ref(verse=2), greek_text="b")],334        )335        translator.context_builder.build_chunk_context.return_value = context336        translator.prompt_builder.build_chunk_prompt.return_value = "prompt"337        with patch.object(translator, "_call_claude",338                          return_value='{"1:1": "新訳", "1:2": "二"}'):339            translator.translate_chunk("john", self._pericope(), skip_existing=True)340        assert translator.storage.load_translation(_ref(verse=1)).text == "既訳"341342    def test_tm_lookup_is_skipped_when_the_first_verse_has_no_source(self, translator, tmp_path):343        translator.translation_memory = TranslationMemoryManager(tmp_path / "m.json")344        context = ChunkTranslationContext(345            pericope=self._pericope(), book="matthew",346            # e.g. a verse the critical text omits, opening the pericope347            verses=[Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit..."),348                    Verse(reference=_ref("matthew", 17, 22), greek_text="Συστρεφομένων")],349        )350        assert translator._get_chunk_tm_matches(context) is None351352353class TestVerseMode:354    def test_progress_says_translated_but_file_is_gone_retranslates(self, translator):355        translator.storage = MagicMock()356        translator.storage.is_verse_translated.return_value = True357        translator.storage.load_translation.return_value = None358        translator.context_builder.build_context.side_effect = RuntimeError("rebuilt")359        with pytest.raises(RuntimeError, match="rebuilt"):360            translator.translate_verse(_ref())361362    def test_async_progress_says_translated_but_file_is_gone(self, translator):363        translator.storage = MagicMock()364        translator.storage.is_verse_translated.return_value = True365        translator.storage.load_translation.return_value = None366        translator.context_builder.build_context.side_effect = RuntimeError("rebuilt")367        with pytest.raises(RuntimeError, match="rebuilt"):368            asyncio.run(translator.translate_verse_async(_ref()))369370    def test_async_chapter_saves_memory_once(self, translator, tmp_path):371        translator.translation_memory = MagicMock()372        translator.rate_limit_delay = 0373        translator.context_builder.loader.load_chapter_verses.return_value = [374            Verse(reference=_ref(verse=1)), Verse(reference=_ref(verse=2)),375        ]376377        async def fake(ref, skip_existing):378            return TranslationOutput(reference=ref, text="訳", model="m")379380        with patch.object(translator, "translate_verse_async", side_effect=fake):381            outputs = asyncio.run(translator.translate_chapter_async("john", 1))382        assert [o.reference.verse for o in outputs] == [1, 2]383        translator.translation_memory.save.assert_called_once()384385    def test_kougo_is_listed_as_a_source(self, translator):386        verse = Verse(reference=_ref(), japanese_kougo="初めに言があった。")387        context = MagicMock(target_verse=verse)388        assert translator._get_sources_used(context) == ["kougo"]389390    def test_repeated_blank_lines_around_a_self_correction(self):391        raw = "\n最初の訳。\n\n\n  \nWait, let me fix that.\n\n\n初めに言があった。\n"392        assert _sanitize_translation_output(raw) == "初めに言があった。"393394395class TestQualityGate:396    def _eval(self, overall, scores):397        evaluation = MagicMock(spec=VerseEvaluation)398        evaluation.overall_score = overall399        evaluation.scores = scores400        return evaluation401402    def test_passes_when_theological_accuracy_was_not_scored(self, tmp_path):403        t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)404        t._check_quality_gate(self._eval(90, {}), _ref())405406    def test_passes_when_theological_accuracy_clears_its_threshold(self, tmp_path):407        t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)408        t._check_quality_gate(self._eval(90, {"theological_accuracy": MagicMock(score=80)}), _ref())409410    def test_high_overall_does_not_mask_low_theological_accuracy(self, tmp_path):411        t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)412        with pytest.raises(QualityThresholdError, match="theological accuracy"):413            t._check_quality_gate(self._eval(95, {"theological_accuracy": MagicMock(score=60)}), _ref())414415416# --- prompts --------------------------------------------------------------------------417418419class TestPromptSourceHandling:420    @pytest.fixture(autouse=True)421    def _morphology_on(self, monkeypatch):422        # Off by default (TRANSLATE_BIBLE_MORPH); the branches only run when on.423        monkeypatch.setattr("src.pipeline.prompt.MORPHOLOGY_ENABLED", True)424425    def test_morphology_is_empty_for_a_verse_without_word_data(self):426        assert _morphology_notes_for(Verse(reference=_ref(), greek_text="λόγος")) == []427428    def test_verse_prompt_includes_hebrew_morphology_and_themes(self):429        verse = Verse(430            reference=_ref("genesis", 1, 1),431            hebrew_text="בְּרֵאשִׁית בָּרָא",432            hebrew_words=[{"word": "בָּרָא", "morph": "HVqp3ms"}],433        )434        context = MagicMock(target_verse=verse, preceding_verses=[], following_verses=[],435                            chapter_summary=None, glossary_terms=[])436        context.book_metadata = MagicMock(name_english="Genesis", name_japanese="創世記",437                                          description="d", themes=["creation"])438        prompt = PromptBuilder(GlossaryManager()).build_prompt(context)439        assert "Hebrew: בְּרֵאשִׁית בָּרָא" in prompt440        assert "MORPHOLOGY" in prompt and "qal perfect (qatal) 3ms" in prompt441        assert "Themes: creation" in prompt442        assert "Romanization" not in prompt  # no transliteration supplied443444    def test_book_context_without_themes_has_no_themes_line(self):445        context = MagicMock(target_verse=Verse(reference=_ref(), greek_text="λόγος"),446                            preceding_verses=[], following_verses=[],447                            chapter_summary=None, glossary_terms=[])448        context.book_metadata = MagicMock(name_english="John", name_japanese="ヨハネ",449                                          description="d", themes=[])450        prompt = PromptBuilder(GlossaryManager()).build_prompt(context)451        assert "Book: John (ヨハネ) - d" in prompt and "Themes:" not in prompt452453    def test_verse_with_no_greek_or_hebrew_shows_only_the_header(self):454        builder = PromptBuilder(GlossaryManager())455        assert builder._format_source_texts(Verse(reference=_ref())) == "SOURCE TEXT:"456457    def test_chunk_source_omits_a_critical_text_gap_but_references_keep_it(self):458        # Matt 17:21 is absent from the critical Greek text; the model still459        # sees it through the reference translations.460        verses = [Verse(reference=_ref("matthew", 17, 20), greek_text="ὁ δὲ λέγει"),461                  Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit this kind")]462        context = MagicMock(verses=verses)463        builder = PromptBuilder(GlossaryManager())464        source = builder._format_chunk_source_texts(context)465        assert "[17:20]" in source and "[17:21]" not in source466        assert "Howbeit this kind" in builder._format_chunk_reference_translations(context)467468469# --- morphology -----------------------------------------------------------------------470471472class TestMorphologySelection:473    def test_participle_with_unparsed_case_and_number(self):474        (note,) = select_morphology_notes(475            [{"word": "λέγων", "lemma": "λέγω", "pos": "V-", "parsing": "-PAP----"}], "greek"476        )477        assert note.endswith("present active participle")478479    def test_marked_tense_outranks_plain_indicative_but_not_moods(self):480        words = [481            {"word": "ἦν", "lemma": "εἰμί", "pos": "V-", "parsing": "3IAI-S--"},    # imperfect482            {"word": "λέγει", "lemma": "λέγω", "pos": "V-", "parsing": "3PAI-S--"},  # present483            {"word": "ἀκούσατε", "lemma": "ἀκούω", "pos": "V-", "parsing": "2AAD-P--"},  # imperative484            {"word": "λόγος", "lemma": "λόγος", "pos": "N-", "parsing": "----NSM-"},  # noun: skipped485        ]486        notes = select_morphology_notes(words, "greek")487        assert [n.split(" (")[0] for n in notes] == ["ἀκούσατε", "ἦν", "λέγει"]488489    def test_limit_caps_the_notes(self):490        words = [{"word": f"w{i}", "lemma": "l", "pos": "V-", "parsing": "3PAI-S--"} for i in range(9)]491        assert len(select_morphology_notes(words, "greek", limit=2)) == 2492493494# --- furigana -------------------------------------------------------------------------495496497class TestFuriganaBrackets:498    def test_names_the_bracket_actually_left_open(self):499        # The old count heuristic blamed position 1 here.500        assert validate_furigana_text("漢[かん]字[じ") == ["Unmatched opening bracket at position 6"]501502    def test_nested_then_unclosed_reports_both(self):503        assert validate_furigana_text("神[かみは愛[あい]です") == [504            "Nested bracket at position 6",505            "Unmatched opening bracket at position 1",506        ]507508    def test_stray_closer_with_a_later_unclosed_opener(self):509        assert validate_furigana_text("神]は愛[あい") == [510            "Unmatched closing bracket at position 1",511            "Unmatched opening bracket at position 4",512        ]513514    def test_kanji_repeat_mark_counts_as_kanji_base(self):515        assert validate_furigana_text("人々[ひとびと]") == []516517    def test_katakana_base_is_flagged(self):518        (error,) = validate_furigana_text("イエス[いえす]")519        assert "Non-kanji character 'ス'" in error520521522# --- retry ----------------------------------------------------------------------------523524525class TestRetryEdges:526    def test_zero_attempts_never_calls_and_raises(self):527        calls = []528529        @retry_with_backoff(RetryConfig(max_attempts=0))530        def work():531            calls.append(1)532533        with pytest.raises(TranslationError, match="All retry attempts failed"):534            work()535        assert calls == []536537    def test_async_zero_attempts_never_calls_and_raises(self):538        calls = []539540        @async_retry_with_backoff(RetryConfig(max_attempts=0))541        async def work():542            calls.append(1)543544        with pytest.raises(TranslationError, match="All retry attempts failed"):545            asyncio.run(work())546        assert calls == []547548549# --- path safety, versification, data loading ---------------------------------------550551552def test_path_guard_holds_even_if_normalisation_ever_passes_traversal(monkeypatch):553    monkeypatch.setattr(validation, "normalize_book_name", lambda book: "../john")554    with pytest.raises(InvalidReferenceError, match="invalid path characters"):555        validation.sanitize_book_name_for_path("john")556557558@pytest.mark.parametrize(559    "lemma,expected",560    [561        ("c/1254 a", "H1254"),   # prefix + homonym letter562        ("b/7225", "H7225"),     # prefix only563        ("430", "H430"),564        ("d", ""),               # article, no number565        (None, ""),566    ],567)568def test_hebrew_strongs_takes_the_head_word(lemma, expected):569    assert hebrew_strongs(lemma) == expected570571572def test_greek_strongs_reads_the_verses_map(tmp_path):573    (tmp_path / "sources").mkdir()574    (tmp_path / "sources" / "greek_strongs.json").write_text(575        json.dumps({"verses": {"JHN 1:1": ["G1722"]}}), encoding="utf-8"576    )577    assert greek_strongs(tmp_path) == {"JHN 1:1": ["G1722"]}578579580def test_book_chapters_ignore_non_numeric_keys(tmp_path):581    kjv = tmp_path / "sources" / "english" / "kjv"582    kjv.mkdir(parents=True)583    (kjv / "jude.json").write_text(json.dumps({"1:1": "Jude", "title": "x", "1:2": "To"}))584    assert DataLoader(tmp_path).get_book_chapters("jude") == [1]585586587def test_v1_glossary_term_keeps_an_explicit_domain(tmp_path):588    path = tmp_path / "glossary.json"589    path.write_text(json.dumps({"version": "1.0", "terms": [{590        "original": "χάρις", "transliteration": "charis", "japanese": "恵み",591        "english": "grace", "domain": "soteriology",592    }]}, ensure_ascii=False), encoding="utf-8")593    (term,) = load_glossary_any_version(path)594    assert term.domain.value == "soteriology"595596597# --- context building ---------------------------------------------------------------------598599600@pytest.fixture601def sparse_john(tmp_path) -> DataLoader:602    greek = tmp_path / "sources" / "greek"603    greek.mkdir(parents=True)604    # 1:1 and 1:5 have no text at all (a gap in the source data).605    (greek / "john.json").write_text(json.dumps({606        "1:2": {"text": "οὗτος ἦν ἐν ἀρχῇ πρὸς τὸν θεόν"},607        "1:3": {"text": "πάντα δι᾽ αὐτοῦ ἐγένετο"},608        "1:4": {"text": "ἐν αὐτῷ ζωὴ ἦν"},609    }, ensure_ascii=False), encoding="utf-8")610    return DataLoader(tmp_path)611612613def _theos():614    return GlossaryTerm(original="θεόν", transliteration="theon", japanese="神",615                        english="God", domain="theology_proper")616617618class TestContextSkipsEmptyNeighbours:619    def test_verse_context_leaves_out_an_empty_preceding_verse(self, sparse_john):620        builder = ContextBuilder(sparse_john, GlossaryManager(), context_window=2)621        context = builder.build_context(_ref(verse=3))622        assert [v.reference.verse for v in context.preceding_verses] == [2]623624    def test_chunk_context_skips_empty_neighbours_and_dedupes_terms(self, sparse_john):625        builder = ContextBuilder(sparse_john, GlossaryManager([_theos()]), context_window=2)626        pericope = Pericope(id="p", start_chapter=1, start_verse=3, end_chapter=1,627                            end_verse=3, verses=["1:3"])628        context = builder.build_chunk_context("john", pericope)629        assert [v.reference.verse for v in context.preceding_verses] == [2]630        assert [v.reference.verse for v in context.following_verses] == [4]631        verses = [Verse(reference=_ref(verse=2), greek_text="πρὸς τὸν θεόν"),632                  Verse(reference=_ref(verse=9), greek_text="τὸν θεόν")]633        assert [t.original for t in builder._find_chunk_terms(verses)] == ["θεόν"]634635    def test_a_term_in_both_greek_and_hebrew_is_listed_once(self, sparse_john):636        builder = ContextBuilder(sparse_john, GlossaryManager([_theos()]))637        verse = Verse(reference=_ref(), greek_text="τὸν θεόν", hebrew_text="θεόν")638        assert [t.original for t in builder._find_relevant_terms(verse)] == ["θεόν"]639640641class TestGlossaryPositions:642    def test_positions_empty_without_terms_or_text(self):643        assert GlossaryManager().find_terms_in_text_with_positions("λόγος") == []644        assert GlossaryManager([_theos()]).find_terms_in_text_with_positions("") == []645646    def test_positions_are_reported_for_every_occurrence(self):647        manager = GlossaryManager([_theos()])648        hits = manager.find_terms_in_text_with_positions("τὸν θεόν καὶ θεόν")649        assert [(t.original, s, e) for t, s, e in hits] == [("θεόν", 4, 8), ("θεόν", 13, 17)]650651    def test_a_micro_sign_lookalike_is_skipped_not_crashed_on(self):652        # U+00B5 (micro sign, a common mis-encoding of Greek mu) matches the653        # IGNORECASE regex but .lower() keeps it distinct from the index key,654        # so the hit is dropped. Source data must be NFKC-clean for μ terms.655        term = GlossaryTerm(original="μαθητής", transliteration="mathētēs",656                            japanese="弟子", english="disciple", domain="general")657        manager = GlossaryManager([term])658        assert manager.find_terms_in_text_with_positions("ὁ \u00b5αθητής") == []659        assert [t.original for t, *_ in manager.find_terms_in_text_with_positions("ὁ μαθητής")] == ["μαθητής"]660661    def test_a_term_seen_twice_is_returned_once(self):662        assert len(GlossaryManager([_theos()]).find_terms_in_text("θεόν θεόν ΘΕΌΝ")) == 1663664665class TestCrossReferences:666    def _xref(self, source_verse):667        return CrossReference(source_book="john", source_chapter=1, source_verse=source_verse,668                              target_book="genesis", target_chapter=1, target_verse_start=1)669670    def test_two_sources_pointing_at_one_target_are_both_found(self, tmp_path):671        manager = CrossReferenceManager(tmp_path)672        manager.add_reference(self._xref(1))673        manager.add_reference(self._xref(3))674        found = manager.find_references_to_verse("genesis", 1, 1)675        assert sorted(r.source_verse for r in found) == [1, 3]676677    def test_save_to_an_explicit_path_and_reload(self, tmp_path):678        manager = CrossReferenceManager(tmp_path)679        manager.add_reference(self._xref(1))680        target = tmp_path / "elsewhere.json"681        manager.save(target)682        assert "genesis" in target.read_text(encoding="utf-8")683684    def test_defaults_are_not_seeded_over_existing_data(self, tmp_path):685        (tmp_path / "metadata").mkdir()686        existing = CrossReferenceManager(tmp_path)687        existing.add_reference(self._xref(7))688        existing.save()689        manager = create_default_cross_references(tmp_path)690        assert [r.source_verse for r in manager.find_references_to_verse("genesis", 1, 1)] == [7]691692693class TestPericopeData:694    def test_statistics_ignore_a_book_with_no_pericopes(self, tmp_path):695        path = tmp_path / "pericopes.json"696        path.write_text(json.dumps({"books": {697            "jude": {"name": "Jude", "pericopes": []},698            "titus": {"name": "Titus", "pericopes": [699                {"id": "t1", "start_chapter": 1, "start_verse": 1, "end_chapter": 1,700                 "end_verse": 4, "verses": ["1:1", "1:2", "1:3", "1:4"]},701            ]},702        }}), encoding="utf-8")703        stats = PericopeManager(path).get_statistics()704        assert stats["book_names"] == ["titus"]705        assert stats["total_pericopes"] == 1706707    def test_generate_and_save_to_an_explicit_path(self, tmp_path):708        greek = tmp_path / "sources" / "greek"709        greek.mkdir(parents=True)710        (greek / "jude.json").write_text(json.dumps({711            "1:1": {"text": "¶ Ἰούδας"}, "1:2": {"text": "ἔλεος"}, "1:3": {"text": "¶ Ἀγαπητοί"},712        }, ensure_ascii=False), encoding="utf-8")713        target = tmp_path / "out" / "generated.json"714        target.parent.mkdir()715        PericopeGenerator(tmp_path).generate_and_save("jude", output_path=target)716        # Note: written as {book: ...}, not the {"books": ...} PericopeManager reads.717        assert json.loads(target.read_text(encoding="utf-8"))["jude"]["auto_generated"] is True718