OPEN SOURCE
software/tests/test_coverage_audit_core.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
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):47return VerseReference(book=book, chapter=chapter, verse=verse)484950# --- translation memory: never clobber ---------------------------------------------515253class TestMemoryNeverClobbered:54def test_a_failed_write_leaves_the_store_and_no_temp_file(self, tmp_path, monkeypatch):55path = tmp_path / "segments.json"56first = TranslationMemoryManager(path)57first.add_from_translation("kept", "保持", _ref())58first.save()59before = path.read_bytes()6061second = TranslationMemoryManager(path)62second.add_from_translation("new", "新", _ref(verse=2))6364def torn_write(*args, **kwargs):65raise OSError("disk full")6667monkeypatch.setattr("src.context.translation_memory.json.dump", torn_write)68with pytest.raises(OSError, match="disk full"):69second.save()7071assert path.read_bytes() == before72assert not list(tmp_path.glob("*.tmp"))7374def test_valid_json_of_the_wrong_shape_is_quarantined_not_crashed_on(self, tmp_path):75path = tmp_path / "segments.json"76path.write_text("[1, 2, 3]", encoding="utf-8")77manager = TranslationMemoryManager(path)78manager.add_from_translation("after", "後", _ref())79manager.save()8081(quarantined,) = tmp_path.glob("segments.corrupt.*.json")82assert quarantined.read_text(encoding="utf-8") == "[1, 2, 3]"83assert len(TranslationMemoryManager(path)) == 18485def test_re_adding_a_segment_does_not_duplicate_its_reference_entry(self, tmp_path):86manager = TranslationMemoryManager(tmp_path / "m.json")87segment = manager.add_from_translation("same", "同じ", _ref())88manager.add_segment(segment)89assert manager.get_by_reference(_ref()) == [segment]9091def test_remove_survives_a_stale_reference_index(self, tmp_path):92manager = TranslationMemoryManager(tmp_path / "m.json")93segment = manager.add_from_translation("gone", "消", _ref())94manager._by_reference.clear() # index drifted from the segment map95assert manager.remove_segment(segment.id) is True96assert manager.get_segment(segment.id) is None9798def test_phrase_level_near_match_is_labelled_phrase(self, tmp_path):99manager = TranslationMemoryManager(tmp_path / "m.json")100manager.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)104assert match.match_type is MatchType.PHRASE105106def 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.108path = tmp_path / "segments.json"109a = TranslationMemoryManager(path)110a.load()111b = TranslationMemoryManager(path)112b.add_from_translation("from b", "B", _ref(verse=2))113b.save()114a.add_from_translation("from a", "A", _ref(verse=1))115a.save()116final = TranslationMemoryManager(path)117assert {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()126return JobOrchestrator(data_dir=tmp_path, max_parallel_books=2)127128129def _verse(chapter, verse, greek="λόγος"):130return Verse(reference=_ref("titus", chapter, verse), greek_text=greek)131132133class TestResumeKeepsWorkAlreadyOnDisk:134def test_verses_already_on_disk_start_completed(self, orchestrator):135job_id = orchestrator.create_job(books=["titus"])136loader = MagicMock()137loader.get_book_chapters.return_value = [1]138loader.load_chapter_verses.return_value = [_verse(1, 1), _verse(1, 2), _verse(1, 3)]139storage = MagicMock()140storage.get_progress.return_value = {"completed": {"1": [2]}}141with patch("src.data.loader.DataLoader", return_value=loader), \142patch("src.data.storage.Storage", return_value=storage):143orchestrator._initialize_all_books(job_id, ["titus"])144145pending = orchestrator.state_store.get_pending_verses(job_id, "titus")146assert sorted((v.chapter, v.verse) for v in pending) == [(1, 1), (1, 3)]147book = orchestrator.state_store.get_book_job(job_id, "titus")148assert book.total_verses == 3149150def test_resume_requeues_previously_failed_verses(self, orchestrator):151job_id = orchestrator.create_job(books=["titus"])152store = orchestrator.state_store153store.initialize_verse_jobs(job_id, "titus", [(1, 1), (1, 2)])154store.update_verse_status(job_id, "titus", 1, 1, JobStatus.FAILED, error="Claude CLI failed:")155store.update_job_status(job_id, JobStatus.FAILED)156157with patch.object(orchestrator, "run", return_value="ran") as run:158assert orchestrator.resume(job_id) == "ran"159run.assert_called_once_with(job_id)160assert {(v.chapter, v.verse) for v in store.get_pending_verses(job_id, "titus")} == {161(1, 1), (1, 2),162}163164def test_resume_without_an_id_picks_the_latest_incomplete_job(self, orchestrator):165job_id = orchestrator.create_job(books=["titus"])166orchestrator.state_store.initialize_verse_jobs(job_id, "titus", [(1, 1)])167with patch.object(orchestrator, "run", return_value="ran") as run:168orchestrator.resume()169run.assert_called_once_with(job_id)170171def test_in_progress_status_does_not_touch_book_counts(self, orchestrator):172job_id = orchestrator.create_job(books=["titus"])173store = orchestrator.state_store174store.initialize_verse_jobs(job_id, "titus", [(1, 1)])175store.update_verse_status(job_id, "titus", 1, 1, JobStatus.IN_PROGRESS)176book = store.get_book_job(job_id, "titus")177assert (book.completed_verses, book.failed_verses) == (0, 0)178179def test_marking_a_verse_translated_twice_is_idempotent(self, tmp_path):180storage = Storage(tmp_path)181storage._update_progress("john", 3, 16)182storage._update_progress("john", 3, 16)183storage._update_progress("john", 3, 2)184assert storage.get_progress("john")["completed"]["3"] == [2, 16]185186@pytest.mark.xfail(187strict=True,188reason=(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)194def test_a_job_whose_verses_all_failed_is_not_a_success(self, orchestrator):195job_id = orchestrator.create_job(books=["titus"])196shredded = [BookResult(book="titus", success=True, verses_translated=0,197verses_failed=46, duration_seconds=1)]198with patch.object(orchestrator, "_initialize_all_books"), \199patch.object(orchestrator, "_run_sequential", return_value=shredded):200orchestrator.max_parallel_books = 1201assert orchestrator.run(job_id).success is False202203204class TestParallelCollection:205def _run(self, orchestrator, worker):206job_id = orchestrator.create_job(books=["titus", "philemon"])207shutdown = GracefulShutdown()208with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker):209results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown)210return sorted(results, key=lambda r: r.book), shutdown211212def test_each_book_result_is_collected(self, orchestrator):213def worker(job_id, book, *rest):214return BookResult(book=book, success=True, verses_translated=3,215verses_failed=0, duration_seconds=0)216217results, _ = self._run(orchestrator, worker)218assert [(r.book, r.verses_translated) for r in results] == [("philemon", 3), ("titus", 3)]219220def test_books_finishing_after_a_shutdown_are_reported_cancelled(self, orchestrator):221started = threading.Barrier(2)222223def worker(job_id, book, *rest):224started.wait(timeout=5)225return BookResult(book=book, success=True, verses_translated=1,226verses_failed=0, duration_seconds=0)227228job_id = orchestrator.create_job(books=["titus", "philemon"])229shutdown = GracefulShutdown()230real_submit = __import__("concurrent.futures").futures.ThreadPoolExecutor.submit231232def submit_then_stop(self, fn, *args, **kwargs):233future = real_submit(self, fn, *args, **kwargs)234if args[1] == "philemon":235shutdown.request_stop()236return future237238with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker), \239patch("concurrent.futures.ThreadPoolExecutor.submit", submit_then_stop):240results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown)241242assert {r.error_message for r in results} == {"Cancelled due to shutdown"}243assert all(not r.success for r in results)244245246def test_graceful_shutdown_off_the_main_thread_installs_no_handlers():247seen = {}248249def body():250with GracefulShutdown() as shutdown:251seen["handlers"] = dict(shutdown._original_handlers)252shutdown.request_stop()253seen["stop"] = shutdown.should_stop254255thread = threading.Thread(target=body)256thread.start()257thread.join()258assert seen == {"handlers": {}, "stop": True}259260261# --- chunk responses -------------------------------------------------------------------262263264@pytest.fixture265def translator(tmp_path) -> Translator:266return Translator(MagicMock(), MagicMock(), Storage(tmp_path), auto_evaluate=False)267268269class TestChunkResponseHygiene:270def 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.273reply = json.dumps({"1:1": "初めに", "1:2": "この方は", "1:3": "次の段落"}, ensure_ascii=False)274assert translator._parse_chunk_response(reply, ["1:1", "1:2"]) == {275"1:1": "初めに", "1:2": "この方は",276}277278def test_a_non_verse_key_does_not_crash_the_save(self, translator, tmp_path):279reply = json.dumps({"1:1": "初めに", "note": None}, ensure_ascii=False)280translations = translator._parse_chunk_response(reply, ["1:1"])281translator._save_chunk_translations("john", translations)282assert translator.storage.load_translation(_ref()).text == "初めに"283284def test_fenced_reply_with_prose_after_the_fence(self, translator):285reply = '```json\n{"1:1": "初めに"}\n```\nI kept 言 as 言葉.'286assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": "初めに"}287288def test_escaped_quotes_and_braces_inside_values(self, translator):289reply = 'Here: {"1:1": "彼は\\"{光}\\"と言った"} done'290assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": '彼は"{光}"と言った'}291292293class TestChunkResume:294def _pericope(self):295return Pericope(id="john-1", start_chapter=1, start_verse=1, end_chapter=1,296end_verse=2, verses=["1:1", "1:2"])297298def test_progress_says_translated_but_file_is_gone(self, translator):299translator.storage = MagicMock()300translator.storage.is_verse_translated.return_value = True301translator.storage.load_translation.return_value = None302assert translator._get_existing_chunk_translations("john", self._pericope()) == {}303304def test_seam_skips_a_preceding_verse_with_no_translation(self, translator, tmp_path):305context = ChunkTranslationContext(306pericope=self._pericope(), book="john",307verses=[Verse(reference=_ref(verse=1), greek_text="Ἐν ἀρχῇ"),308Verse(reference=_ref(verse=2), greek_text="οὗτος")],309preceding_verses=[Verse(reference=_ref(verse=0 + 1), greek_text="x")],310)311translator.context_builder.build_chunk_context.return_value = context312translator.prompt_builder.build_chunk_prompt.return_value = "prompt"313with patch.object(translator, "_call_claude",314return_value='{"1:1": "初めに", "1:2": "この方は"}'):315translator.translate_chunk("john", self._pericope(), skip_existing=False)316kwargs = translator.prompt_builder.build_chunk_prompt.call_args.kwargs317assert kwargs["preceding_translations"] is None318319@pytest.mark.xfail(320strict=True,321reason=(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)328def test_partial_pericope_resume_keeps_already_translated_verses(self, translator):329translator.storage.save_translation(TranslationOutput(reference=_ref(verse=1), text="既訳", model="m"))330context = ChunkTranslationContext(331pericope=self._pericope(), book="john",332verses=[Verse(reference=_ref(verse=1), greek_text="a"),333Verse(reference=_ref(verse=2), greek_text="b")],334)335translator.context_builder.build_chunk_context.return_value = context336translator.prompt_builder.build_chunk_prompt.return_value = "prompt"337with patch.object(translator, "_call_claude",338return_value='{"1:1": "新訳", "1:2": "二"}'):339translator.translate_chunk("john", self._pericope(), skip_existing=True)340assert translator.storage.load_translation(_ref(verse=1)).text == "既訳"341342def test_tm_lookup_is_skipped_when_the_first_verse_has_no_source(self, translator, tmp_path):343translator.translation_memory = TranslationMemoryManager(tmp_path / "m.json")344context = ChunkTranslationContext(345pericope=self._pericope(), book="matthew",346# e.g. a verse the critical text omits, opening the pericope347verses=[Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit..."),348Verse(reference=_ref("matthew", 17, 22), greek_text="Συστρεφομένων")],349)350assert translator._get_chunk_tm_matches(context) is None351352353class TestVerseMode:354def test_progress_says_translated_but_file_is_gone_retranslates(self, translator):355translator.storage = MagicMock()356translator.storage.is_verse_translated.return_value = True357translator.storage.load_translation.return_value = None358translator.context_builder.build_context.side_effect = RuntimeError("rebuilt")359with pytest.raises(RuntimeError, match="rebuilt"):360translator.translate_verse(_ref())361362def test_async_progress_says_translated_but_file_is_gone(self, translator):363translator.storage = MagicMock()364translator.storage.is_verse_translated.return_value = True365translator.storage.load_translation.return_value = None366translator.context_builder.build_context.side_effect = RuntimeError("rebuilt")367with pytest.raises(RuntimeError, match="rebuilt"):368asyncio.run(translator.translate_verse_async(_ref()))369370def test_async_chapter_saves_memory_once(self, translator, tmp_path):371translator.translation_memory = MagicMock()372translator.rate_limit_delay = 0373translator.context_builder.loader.load_chapter_verses.return_value = [374Verse(reference=_ref(verse=1)), Verse(reference=_ref(verse=2)),375]376377async def fake(ref, skip_existing):378return TranslationOutput(reference=ref, text="訳", model="m")379380with patch.object(translator, "translate_verse_async", side_effect=fake):381outputs = asyncio.run(translator.translate_chapter_async("john", 1))382assert [o.reference.verse for o in outputs] == [1, 2]383translator.translation_memory.save.assert_called_once()384385def test_kougo_is_listed_as_a_source(self, translator):386verse = Verse(reference=_ref(), japanese_kougo="初めに言があった。")387context = MagicMock(target_verse=verse)388assert translator._get_sources_used(context) == ["kougo"]389390def test_repeated_blank_lines_around_a_self_correction(self):391raw = "\n最初の訳。\n\n\n \nWait, let me fix that.\n\n\n初めに言があった。\n"392assert _sanitize_translation_output(raw) == "初めに言があった。"393394395class TestQualityGate:396def _eval(self, overall, scores):397evaluation = MagicMock(spec=VerseEvaluation)398evaluation.overall_score = overall399evaluation.scores = scores400return evaluation401402def test_passes_when_theological_accuracy_was_not_scored(self, tmp_path):403t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)404t._check_quality_gate(self._eval(90, {}), _ref())405406def test_passes_when_theological_accuracy_clears_its_threshold(self, tmp_path):407t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)408t._check_quality_gate(self._eval(90, {"theological_accuracy": MagicMock(score=80)}), _ref())409410def test_high_overall_does_not_mask_low_theological_accuracy(self, tmp_path):411t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True)412with pytest.raises(QualityThresholdError, match="theological accuracy"):413t._check_quality_gate(self._eval(95, {"theological_accuracy": MagicMock(score=60)}), _ref())414415416# --- prompts --------------------------------------------------------------------------417418419class TestPromptSourceHandling:420@pytest.fixture(autouse=True)421def _morphology_on(self, monkeypatch):422# Off by default (TRANSLATE_BIBLE_MORPH); the branches only run when on.423monkeypatch.setattr("src.pipeline.prompt.MORPHOLOGY_ENABLED", True)424425def test_morphology_is_empty_for_a_verse_without_word_data(self):426assert _morphology_notes_for(Verse(reference=_ref(), greek_text="λόγος")) == []427428def test_verse_prompt_includes_hebrew_morphology_and_themes(self):429verse = Verse(430reference=_ref("genesis", 1, 1),431hebrew_text="בְּרֵאשִׁית בָּרָא",432hebrew_words=[{"word": "בָּרָא", "morph": "HVqp3ms"}],433)434context = MagicMock(target_verse=verse, preceding_verses=[], following_verses=[],435chapter_summary=None, glossary_terms=[])436context.book_metadata = MagicMock(name_english="Genesis", name_japanese="創世記",437description="d", themes=["creation"])438prompt = PromptBuilder(GlossaryManager()).build_prompt(context)439assert "Hebrew: בְּרֵאשִׁית בָּרָא" in prompt440assert "MORPHOLOGY" in prompt and "qal perfect (qatal) 3ms" in prompt441assert "Themes: creation" in prompt442assert "Romanization" not in prompt # no transliteration supplied443444def test_book_context_without_themes_has_no_themes_line(self):445context = MagicMock(target_verse=Verse(reference=_ref(), greek_text="λόγος"),446preceding_verses=[], following_verses=[],447chapter_summary=None, glossary_terms=[])448context.book_metadata = MagicMock(name_english="John", name_japanese="ヨハネ",449description="d", themes=[])450prompt = PromptBuilder(GlossaryManager()).build_prompt(context)451assert "Book: John (ヨハネ) - d" in prompt and "Themes:" not in prompt452453def test_verse_with_no_greek_or_hebrew_shows_only_the_header(self):454builder = PromptBuilder(GlossaryManager())455assert builder._format_source_texts(Verse(reference=_ref())) == "SOURCE TEXT:"456457def 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.460verses = [Verse(reference=_ref("matthew", 17, 20), greek_text="ὁ δὲ λέγει"),461Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit this kind")]462context = MagicMock(verses=verses)463builder = PromptBuilder(GlossaryManager())464source = builder._format_chunk_source_texts(context)465assert "[17:20]" in source and "[17:21]" not in source466assert "Howbeit this kind" in builder._format_chunk_reference_translations(context)467468469# --- morphology -----------------------------------------------------------------------470471472class TestMorphologySelection:473def test_participle_with_unparsed_case_and_number(self):474(note,) = select_morphology_notes(475[{"word": "λέγων", "lemma": "λέγω", "pos": "V-", "parsing": "-PAP----"}], "greek"476)477assert note.endswith("present active participle")478479def test_marked_tense_outranks_plain_indicative_but_not_moods(self):480words = [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]486notes = select_morphology_notes(words, "greek")487assert [n.split(" (")[0] for n in notes] == ["ἀκούσατε", "ἦν", "λέγει"]488489def test_limit_caps_the_notes(self):490words = [{"word": f"w{i}", "lemma": "l", "pos": "V-", "parsing": "3PAI-S--"} for i in range(9)]491assert len(select_morphology_notes(words, "greek", limit=2)) == 2492493494# --- furigana -------------------------------------------------------------------------495496497class TestFuriganaBrackets:498def test_names_the_bracket_actually_left_open(self):499# The old count heuristic blamed position 1 here.500assert validate_furigana_text("漢[かん]字[じ") == ["Unmatched opening bracket at position 6"]501502def test_nested_then_unclosed_reports_both(self):503assert validate_furigana_text("神[かみは愛[あい]です") == [504"Nested bracket at position 6",505"Unmatched opening bracket at position 1",506]507508def test_stray_closer_with_a_later_unclosed_opener(self):509assert validate_furigana_text("神]は愛[あい") == [510"Unmatched closing bracket at position 1",511"Unmatched opening bracket at position 4",512]513514def test_kanji_repeat_mark_counts_as_kanji_base(self):515assert validate_furigana_text("人々[ひとびと]") == []516517def test_katakana_base_is_flagged(self):518(error,) = validate_furigana_text("イエス[いえす]")519assert "Non-kanji character 'ス'" in error520521522# --- retry ----------------------------------------------------------------------------523524525class TestRetryEdges:526def test_zero_attempts_never_calls_and_raises(self):527calls = []528529@retry_with_backoff(RetryConfig(max_attempts=0))530def work():531calls.append(1)532533with pytest.raises(TranslationError, match="All retry attempts failed"):534work()535assert calls == []536537def test_async_zero_attempts_never_calls_and_raises(self):538calls = []539540@async_retry_with_backoff(RetryConfig(max_attempts=0))541async def work():542calls.append(1)543544with pytest.raises(TranslationError, match="All retry attempts failed"):545asyncio.run(work())546assert calls == []547548549# --- path safety, versification, data loading ---------------------------------------550551552def test_path_guard_holds_even_if_normalisation_ever_passes_traversal(monkeypatch):553monkeypatch.setattr(validation, "normalize_book_name", lambda book: "../john")554with pytest.raises(InvalidReferenceError, match="invalid path characters"):555validation.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):569assert 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(575json.dumps({"verses": {"JHN 1:1": ["G1722"]}}), encoding="utf-8"576)577assert greek_strongs(tmp_path) == {"JHN 1:1": ["G1722"]}578579580def test_book_chapters_ignore_non_numeric_keys(tmp_path):581kjv = tmp_path / "sources" / "english" / "kjv"582kjv.mkdir(parents=True)583(kjv / "jude.json").write_text(json.dumps({"1:1": "Jude", "title": "x", "1:2": "To"}))584assert DataLoader(tmp_path).get_book_chapters("jude") == [1]585586587def test_v1_glossary_term_keeps_an_explicit_domain(tmp_path):588path = tmp_path / "glossary.json"589path.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)594assert term.domain.value == "soteriology"595596597# --- context building ---------------------------------------------------------------------598599600@pytest.fixture601def sparse_john(tmp_path) -> DataLoader:602greek = tmp_path / "sources" / "greek"603greek.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")610return DataLoader(tmp_path)611612613def _theos():614return GlossaryTerm(original="θεόν", transliteration="theon", japanese="神",615english="God", domain="theology_proper")616617618class TestContextSkipsEmptyNeighbours:619def test_verse_context_leaves_out_an_empty_preceding_verse(self, sparse_john):620builder = ContextBuilder(sparse_john, GlossaryManager(), context_window=2)621context = builder.build_context(_ref(verse=3))622assert [v.reference.verse for v in context.preceding_verses] == [2]623624def test_chunk_context_skips_empty_neighbours_and_dedupes_terms(self, sparse_john):625builder = ContextBuilder(sparse_john, GlossaryManager([_theos()]), context_window=2)626pericope = Pericope(id="p", start_chapter=1, start_verse=3, end_chapter=1,627end_verse=3, verses=["1:3"])628context = builder.build_chunk_context("john", pericope)629assert [v.reference.verse for v in context.preceding_verses] == [2]630assert [v.reference.verse for v in context.following_verses] == [4]631verses = [Verse(reference=_ref(verse=2), greek_text="πρὸς τὸν θεόν"),632Verse(reference=_ref(verse=9), greek_text="τὸν θεόν")]633assert [t.original for t in builder._find_chunk_terms(verses)] == ["θεόν"]634635def test_a_term_in_both_greek_and_hebrew_is_listed_once(self, sparse_john):636builder = ContextBuilder(sparse_john, GlossaryManager([_theos()]))637verse = Verse(reference=_ref(), greek_text="τὸν θεόν", hebrew_text="θεόν")638assert [t.original for t in builder._find_relevant_terms(verse)] == ["θεόν"]639640641class TestGlossaryPositions:642def test_positions_empty_without_terms_or_text(self):643assert GlossaryManager().find_terms_in_text_with_positions("λόγος") == []644assert GlossaryManager([_theos()]).find_terms_in_text_with_positions("") == []645646def test_positions_are_reported_for_every_occurrence(self):647manager = GlossaryManager([_theos()])648hits = manager.find_terms_in_text_with_positions("τὸν θεόν καὶ θεόν")649assert [(t.original, s, e) for t, s, e in hits] == [("θεόν", 4, 8), ("θεόν", 13, 17)]650651def 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.655term = GlossaryTerm(original="μαθητής", transliteration="mathētēs",656japanese="弟子", english="disciple", domain="general")657manager = GlossaryManager([term])658assert manager.find_terms_in_text_with_positions("ὁ \u00b5αθητής") == []659assert [t.original for t, *_ in manager.find_terms_in_text_with_positions("ὁ μαθητής")] == ["μαθητής"]660661def test_a_term_seen_twice_is_returned_once(self):662assert len(GlossaryManager([_theos()]).find_terms_in_text("θεόν θεόν ΘΕΌΝ")) == 1663664665class TestCrossReferences:666def _xref(self, source_verse):667return CrossReference(source_book="john", source_chapter=1, source_verse=source_verse,668target_book="genesis", target_chapter=1, target_verse_start=1)669670def test_two_sources_pointing_at_one_target_are_both_found(self, tmp_path):671manager = CrossReferenceManager(tmp_path)672manager.add_reference(self._xref(1))673manager.add_reference(self._xref(3))674found = manager.find_references_to_verse("genesis", 1, 1)675assert sorted(r.source_verse for r in found) == [1, 3]676677def test_save_to_an_explicit_path_and_reload(self, tmp_path):678manager = CrossReferenceManager(tmp_path)679manager.add_reference(self._xref(1))680target = tmp_path / "elsewhere.json"681manager.save(target)682assert "genesis" in target.read_text(encoding="utf-8")683684def test_defaults_are_not_seeded_over_existing_data(self, tmp_path):685(tmp_path / "metadata").mkdir()686existing = CrossReferenceManager(tmp_path)687existing.add_reference(self._xref(7))688existing.save()689manager = create_default_cross_references(tmp_path)690assert [r.source_verse for r in manager.find_references_to_verse("genesis", 1, 1)] == [7]691692693class TestPericopeData:694def test_statistics_ignore_a_book_with_no_pericopes(self, tmp_path):695path = tmp_path / "pericopes.json"696path.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")703stats = PericopeManager(path).get_statistics()704assert stats["book_names"] == ["titus"]705assert stats["total_pericopes"] == 1706707def test_generate_and_save_to_an_explicit_path(self, tmp_path):708greek = tmp_path / "sources" / "greek"709greek.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")713target = tmp_path / "out" / "generated.json"714target.parent.mkdir()715PericopeGenerator(tmp_path).generate_and_save("jude", output_path=target)716# Note: written as {book: ...}, not the {"books": ...} PericopeManager reads.717assert json.loads(target.read_text(encoding="utf-8"))["jude"]["auto_generated"] is True718