"""Tricky core behaviour found uncovered by the 2026-09 coverage audit. Memory never-clobber, resume/checkpoint state, chunk-response hygiene, furigana validation, morphology selection and retry edges. No model or network is called: subprocess and the Claude call are replaced, every store lives under tmp_path, and conftest's rail keeps the production memory unreachable. """ from __future__ import annotations import asyncio import json import threading from pathlib import Path from unittest.mock import MagicMock, patch import pytest from src.context.builder import ContextBuilder from src.context.cross_references import CrossReference, CrossReferenceManager, create_default_cross_references from src.context.glossary import GlossaryManager from src.context.pericope import PericopeManager from src.context.pericope_generator import PericopeGenerator from src.context.translation_memory import TranslationMemoryManager from src.data.loader import DataLoader from src.data.migrations import load_glossary_any_version from src.data.storage import Storage from src.data.versification import greek_strongs, hebrew_strongs from src.exceptions import InvalidReferenceError, QualityThresholdError, TranslationError from src.models.evaluation import VerseEvaluation from src.models.glossary import GlossaryTerm from src.models.pericope import ChunkTranslationContext, Pericope from src.models.translation_memory import MatchType, SegmentLevel, TMSegment from src.models.verse import TranslationOutput, Verse, VerseReference from src.orchestration.models import BookResult, JobStatus from src.orchestration.orchestrator import JobOrchestrator from src.orchestration.signals import GracefulShutdown from src.pipeline.morphology import select_morphology_notes from src.pipeline.prompt import PromptBuilder, _morphology_notes_for from src.pipeline.translator import Translator, _sanitize_translation_output from src.utils import validation from src.utils.furigana import validate_furigana_text from src.utils.retry import RetryConfig, async_retry_with_backoff, retry_with_backoff def _ref(book="john", chapter=1, verse=1): return VerseReference(book=book, chapter=chapter, verse=verse) # --- translation memory: never clobber --------------------------------------------- class TestMemoryNeverClobbered: def test_a_failed_write_leaves_the_store_and_no_temp_file(self, tmp_path, monkeypatch): path = tmp_path / "segments.json" first = TranslationMemoryManager(path) first.add_from_translation("kept", "保持", _ref()) first.save() before = path.read_bytes() second = TranslationMemoryManager(path) second.add_from_translation("new", "新", _ref(verse=2)) def torn_write(*args, **kwargs): raise OSError("disk full") monkeypatch.setattr("src.context.translation_memory.json.dump", torn_write) with pytest.raises(OSError, match="disk full"): second.save() assert path.read_bytes() == before assert not list(tmp_path.glob("*.tmp")) def test_valid_json_of_the_wrong_shape_is_quarantined_not_crashed_on(self, tmp_path): path = tmp_path / "segments.json" path.write_text("[1, 2, 3]", encoding="utf-8") manager = TranslationMemoryManager(path) manager.add_from_translation("after", "後", _ref()) manager.save() (quarantined,) = tmp_path.glob("segments.corrupt.*.json") assert quarantined.read_text(encoding="utf-8") == "[1, 2, 3]" assert len(TranslationMemoryManager(path)) == 1 def test_re_adding_a_segment_does_not_duplicate_its_reference_entry(self, tmp_path): manager = TranslationMemoryManager(tmp_path / "m.json") segment = manager.add_from_translation("same", "同じ", _ref()) manager.add_segment(segment) assert manager.get_by_reference(_ref()) == [segment] def test_remove_survives_a_stale_reference_index(self, tmp_path): manager = TranslationMemoryManager(tmp_path / "m.json") segment = manager.add_from_translation("gone", "消", _ref()) manager._by_reference.clear() # index drifted from the segment map assert manager.remove_segment(segment.id) is True assert manager.get_segment(segment.id) is None def test_phrase_level_near_match_is_labelled_phrase(self, tmp_path): manager = TranslationMemoryManager(tmp_path / "m.json") manager.add_from_translation( "the word was with god", "言葉は神と共に", _ref(), level=SegmentLevel.PHRASE ) (match,) = manager.find_matches("the word was with god always", threshold=0.7) assert match.match_type is MatchType.PHRASE def test_segments_written_by_another_worker_mid_run_are_kept(self, tmp_path): # Worker A loads, worker B saves, then A saves: B's segment survives. path = tmp_path / "segments.json" a = TranslationMemoryManager(path) a.load() b = TranslationMemoryManager(path) b.add_from_translation("from b", "B", _ref(verse=2)) b.save() a.add_from_translation("from a", "A", _ref(verse=1)) a.save() final = TranslationMemoryManager(path) assert {s.target_text for s in final.get_all_segments()} == {"A", "B"} # --- resume / checkpoint state --------------------------------------------------------- @pytest.fixture def orchestrator(tmp_path) -> JobOrchestrator: (tmp_path / "config").mkdir() return JobOrchestrator(data_dir=tmp_path, max_parallel_books=2) def _verse(chapter, verse, greek="λόγος"): return Verse(reference=_ref("titus", chapter, verse), greek_text=greek) class TestResumeKeepsWorkAlreadyOnDisk: def test_verses_already_on_disk_start_completed(self, orchestrator): job_id = orchestrator.create_job(books=["titus"]) loader = MagicMock() loader.get_book_chapters.return_value = [1] loader.load_chapter_verses.return_value = [_verse(1, 1), _verse(1, 2), _verse(1, 3)] storage = MagicMock() storage.get_progress.return_value = {"completed": {"1": [2]}} with patch("src.data.loader.DataLoader", return_value=loader), \ patch("src.data.storage.Storage", return_value=storage): orchestrator._initialize_all_books(job_id, ["titus"]) pending = orchestrator.state_store.get_pending_verses(job_id, "titus") assert sorted((v.chapter, v.verse) for v in pending) == [(1, 1), (1, 3)] book = orchestrator.state_store.get_book_job(job_id, "titus") assert book.total_verses == 3 def test_resume_requeues_previously_failed_verses(self, orchestrator): job_id = orchestrator.create_job(books=["titus"]) store = orchestrator.state_store store.initialize_verse_jobs(job_id, "titus", [(1, 1), (1, 2)]) store.update_verse_status(job_id, "titus", 1, 1, JobStatus.FAILED, error="Claude CLI failed:") store.update_job_status(job_id, JobStatus.FAILED) with patch.object(orchestrator, "run", return_value="ran") as run: assert orchestrator.resume(job_id) == "ran" run.assert_called_once_with(job_id) assert {(v.chapter, v.verse) for v in store.get_pending_verses(job_id, "titus")} == { (1, 1), (1, 2), } def test_resume_without_an_id_picks_the_latest_incomplete_job(self, orchestrator): job_id = orchestrator.create_job(books=["titus"]) orchestrator.state_store.initialize_verse_jobs(job_id, "titus", [(1, 1)]) with patch.object(orchestrator, "run", return_value="ran") as run: orchestrator.resume() run.assert_called_once_with(job_id) def test_in_progress_status_does_not_touch_book_counts(self, orchestrator): job_id = orchestrator.create_job(books=["titus"]) store = orchestrator.state_store store.initialize_verse_jobs(job_id, "titus", [(1, 1)]) store.update_verse_status(job_id, "titus", 1, 1, JobStatus.IN_PROGRESS) book = store.get_book_job(job_id, "titus") assert (book.completed_verses, book.failed_verses) == (0, 0) def test_marking_a_verse_translated_twice_is_idempotent(self, tmp_path): storage = Storage(tmp_path) storage._update_progress("john", 3, 16) storage._update_progress("john", 3, 16) storage._update_progress("john", 3, 2) assert storage.get_progress("john")["completed"]["3"] == [2, 16] @pytest.mark.xfail( strict=True, reason=( "Known and recorded in the run-bible-translation skill: a book " "counts as success however many verses failed, so a quota-shredded " "run is marked COMPLETED and `resume` finds nothing to do." ), ) def test_a_job_whose_verses_all_failed_is_not_a_success(self, orchestrator): job_id = orchestrator.create_job(books=["titus"]) shredded = [BookResult(book="titus", success=True, verses_translated=0, verses_failed=46, duration_seconds=1)] with patch.object(orchestrator, "_initialize_all_books"), \ patch.object(orchestrator, "_run_sequential", return_value=shredded): orchestrator.max_parallel_books = 1 assert orchestrator.run(job_id).success is False class TestParallelCollection: def _run(self, orchestrator, worker): job_id = orchestrator.create_job(books=["titus", "philemon"]) shutdown = GracefulShutdown() with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker): results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown) return sorted(results, key=lambda r: r.book), shutdown def test_each_book_result_is_collected(self, orchestrator): def worker(job_id, book, *rest): return BookResult(book=book, success=True, verses_translated=3, verses_failed=0, duration_seconds=0) results, _ = self._run(orchestrator, worker) assert [(r.book, r.verses_translated) for r in results] == [("philemon", 3), ("titus", 3)] def test_books_finishing_after_a_shutdown_are_reported_cancelled(self, orchestrator): started = threading.Barrier(2) def worker(job_id, book, *rest): started.wait(timeout=5) return BookResult(book=book, success=True, verses_translated=1, verses_failed=0, duration_seconds=0) job_id = orchestrator.create_job(books=["titus", "philemon"]) shutdown = GracefulShutdown() real_submit = __import__("concurrent.futures").futures.ThreadPoolExecutor.submit def submit_then_stop(self, fn, *args, **kwargs): future = real_submit(self, fn, *args, **kwargs) if args[1] == "philemon": shutdown.request_stop() return future with patch("src.orchestration.orchestrator._translate_book_worker", side_effect=worker), \ patch("concurrent.futures.ThreadPoolExecutor.submit", submit_then_stop): results = orchestrator._run_parallel(job_id, ["titus", "philemon"], MagicMock(), shutdown) assert {r.error_message for r in results} == {"Cancelled due to shutdown"} assert all(not r.success for r in results) def test_graceful_shutdown_off_the_main_thread_installs_no_handlers(): seen = {} def body(): with GracefulShutdown() as shutdown: seen["handlers"] = dict(shutdown._original_handlers) shutdown.request_stop() seen["stop"] = shutdown.should_stop thread = threading.Thread(target=body) thread.start() thread.join() assert seen == {"handlers": {}, "stop": True} # --- chunk responses ------------------------------------------------------------------- @pytest.fixture def translator(tmp_path) -> Translator: return Translator(MagicMock(), MagicMock(), Storage(tmp_path), auto_evaluate=False) class TestChunkResponseHygiene: def test_an_extra_following_verse_is_dropped_not_saved(self, translator): # The prompt shows following verses with their refs; a model that # translates one must not overwrite the next pericope's verse. reply = json.dumps({"1:1": "初めに", "1:2": "この方は", "1:3": "次の段落"}, ensure_ascii=False) assert translator._parse_chunk_response(reply, ["1:1", "1:2"]) == { "1:1": "初めに", "1:2": "この方は", } def test_a_non_verse_key_does_not_crash_the_save(self, translator, tmp_path): reply = json.dumps({"1:1": "初めに", "note": None}, ensure_ascii=False) translations = translator._parse_chunk_response(reply, ["1:1"]) translator._save_chunk_translations("john", translations) assert translator.storage.load_translation(_ref()).text == "初めに" def test_fenced_reply_with_prose_after_the_fence(self, translator): reply = '```json\n{"1:1": "初めに"}\n```\nI kept 言 as 言葉.' assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": "初めに"} def test_escaped_quotes_and_braces_inside_values(self, translator): reply = 'Here: {"1:1": "彼は\\"{光}\\"と言った"} done' assert translator._parse_chunk_response(reply, ["1:1"]) == {"1:1": '彼は"{光}"と言った'} class TestChunkResume: def _pericope(self): return Pericope(id="john-1", start_chapter=1, start_verse=1, end_chapter=1, end_verse=2, verses=["1:1", "1:2"]) def test_progress_says_translated_but_file_is_gone(self, translator): translator.storage = MagicMock() translator.storage.is_verse_translated.return_value = True translator.storage.load_translation.return_value = None assert translator._get_existing_chunk_translations("john", self._pericope()) == {} def test_seam_skips_a_preceding_verse_with_no_translation(self, translator, tmp_path): context = ChunkTranslationContext( pericope=self._pericope(), book="john", verses=[Verse(reference=_ref(verse=1), greek_text="Ἐν ἀρχῇ"), Verse(reference=_ref(verse=2), greek_text="οὗτος")], preceding_verses=[Verse(reference=_ref(verse=0 + 1), greek_text="x")], ) translator.context_builder.build_chunk_context.return_value = context translator.prompt_builder.build_chunk_prompt.return_value = "prompt" with patch.object(translator, "_call_claude", return_value='{"1:1": "初めに", "1:2": "この方は"}'): translator.translate_chunk("john", self._pericope(), skip_existing=False) kwargs = translator.prompt_builder.build_chunk_prompt.call_args.kwargs assert kwargs["preceding_translations"] is None @pytest.mark.xfail( strict=True, reason=( "A resume that requeues one verse of a pericope re-translates the " "whole pericope and overwrites its already-completed siblings, even " "with skip_existing=True. Whole-pericope coherence may be the intent; " "needs a decision, so it is pinned rather than changed." ), ) def test_partial_pericope_resume_keeps_already_translated_verses(self, translator): translator.storage.save_translation(TranslationOutput(reference=_ref(verse=1), text="既訳", model="m")) context = ChunkTranslationContext( pericope=self._pericope(), book="john", verses=[Verse(reference=_ref(verse=1), greek_text="a"), Verse(reference=_ref(verse=2), greek_text="b")], ) translator.context_builder.build_chunk_context.return_value = context translator.prompt_builder.build_chunk_prompt.return_value = "prompt" with patch.object(translator, "_call_claude", return_value='{"1:1": "新訳", "1:2": "二"}'): translator.translate_chunk("john", self._pericope(), skip_existing=True) assert translator.storage.load_translation(_ref(verse=1)).text == "既訳" def test_tm_lookup_is_skipped_when_the_first_verse_has_no_source(self, translator, tmp_path): translator.translation_memory = TranslationMemoryManager(tmp_path / "m.json") context = ChunkTranslationContext( pericope=self._pericope(), book="matthew", # e.g. a verse the critical text omits, opening the pericope verses=[Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit..."), Verse(reference=_ref("matthew", 17, 22), greek_text="Συστρεφομένων")], ) assert translator._get_chunk_tm_matches(context) is None class TestVerseMode: def test_progress_says_translated_but_file_is_gone_retranslates(self, translator): translator.storage = MagicMock() translator.storage.is_verse_translated.return_value = True translator.storage.load_translation.return_value = None translator.context_builder.build_context.side_effect = RuntimeError("rebuilt") with pytest.raises(RuntimeError, match="rebuilt"): translator.translate_verse(_ref()) def test_async_progress_says_translated_but_file_is_gone(self, translator): translator.storage = MagicMock() translator.storage.is_verse_translated.return_value = True translator.storage.load_translation.return_value = None translator.context_builder.build_context.side_effect = RuntimeError("rebuilt") with pytest.raises(RuntimeError, match="rebuilt"): asyncio.run(translator.translate_verse_async(_ref())) def test_async_chapter_saves_memory_once(self, translator, tmp_path): translator.translation_memory = MagicMock() translator.rate_limit_delay = 0 translator.context_builder.loader.load_chapter_verses.return_value = [ Verse(reference=_ref(verse=1)), Verse(reference=_ref(verse=2)), ] async def fake(ref, skip_existing): return TranslationOutput(reference=ref, text="訳", model="m") with patch.object(translator, "translate_verse_async", side_effect=fake): outputs = asyncio.run(translator.translate_chapter_async("john", 1)) assert [o.reference.verse for o in outputs] == [1, 2] translator.translation_memory.save.assert_called_once() def test_kougo_is_listed_as_a_source(self, translator): verse = Verse(reference=_ref(), japanese_kougo="初めに言があった。") context = MagicMock(target_verse=verse) assert translator._get_sources_used(context) == ["kougo"] def test_repeated_blank_lines_around_a_self_correction(self): raw = "\n最初の訳。\n\n\n \nWait, let me fix that.\n\n\n初めに言があった。\n" assert _sanitize_translation_output(raw) == "初めに言があった。" class TestQualityGate: def _eval(self, overall, scores): evaluation = MagicMock(spec=VerseEvaluation) evaluation.overall_score = overall evaluation.scores = scores return evaluation def test_passes_when_theological_accuracy_was_not_scored(self, tmp_path): t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True) t._check_quality_gate(self._eval(90, {}), _ref()) def test_passes_when_theological_accuracy_clears_its_threshold(self, tmp_path): t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True) t._check_quality_gate(self._eval(90, {"theological_accuracy": MagicMock(score=80)}), _ref()) def test_high_overall_does_not_mask_low_theological_accuracy(self, tmp_path): t = Translator(MagicMock(), MagicMock(), Storage(tmp_path), enforce_quality_gate=True) with pytest.raises(QualityThresholdError, match="theological accuracy"): t._check_quality_gate(self._eval(95, {"theological_accuracy": MagicMock(score=60)}), _ref()) # --- prompts -------------------------------------------------------------------------- class TestPromptSourceHandling: @pytest.fixture(autouse=True) def _morphology_on(self, monkeypatch): # Off by default (TRANSLATE_BIBLE_MORPH); the branches only run when on. monkeypatch.setattr("src.pipeline.prompt.MORPHOLOGY_ENABLED", True) def test_morphology_is_empty_for_a_verse_without_word_data(self): assert _morphology_notes_for(Verse(reference=_ref(), greek_text="λόγος")) == [] def test_verse_prompt_includes_hebrew_morphology_and_themes(self): verse = Verse( reference=_ref("genesis", 1, 1), hebrew_text="בְּרֵאשִׁית בָּרָא", hebrew_words=[{"word": "בָּרָא", "morph": "HVqp3ms"}], ) context = MagicMock(target_verse=verse, preceding_verses=[], following_verses=[], chapter_summary=None, glossary_terms=[]) context.book_metadata = MagicMock(name_english="Genesis", name_japanese="創世記", description="d", themes=["creation"]) prompt = PromptBuilder(GlossaryManager()).build_prompt(context) assert "Hebrew: בְּרֵאשִׁית בָּרָא" in prompt assert "MORPHOLOGY" in prompt and "qal perfect (qatal) 3ms" in prompt assert "Themes: creation" in prompt assert "Romanization" not in prompt # no transliteration supplied def test_book_context_without_themes_has_no_themes_line(self): context = MagicMock(target_verse=Verse(reference=_ref(), greek_text="λόγος"), preceding_verses=[], following_verses=[], chapter_summary=None, glossary_terms=[]) context.book_metadata = MagicMock(name_english="John", name_japanese="ヨハネ", description="d", themes=[]) prompt = PromptBuilder(GlossaryManager()).build_prompt(context) assert "Book: John (ヨハネ) - d" in prompt and "Themes:" not in prompt def test_verse_with_no_greek_or_hebrew_shows_only_the_header(self): builder = PromptBuilder(GlossaryManager()) assert builder._format_source_texts(Verse(reference=_ref())) == "SOURCE TEXT:" def test_chunk_source_omits_a_critical_text_gap_but_references_keep_it(self): # Matt 17:21 is absent from the critical Greek text; the model still # sees it through the reference translations. verses = [Verse(reference=_ref("matthew", 17, 20), greek_text="ὁ δὲ λέγει"), Verse(reference=_ref("matthew", 17, 21), english_kjv="Howbeit this kind")] context = MagicMock(verses=verses) builder = PromptBuilder(GlossaryManager()) source = builder._format_chunk_source_texts(context) assert "[17:20]" in source and "[17:21]" not in source assert "Howbeit this kind" in builder._format_chunk_reference_translations(context) # --- morphology ----------------------------------------------------------------------- class TestMorphologySelection: def test_participle_with_unparsed_case_and_number(self): (note,) = select_morphology_notes( [{"word": "λέγων", "lemma": "λέγω", "pos": "V-", "parsing": "-PAP----"}], "greek" ) assert note.endswith("present active participle") def test_marked_tense_outranks_plain_indicative_but_not_moods(self): words = [ {"word": "ἦν", "lemma": "εἰμί", "pos": "V-", "parsing": "3IAI-S--"}, # imperfect {"word": "λέγει", "lemma": "λέγω", "pos": "V-", "parsing": "3PAI-S--"}, # present {"word": "ἀκούσατε", "lemma": "ἀκούω", "pos": "V-", "parsing": "2AAD-P--"}, # imperative {"word": "λόγος", "lemma": "λόγος", "pos": "N-", "parsing": "----NSM-"}, # noun: skipped ] notes = select_morphology_notes(words, "greek") assert [n.split(" (")[0] for n in notes] == ["ἀκούσατε", "ἦν", "λέγει"] def test_limit_caps_the_notes(self): words = [{"word": f"w{i}", "lemma": "l", "pos": "V-", "parsing": "3PAI-S--"} for i in range(9)] assert len(select_morphology_notes(words, "greek", limit=2)) == 2 # --- furigana ------------------------------------------------------------------------- class TestFuriganaBrackets: def test_names_the_bracket_actually_left_open(self): # The old count heuristic blamed position 1 here. assert validate_furigana_text("漢[かん]字[じ") == ["Unmatched opening bracket at position 6"] def test_nested_then_unclosed_reports_both(self): assert validate_furigana_text("神[かみは愛[あい]です") == [ "Nested bracket at position 6", "Unmatched opening bracket at position 1", ] def test_stray_closer_with_a_later_unclosed_opener(self): assert validate_furigana_text("神]は愛[あい") == [ "Unmatched closing bracket at position 1", "Unmatched opening bracket at position 4", ] def test_kanji_repeat_mark_counts_as_kanji_base(self): assert validate_furigana_text("人々[ひとびと]") == [] def test_katakana_base_is_flagged(self): (error,) = validate_furigana_text("イエス[いえす]") assert "Non-kanji character 'ス'" in error # --- retry ---------------------------------------------------------------------------- class TestRetryEdges: def test_zero_attempts_never_calls_and_raises(self): calls = [] @retry_with_backoff(RetryConfig(max_attempts=0)) def work(): calls.append(1) with pytest.raises(TranslationError, match="All retry attempts failed"): work() assert calls == [] def test_async_zero_attempts_never_calls_and_raises(self): calls = [] @async_retry_with_backoff(RetryConfig(max_attempts=0)) async def work(): calls.append(1) with pytest.raises(TranslationError, match="All retry attempts failed"): asyncio.run(work()) assert calls == [] # --- path safety, versification, data loading --------------------------------------- def test_path_guard_holds_even_if_normalisation_ever_passes_traversal(monkeypatch): monkeypatch.setattr(validation, "normalize_book_name", lambda book: "../john") with pytest.raises(InvalidReferenceError, match="invalid path characters"): validation.sanitize_book_name_for_path("john") @pytest.mark.parametrize( "lemma,expected", [ ("c/1254 a", "H1254"), # prefix + homonym letter ("b/7225", "H7225"), # prefix only ("430", "H430"), ("d", ""), # article, no number (None, ""), ], ) def test_hebrew_strongs_takes_the_head_word(lemma, expected): assert hebrew_strongs(lemma) == expected def test_greek_strongs_reads_the_verses_map(tmp_path): (tmp_path / "sources").mkdir() (tmp_path / "sources" / "greek_strongs.json").write_text( json.dumps({"verses": {"JHN 1:1": ["G1722"]}}), encoding="utf-8" ) assert greek_strongs(tmp_path) == {"JHN 1:1": ["G1722"]} def test_book_chapters_ignore_non_numeric_keys(tmp_path): kjv = tmp_path / "sources" / "english" / "kjv" kjv.mkdir(parents=True) (kjv / "jude.json").write_text(json.dumps({"1:1": "Jude", "title": "x", "1:2": "To"})) assert DataLoader(tmp_path).get_book_chapters("jude") == [1] def test_v1_glossary_term_keeps_an_explicit_domain(tmp_path): path = tmp_path / "glossary.json" path.write_text(json.dumps({"version": "1.0", "terms": [{ "original": "χάρις", "transliteration": "charis", "japanese": "恵み", "english": "grace", "domain": "soteriology", }]}, ensure_ascii=False), encoding="utf-8") (term,) = load_glossary_any_version(path) assert term.domain.value == "soteriology" # --- context building --------------------------------------------------------------------- @pytest.fixture def sparse_john(tmp_path) -> DataLoader: greek = tmp_path / "sources" / "greek" greek.mkdir(parents=True) # 1:1 and 1:5 have no text at all (a gap in the source data). (greek / "john.json").write_text(json.dumps({ "1:2": {"text": "οὗτος ἦν ἐν ἀρχῇ πρὸς τὸν θεόν"}, "1:3": {"text": "πάντα δι᾽ αὐτοῦ ἐγένετο"}, "1:4": {"text": "ἐν αὐτῷ ζωὴ ἦν"}, }, ensure_ascii=False), encoding="utf-8") return DataLoader(tmp_path) def _theos(): return GlossaryTerm(original="θεόν", transliteration="theon", japanese="神", english="God", domain="theology_proper") class TestContextSkipsEmptyNeighbours: def test_verse_context_leaves_out_an_empty_preceding_verse(self, sparse_john): builder = ContextBuilder(sparse_john, GlossaryManager(), context_window=2) context = builder.build_context(_ref(verse=3)) assert [v.reference.verse for v in context.preceding_verses] == [2] def test_chunk_context_skips_empty_neighbours_and_dedupes_terms(self, sparse_john): builder = ContextBuilder(sparse_john, GlossaryManager([_theos()]), context_window=2) pericope = Pericope(id="p", start_chapter=1, start_verse=3, end_chapter=1, end_verse=3, verses=["1:3"]) context = builder.build_chunk_context("john", pericope) assert [v.reference.verse for v in context.preceding_verses] == [2] assert [v.reference.verse for v in context.following_verses] == [4] verses = [Verse(reference=_ref(verse=2), greek_text="πρὸς τὸν θεόν"), Verse(reference=_ref(verse=9), greek_text="τὸν θεόν")] assert [t.original for t in builder._find_chunk_terms(verses)] == ["θεόν"] def test_a_term_in_both_greek_and_hebrew_is_listed_once(self, sparse_john): builder = ContextBuilder(sparse_john, GlossaryManager([_theos()])) verse = Verse(reference=_ref(), greek_text="τὸν θεόν", hebrew_text="θεόν") assert [t.original for t in builder._find_relevant_terms(verse)] == ["θεόν"] class TestGlossaryPositions: def test_positions_empty_without_terms_or_text(self): assert GlossaryManager().find_terms_in_text_with_positions("λόγος") == [] assert GlossaryManager([_theos()]).find_terms_in_text_with_positions("") == [] def test_positions_are_reported_for_every_occurrence(self): manager = GlossaryManager([_theos()]) hits = manager.find_terms_in_text_with_positions("τὸν θεόν καὶ θεόν") assert [(t.original, s, e) for t, s, e in hits] == [("θεόν", 4, 8), ("θεόν", 13, 17)] def test_a_micro_sign_lookalike_is_skipped_not_crashed_on(self): # U+00B5 (micro sign, a common mis-encoding of Greek mu) matches the # IGNORECASE regex but .lower() keeps it distinct from the index key, # so the hit is dropped. Source data must be NFKC-clean for μ terms. term = GlossaryTerm(original="μαθητής", transliteration="mathētēs", japanese="弟子", english="disciple", domain="general") manager = GlossaryManager([term]) assert manager.find_terms_in_text_with_positions("ὁ \u00b5αθητής") == [] assert [t.original for t, *_ in manager.find_terms_in_text_with_positions("ὁ μαθητής")] == ["μαθητής"] def test_a_term_seen_twice_is_returned_once(self): assert len(GlossaryManager([_theos()]).find_terms_in_text("θεόν θεόν ΘΕΌΝ")) == 1 class TestCrossReferences: def _xref(self, source_verse): return CrossReference(source_book="john", source_chapter=1, source_verse=source_verse, target_book="genesis", target_chapter=1, target_verse_start=1) def test_two_sources_pointing_at_one_target_are_both_found(self, tmp_path): manager = CrossReferenceManager(tmp_path) manager.add_reference(self._xref(1)) manager.add_reference(self._xref(3)) found = manager.find_references_to_verse("genesis", 1, 1) assert sorted(r.source_verse for r in found) == [1, 3] def test_save_to_an_explicit_path_and_reload(self, tmp_path): manager = CrossReferenceManager(tmp_path) manager.add_reference(self._xref(1)) target = tmp_path / "elsewhere.json" manager.save(target) assert "genesis" in target.read_text(encoding="utf-8") def test_defaults_are_not_seeded_over_existing_data(self, tmp_path): (tmp_path / "metadata").mkdir() existing = CrossReferenceManager(tmp_path) existing.add_reference(self._xref(7)) existing.save() manager = create_default_cross_references(tmp_path) assert [r.source_verse for r in manager.find_references_to_verse("genesis", 1, 1)] == [7] class TestPericopeData: def test_statistics_ignore_a_book_with_no_pericopes(self, tmp_path): path = tmp_path / "pericopes.json" path.write_text(json.dumps({"books": { "jude": {"name": "Jude", "pericopes": []}, "titus": {"name": "Titus", "pericopes": [ {"id": "t1", "start_chapter": 1, "start_verse": 1, "end_chapter": 1, "end_verse": 4, "verses": ["1:1", "1:2", "1:3", "1:4"]}, ]}, }}), encoding="utf-8") stats = PericopeManager(path).get_statistics() assert stats["book_names"] == ["titus"] assert stats["total_pericopes"] == 1 def test_generate_and_save_to_an_explicit_path(self, tmp_path): greek = tmp_path / "sources" / "greek" greek.mkdir(parents=True) (greek / "jude.json").write_text(json.dumps({ "1:1": {"text": "¶ Ἰούδας"}, "1:2": {"text": "ἔλεος"}, "1:3": {"text": "¶ Ἀγαπητοί"}, }, ensure_ascii=False), encoding="utf-8") target = tmp_path / "out" / "generated.json" target.parent.mkdir() PericopeGenerator(tmp_path).generate_and_save("jude", output_path=target) # Note: written as {book: ...}, not the {"books": ...} PericopeManager reads. assert json.loads(target.read_text(encoding="utf-8"))["jude"]["auto_generated"] is True