OPEN SOURCE
software/tests/test_orchestration_extended.py
Read on this site and download the files you need.
Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.
← Back to the open-source guide
1"""Extended tests for orchestration coverage."""23from __future__ import annotations45import tempfile6import threading7import time8from datetime import datetime9from pathlib import Path10from unittest.mock import MagicMock, patch1112import pytest1314from src.orchestration.models import JobStatus15from src.orchestration.orchestrator import (16JobOrchestrator,17_translate_book_worker,18_translate_verse_with_provenance,19)20from src.orchestration.provenance import TranslationProvenance21from src.orchestration.rate_limiter import DummyRateLimiter, SharedRateLimiter22from src.orchestration.signals import GracefulShutdown, ShutdownCallback23from src.orchestration.state import JobStateStore242526@pytest.fixture27def temp_data_dir() -> Path:28"""Create a temporary data directory with full structure."""29with tempfile.TemporaryDirectory() as tmpdir:30data_dir = Path(tmpdir)31# Create required subdirectories32(data_dir / "sources" / "greek").mkdir(parents=True)33(data_dir / "sources" / "hebrew").mkdir(parents=True)34(data_dir / "output").mkdir(parents=True)35(data_dir / "progress").mkdir(parents=True)36(data_dir / "memory").mkdir(parents=True)37(data_dir / "metadata").mkdir(parents=True)38(data_dir / "config").mkdir(parents=True)39(data_dir / "jobs").mkdir(parents=True)40(data_dir / "evaluations").mkdir(parents=True)4142# Create minimal config43import json44with (data_dir / "config" / "style.json").open("w") as f:45json.dump({46"equivalence_approach": "optimal",47"readability_level": "general_public",48"gender_language": "traditional",49"theological_tradition": "ecumenical",50"term_consistency": "balanced",51"formality_level": "standard",52"avoid_archaic": [],53"textual_basis": "critical",54"target_style_reference": "",55"additional_instructions": "",56}, f)5758# Create empty TM59with (data_dir / "memory" / "segments.json").open("w") as f:60json.dump({"segments": []}, f)6162# Create empty pericopes63with (data_dir / "metadata" / "pericopes.json").open("w") as f:64json.dump({}, f)6566# Create glossary67with (data_dir / "sources" / "glossary.json").open("w") as f:68json.dump([], f)6970yield data_dir717273class TestStateStoreEdgeCases:74"""Tests for state store edge cases."""7576def test_update_job_verse_counts_empty(self) -> None:77"""Test update_job_verse_counts with no updates."""78with tempfile.TemporaryDirectory() as tmpdir:79store = JobStateStore(Path(tmpdir) / "test.db")80job = store.create_bible_job(books=["john"], config={})8182# Should not raise83store.update_job_verse_counts(job.job_id)8485# Verify nothing changed86updated = store.get_bible_job(job.job_id)87assert updated is not None88assert updated.total_verses == 08990def test_update_book_verse_counts_empty(self) -> None:91"""Test update_book_verse_counts with no updates."""92with tempfile.TemporaryDirectory() as tmpdir:93store = JobStateStore(Path(tmpdir) / "test.db")94job = store.create_bible_job(books=["john"], config={})9596# Should not raise97store.update_book_verse_counts(job.job_id, "john")9899# Verify nothing changed100book_job = store.get_book_job(job.job_id, "john")101assert book_job is not None102assert book_job.total_verses == 0103104def test_get_book_progress_nonexistent(self) -> None:105"""Test get_book_progress for non-existent book."""106with tempfile.TemporaryDirectory() as tmpdir:107store = JobStateStore(Path(tmpdir) / "test.db")108job = store.create_bible_job(books=["john"], config={})109110progress = store.get_book_progress(job.job_id, "nonexistent")111assert progress == {}112113114class TestShutdownCallbackException:115"""Tests for shutdown callback exception handling."""116117def test_callback_raises_exception(self) -> None:118"""Test callback that raises exception."""119shutdown = GracefulShutdown()120callbacks = ShutdownCallback(shutdown)121122def bad_callback() -> None:123raise RuntimeError("Test error")124125callbacks.register(bad_callback)126exceptions = callbacks.run_all()127128assert len(exceptions) == 1129assert isinstance(exceptions[0], RuntimeError)130131132class TestRateLimiterEdgeCases:133"""Tests for rate limiter edge cases."""134135def test_shared_rate_limiter_shutdown_error(self) -> None:136"""Test that shutdown handles manager errors gracefully."""137limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)138139# Shutdown multiple times should not raise140limiter.shutdown()141limiter.shutdown()142143144class TestOrchestratorRunMocked:145"""Tests for orchestrator run with mocked components."""146147def test_run_job_with_no_verses(self, temp_data_dir: Path) -> None:148"""Test running a job where books have no verses."""149orchestrator = JobOrchestrator(150data_dir=temp_data_dir,151max_parallel_books=1,152)153154# Create job for a book that has no source data155job_id = orchestrator.create_job(books=["philemon"])156157# Mock DataLoader to return no chapters158with patch("src.data.loader.DataLoader") as mock_loader:159mock_instance = MagicMock()160mock_instance.get_book_chapters.return_value = []161mock_loader.return_value = mock_instance162163result = orchestrator.run(job_id)164165# Job should complete (with no verses translated)166assert result.total_verses_translated == 0167168def test_run_sequential_mode(self, temp_data_dir: Path) -> None:169"""Test running in sequential mode (1 worker)."""170orchestrator = JobOrchestrator(171data_dir=temp_data_dir,172max_parallel_books=1,173)174175job_id = orchestrator.create_job(books=["titus"])176177# Mock to skip actual translation178with patch.object(orchestrator, "_initialize_all_books"):179with patch.object(orchestrator, "_run_sequential") as mock_seq:180mock_seq.return_value = []181orchestrator.run(job_id)182183mock_seq.assert_called_once()184185def test_run_parallel_mode(self, temp_data_dir: Path) -> None:186"""Test running in parallel mode (>1 workers)."""187orchestrator = JobOrchestrator(188data_dir=temp_data_dir,189max_parallel_books=2,190)191192job_id = orchestrator.create_job(books=["titus", "philemon"])193194# Mock to skip actual translation195with patch.object(orchestrator, "_initialize_all_books"):196with patch.object(orchestrator, "_run_parallel") as mock_par:197mock_par.return_value = []198orchestrator.run(job_id)199200mock_par.assert_called_once()201202203class TestOrchestratorRunWithShutdown:204"""Tests for orchestrator with shutdown handling."""205206def test_run_with_immediate_shutdown(self, temp_data_dir: Path) -> None:207"""Test that shutdown is handled during run."""208orchestrator = JobOrchestrator(209data_dir=temp_data_dir,210max_parallel_books=1,211)212213job_id = orchestrator.create_job(books=["titus"])214215# Mock _run_sequential to signal shutdown216def mock_run_with_shutdown(job_id, books, rate_limiter, shutdown):217shutdown.request_stop()218return []219220with patch.object(orchestrator, "_initialize_all_books"):221with patch.object(222orchestrator, "_run_sequential", side_effect=mock_run_with_shutdown223):224result = orchestrator.run(job_id)225226# Should complete without error (just no verses)227assert result is not None228229230class TestBookWorker:231"""Tests for book worker function."""232233def test_worker_with_empty_pending(self, temp_data_dir: Path) -> None:234"""Test worker when no pending verses."""235state_store = JobStateStore(temp_data_dir / "jobs" / "test.db")236job = state_store.create_bible_job(books=["titus"], config={})237238# Initialize with no verses239state_store.initialize_verse_jobs(job.job_id, "titus", [])240241# Create dummy rate limiter242limiter = DummyRateLimiter(delay=0.01)243244# Mock all the translation components245with patch("src.data.loader.DataLoader") as mock_loader:246with patch("src.data.storage.Storage"):247with patch("src.config.style_config.StyleConfigManager"):248with patch("src.context.glossary.GlossaryManager"):249with patch("src.context.translation_memory.TranslationMemoryManager"):250with patch("src.context.pericope.PericopeManager"):251with patch("src.context.builder.ContextBuilder"):252with patch("src.pipeline.prompt.PromptBuilder"):253with patch("src.evaluation.evaluator.TranslationEvaluator"):254with patch("src.evaluation.storage.EvaluationStorage"):255with patch("src.pipeline.translator.Translator"):256mock_loader_instance = MagicMock()257mock_loader_instance.load_glossary.return_value = []258mock_loader.return_value = mock_loader_instance259260result = _translate_book_worker(261job.job_id,262"titus",263str(temp_data_dir),264str(state_store.db_path),265limiter,266)267268assert result.book == "titus"269assert result.verses_translated == 0270271272class TestProvenanceExtended:273"""Extended tests for provenance tracking."""274275def test_provenance_with_evaluation(self) -> None:276"""Test provenance with evaluation data."""277prov = TranslationProvenance(278evaluation_score=85.5,279evaluation_flags=["consistency_warning"],280)281282data = prov.to_dict()283restored = TranslationProvenance.from_dict(data)284285assert restored.evaluation_score == 85.5286assert restored.evaluation_flags == ["consistency_warning"]287288def test_provenance_with_book_metadata(self) -> None:289"""Test provenance with book metadata."""290prov = TranslationProvenance(291book_metadata={292"name": "John",293"testament": "NT",294"themes": ["love", "belief"],295}296)297298summary = prov.get_inputs_summary()299assert summary["pericope"] is False300301def test_provenance_no_pericope(self) -> None:302"""Test get_inputs_summary with no pericope."""303prov = TranslationProvenance(pericope_id=None)304305summary = prov.get_inputs_summary()306assert summary["pericope"] is False307308309class TestSignalsExtended:310"""Extended tests for signal handling."""311312def test_signal_handler_idempotent(self) -> None:313"""Test that signal handler is idempotent."""314shutdown = GracefulShutdown()315316# Simulate signal received twice317shutdown._signal_handler(2, None) # SIGINT318shutdown._signal_handler(15, None) # SIGTERM319320# Should still only have first signal321assert shutdown.should_stop is True322assert shutdown.signal_received == 2 # First signal323324def test_context_manager_exception(self) -> None:325"""Test context manager with exception inside."""326shutdown = GracefulShutdown()327328with pytest.raises(ValueError):329with shutdown:330raise ValueError("Test error")331332# Handlers should be restored even with exception333assert not shutdown.should_stop or shutdown.should_stop334335336class TestOrchestratorResume:337"""Tests for resume functionality."""338339def test_resume_resets_orphaned(self, temp_data_dir: Path) -> None:340"""Test that resume resets orphaned verses."""341orchestrator = JobOrchestrator(342data_dir=temp_data_dir,343max_parallel_books=1,344)345346# Create job and initialize347job_id = orchestrator.create_job(books=["titus"])348orchestrator.state_store.initialize_verse_jobs(349job_id, "titus", [(1, 1), (1, 2)]350)351352# Claim verse (simulating in-progress from crashed worker)353orchestrator.state_store.atomic_claim_verse(354job_id, "titus", 1, 1, worker_pid=99999355)356357# Resume should reset orphaned358with patch.object(orchestrator, "run") as mock_run:359mock_run.return_value = MagicMock(360success=True,361books_completed=1,362books_failed=0,363)364orchestrator.resume(job_id)365366# Verify orphaned was reset before run367pending = orchestrator.state_store.get_pending_verses(job_id, "titus")368# The run mock doesn't actually translate, so both should be pending369assert len(pending) == 2370371372class TestStateStoreConnection:373"""Tests for state store connection handling."""374375def test_connection_context_manager_commits(self) -> None:376"""Test that connection context manager commits on success."""377with tempfile.TemporaryDirectory() as tmpdir:378store = JobStateStore(Path(tmpdir) / "test.db")379380# Create job (uses connection context manager)381job = store.create_bible_job(books=["john"], config={})382383# Verify committed by loading384loaded = store.get_bible_job(job.job_id)385assert loaded is not None386387def test_connection_context_manager_rollback(self) -> None:388"""Test that connection context manager rolls back on error."""389with tempfile.TemporaryDirectory() as tmpdir:390store = JobStateStore(Path(tmpdir) / "test.db")391392# Try to create job with invalid data (should fail at some point)393# This tests the rollback path394with pytest.raises(Exception):395with store._connection() as conn:396conn.execute("INSERT INTO nonexistent_table VALUES (1)")397398399class TestInitializeBooks:400"""Tests for book initialization."""401402def test_initialize_skips_already_initialized(403self, temp_data_dir: Path404) -> None:405"""Test that initialization skips already initialized books."""406orchestrator = JobOrchestrator(407data_dir=temp_data_dir,408max_parallel_books=1,409)410411job_id = orchestrator.create_job(books=["titus"])412413# Pre-initialize with verses414orchestrator.state_store.initialize_verse_jobs(415job_id, "titus", [(1, 1), (1, 2)]416)417418# Mock DataLoader419with patch("src.data.loader.DataLoader") as mock_loader:420mock_instance = MagicMock()421mock_loader.return_value = mock_instance422423# This should skip titus since it's already initialized424orchestrator._initialize_all_books(job_id, ["titus"])425426# get_book_chapters should not be called since book is initialized427# (it has total_verses > 0)428429430class TestJobProgressTracking:431"""Tests for progress tracking during translation."""432433def test_verse_status_updates_book_counts(self) -> None:434"""Test that verse status updates also update book counts."""435with tempfile.TemporaryDirectory() as tmpdir:436store = JobStateStore(Path(tmpdir) / "test.db")437job = store.create_bible_job(books=["john"], config={})438store.initialize_verse_jobs(job.job_id, "john", [(1, 1), (1, 2)])439440# Claim and complete first verse441store.atomic_claim_verse(job.job_id, "john", 1, 1, 12345)442store.update_verse_status(443job.job_id, "john", 1, 1, JobStatus.COMPLETED444)445446book_job = store.get_book_job(job.job_id, "john")447assert book_job is not None448assert book_job.completed_verses == 1449450# Claim and fail second verse451store.atomic_claim_verse(job.job_id, "john", 1, 2, 12345)452store.update_verse_status(453job.job_id, "john", 1, 2, JobStatus.FAILED, error="Test"454)455456book_job = store.get_book_job(job.job_id, "john")457assert book_job is not None458assert book_job.failed_verses == 1459460461class TestOrchestratorParallelRun:462"""Tests for parallel book translation."""463464def test_run_parallel_with_shutdown(self, temp_data_dir: Path) -> None:465"""Test parallel run handles shutdown."""466orchestrator = JobOrchestrator(467data_dir=temp_data_dir,468max_parallel_books=2,469)470471job_id = orchestrator.create_job(books=["titus", "philemon"])472473# Mock _run_parallel to return empty results474def mock_parallel(job_id, books, rate_limiter, shutdown):475from src.orchestration.models import BookResult476return [477BookResult(478book=book,479success=True,480verses_translated=0,481verses_failed=0,482duration_seconds=0.1,483)484for book in books485]486487with patch.object(orchestrator, "_initialize_all_books"):488with patch.object(orchestrator, "_run_parallel", side_effect=mock_parallel):489result = orchestrator.run(job_id)490491assert result.success is True492assert result.books_completed == 2493494495class TestOrchestratorSequentialRun:496"""Tests for sequential book translation."""497498def test_run_sequential_with_book_results(self, temp_data_dir: Path) -> None:499"""Test sequential run with mixed results."""500orchestrator = JobOrchestrator(501data_dir=temp_data_dir,502max_parallel_books=1,503)504505job_id = orchestrator.create_job(books=["titus", "philemon"])506507# Mock _run_sequential to return mixed results508def mock_sequential(job_id, books, rate_limiter, shutdown):509from src.orchestration.models import BookResult510return [511BookResult(512book="titus",513success=True,514verses_translated=46,515verses_failed=0,516duration_seconds=10.0,517),518BookResult(519book="philemon",520success=False,521verses_translated=10,522verses_failed=15,523duration_seconds=5.0,524error_message="Test error",525),526]527528with patch.object(orchestrator, "_initialize_all_books"):529with patch.object(orchestrator, "_run_sequential", side_effect=mock_sequential):530result = orchestrator.run(job_id)531532assert result.success is False533assert result.books_completed == 1534assert result.books_failed == 1535assert result.total_verses_translated == 56536assert result.total_verses_failed == 15537538539class TestStateStoreMoreEdgeCases:540"""Additional state store edge case tests."""541542def test_update_book_status_with_all_fields(self) -> None:543"""Test update_book_status with all optional fields."""544with tempfile.TemporaryDirectory() as tmpdir:545store = JobStateStore(Path(tmpdir) / "test.db")546job = store.create_bible_job(books=["john"], config={})547548now = datetime.utcnow()549store.update_book_status(550job.job_id,551"john",552JobStatus.FAILED,553worker_pid=12345,554started_at=now,555completed_at=now,556error_message="Test error",557)558559book_job = store.get_book_job(job.job_id, "john")560assert book_job is not None561assert book_job.status == JobStatus.FAILED562assert book_job.worker_pid == 12345563assert book_job.error_message == "Test error"564565def test_update_job_status_with_completed(self) -> None:566"""Test update_job_status with completed_at."""567with tempfile.TemporaryDirectory() as tmpdir:568store = JobStateStore(Path(tmpdir) / "test.db")569job = store.create_bible_job(books=["john"], config={})570571now = datetime.utcnow()572store.update_job_status(573job.job_id,574JobStatus.COMPLETED,575started_at=now,576completed_at=now,577)578579updated = store.get_bible_job(job.job_id)580assert updated is not None581assert updated.status == JobStatus.COMPLETED582assert updated.completed_at is not None583584585class TestOrchestratorConfigSnapshot:586"""Tests for configuration snapshot in orchestrator."""587588def test_snapshot_without_style_config(self, temp_data_dir: Path) -> None:589"""Test config snapshot when style.json doesn't exist."""590# Remove style.json591style_path = temp_data_dir / "config" / "style.json"592if style_path.exists():593style_path.unlink()594595orchestrator = JobOrchestrator(data_dir=temp_data_dir)596597config = orchestrator._snapshot_config()598599# Should still have model600assert "model" in config601602603class TestRunSequentialShutdown:604"""Tests for sequential run with shutdown."""605606def test_sequential_books_cancelled_on_shutdown(607self, temp_data_dir: Path608) -> None:609"""Test that remaining books are cancelled when shutdown is requested."""610orchestrator = JobOrchestrator(611data_dir=temp_data_dir,612max_parallel_books=1,613)614615job_id = orchestrator.create_job(books=["titus", "philemon", "jude"])616617# Create shutdown that's already signaled618shutdown = GracefulShutdown()619shutdown.request_stop()620621# Create limiter622limiter = DummyRateLimiter(delay=0.01)623624# Run sequential (should immediately return cancelled for all)625results = orchestrator._run_sequential(626job_id, ["titus", "philemon", "jude"], limiter, shutdown627)628629# All should be cancelled630assert len(results) == 3631for result in results:632assert result.success is False633assert "Cancelled" in result.error_message634635636class TestRunParallelShutdown:637"""Tests for parallel run with shutdown."""638639def test_parallel_shutdown_during_wait(self, temp_data_dir: Path) -> None:640"""Test parallel run when shutdown occurs while waiting."""641orchestrator = JobOrchestrator(642data_dir=temp_data_dir,643max_parallel_books=2,644)645646job_id = orchestrator.create_job(books=["titus", "philemon"])647648# Create shutdown649shutdown = GracefulShutdown()650651# Create limiter652limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)653654try:655# Signal shutdown before running656shutdown.request_stop()657658results = orchestrator._run_parallel(659job_id, ["titus", "philemon"], limiter, shutdown660)661662# Books should be cancelled663for result in results:664assert result.success is False665finally:666limiter.shutdown()667668669class TestBookWorkerException:670"""Tests for book worker exception handling."""671672def test_worker_handles_general_exception(self, temp_data_dir: Path) -> None:673"""Test that worker handles general exceptions gracefully."""674state_store = JobStateStore(temp_data_dir / "jobs" / "test.db")675job = state_store.create_bible_job(books=["titus"], config={})676677limiter = DummyRateLimiter(delay=0.01)678679# Mock to raise exception680with patch("src.data.loader.DataLoader") as mock_loader:681mock_loader.side_effect = RuntimeError("Test error")682683result = _translate_book_worker(684job.job_id,685"titus",686str(temp_data_dir),687str(state_store.db_path),688limiter,689)690691assert result.success is False692assert "Test error" in result.error_message693694695class TestRunParallelExecution:696"""Tests for parallel execution details."""697698def test_run_parallel_future_exception(self, temp_data_dir: Path) -> None:699"""Test parallel run when a future raises exception."""700orchestrator = JobOrchestrator(701data_dir=temp_data_dir,702max_parallel_books=2,703)704705job_id = orchestrator.create_job(books=["titus"])706shutdown = GracefulShutdown()707708# Create limiter709limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)710711try:712# Mock _translate_book_worker to raise an exception713with patch(714"src.orchestration.orchestrator._translate_book_worker",715side_effect=RuntimeError("Worker error"),716):717results = orchestrator._run_parallel(718job_id, ["titus"], limiter, shutdown719)720721# Should have one failed result722assert len(results) == 1723assert results[0].success is False724assert "Worker error" in results[0].error_message725finally:726limiter.shutdown()727728729class TestGetStatusDetails:730"""Tests for job status retrieval details."""731732def test_get_status_with_started_job(self, temp_data_dir: Path) -> None:733"""Test getting status of a started job."""734orchestrator = JobOrchestrator(data_dir=temp_data_dir)735736job_id = orchestrator.create_job(books=["titus", "philemon"])737738# Update to in_progress with started_at739now = datetime.utcnow()740orchestrator.state_store.update_job_status(741job_id, JobStatus.IN_PROGRESS, started_at=now742)743744status = orchestrator.get_status(job_id)745746assert status["status"] == "in_progress"747assert status["started_at"] is not None748assert status["completed_at"] is None749750def test_get_status_with_completed_job(self, temp_data_dir: Path) -> None:751"""Test getting status of a completed job."""752orchestrator = JobOrchestrator(data_dir=temp_data_dir)753754job_id = orchestrator.create_job(books=["titus"])755756# Update to completed757now = datetime.utcnow()758orchestrator.state_store.update_job_status(759job_id, JobStatus.COMPLETED, started_at=now, completed_at=now760)761762status = orchestrator.get_status(job_id)763764assert status["status"] == "completed"765assert status["completed_at"] is not None766767def test_get_status_book_progress(self, temp_data_dir: Path) -> None:768"""Test getting status shows book progress."""769orchestrator = JobOrchestrator(data_dir=temp_data_dir)770771job_id = orchestrator.create_job(books=["titus"])772773# Initialize and complete some verses774orchestrator.state_store.initialize_verse_jobs(775job_id, "titus", [(1, 1), (1, 2), (1, 3)]776)777orchestrator.state_store.atomic_claim_verse(job_id, "titus", 1, 1, 12345)778orchestrator.state_store.update_verse_status(779job_id, "titus", 1, 1, JobStatus.COMPLETED780)781782status = orchestrator.get_status(job_id)783784book_status = status["books"][0]785assert book_status["completed_verses"] == 1786assert book_status["total_verses"] == 3787788789class TestCreateJobAllBooks:790"""Tests for creating jobs with all books."""791792def test_create_job_default_all_books(self, temp_data_dir: Path) -> None:793"""Test that default creates all 66 books."""794orchestrator = JobOrchestrator(data_dir=temp_data_dir)795796# Don't pass any book specification797job_id = orchestrator.create_job()798799job = orchestrator.state_store.get_bible_job(job_id)800assert job is not None801assert len(job.books) == 66802803def test_create_job_nt_only_branch(self, temp_data_dir: Path) -> None:804"""Test nt_only branch in create_job."""805orchestrator = JobOrchestrator(data_dir=temp_data_dir)806807# Use nt_only flag808job_id = orchestrator.create_job(nt_only=True)809810job = orchestrator.state_store.get_bible_job(job_id)811assert job is not None812assert len(job.books) == 27813assert "matthew" in job.books814815def test_create_job_ot_only_branch(self, temp_data_dir: Path) -> None:816"""Test ot_only branch in create_job."""817orchestrator = JobOrchestrator(data_dir=temp_data_dir)818819# Use ot_only flag820job_id = orchestrator.create_job(ot_only=True)821822job = orchestrator.state_store.get_bible_job(job_id)823assert job is not None824assert len(job.books) == 39825assert "genesis" in job.books826827828class TestInitializeAllBooksDetails:829"""Tests for book initialization details."""830831def test_initialize_logs_warning_for_no_verses(832self, temp_data_dir: Path833) -> None:834"""Test that initialization logs warning when book has no verses."""835orchestrator = JobOrchestrator(data_dir=temp_data_dir)836job_id = orchestrator.create_job(books=["titus"])837838# Mock loader to return no chapters839with patch("src.data.loader.DataLoader") as mock_loader:840mock_instance = MagicMock()841mock_instance.get_book_chapters.return_value = []842mock_loader.return_value = mock_instance843844# Should not raise, just log warning845orchestrator._initialize_all_books(job_id, ["titus"])846847def test_initialize_with_verses(self, temp_data_dir: Path) -> None:848"""Test initialization with actual verses."""849orchestrator = JobOrchestrator(data_dir=temp_data_dir)850job_id = orchestrator.create_job(books=["titus"])851852# Mock loader to return some verses853with patch("src.data.loader.DataLoader") as mock_loader:854mock_instance = MagicMock()855mock_instance.get_book_chapters.return_value = [1, 2]856857# Create mock verses858mock_verse1 = MagicMock()859mock_verse1.reference.chapter = 1860mock_verse1.reference.verse = 1861862mock_verse2 = MagicMock()863mock_verse2.reference.chapter = 1864mock_verse2.reference.verse = 2865866mock_instance.load_chapter_verses.side_effect = [867[mock_verse1],868[mock_verse2],869]870mock_loader.return_value = mock_instance871872orchestrator._initialize_all_books(job_id, ["titus"])873874# Verify verses were initialized875pending = orchestrator.state_store.get_pending_verses(job_id, "titus")876assert len(pending) == 2877878879class TestBookStatusUpdates:880"""Tests for book status update edge cases."""881882def test_update_book_completed_with_no_error(self) -> None:883"""Test completing a book without error."""884with tempfile.TemporaryDirectory() as tmpdir:885store = JobStateStore(Path(tmpdir) / "test.db")886job = store.create_bible_job(books=["john"], config={})887888now = datetime.utcnow()889store.update_book_status(890job.job_id,891"john",892JobStatus.COMPLETED,893completed_at=now,894)895896book_job = store.get_book_job(job.job_id, "john")897assert book_job is not None898assert book_job.status == JobStatus.COMPLETED899assert book_job.error_message is None900901902class TestTranslateVerseWithProvenanceFunction:903"""Tests for the _translate_verse_with_provenance function."""904905def test_translate_verse_captures_provenance(self) -> None:906"""Test that provenance is captured during translation."""907# Create mock translator908mock_translator = MagicMock()909mock_translator.model = "test-model"910911# Mock context912mock_context = MagicMock()913mock_context.target_verse.greek_text = "Greek text"914mock_context.target_verse.hebrew_text = None915mock_context.target_verse.english_kjv = "KJV text"916mock_context.target_verse.english_asv = None917mock_context.target_verse.japanese_meiji = None918mock_context.target_verse.japanese_taisho = None919mock_context.preceding_verses = []920mock_context.following_verses = []921mock_context.chapter_summary = None922mock_context.book_metadata = None923mock_context.glossary_terms = []924925mock_translator.context_builder.build_context.return_value = mock_context926mock_translator._get_tm_matches.return_value = None927mock_translator.prompt_builder.build_prompt.return_value = "user prompt"928mock_translator.prompt_builder.get_system_prompt.return_value = "system prompt"929mock_translator.prompt_builder._style = None930mock_translator._call_claude.return_value = "Translation result"931mock_translator._add_to_tm = MagicMock()932933# Create mock reference934from src.models.verse import VerseReference935ref = VerseReference(book="john", chapter=1, verse=1)936937# Create mock storage938mock_storage = MagicMock()939940# Call the function941output, provenance = _translate_verse_with_provenance(942mock_translator, ref, mock_storage, Path("/tmp")943)944945# Verify output946assert output.text == "Translation result"947assert output.model == "test-model"948949# Verify provenance950assert "greek" in provenance.source_texts951assert "kjv" in provenance.source_texts952assert provenance.system_prompt == "system prompt"953assert provenance.user_prompt == "user prompt"954955def test_translate_verse_with_tm_matches(self) -> None:956"""Test provenance captures TM matches."""957mock_translator = MagicMock()958mock_translator.model = "test-model"959960mock_context = MagicMock()961mock_context.target_verse.greek_text = "Greek"962mock_context.target_verse.hebrew_text = None963mock_context.target_verse.english_kjv = None964mock_context.target_verse.english_asv = None965mock_context.target_verse.japanese_meiji = None966mock_context.target_verse.japanese_taisho = None967mock_context.preceding_verses = [MagicMock()]968mock_context.following_verses = [MagicMock()]969mock_context.preceding_verses[0].reference = "john:1:0"970mock_context.following_verses[0].reference = "john:1:2"971mock_context.chapter_summary = MagicMock()972mock_context.chapter_summary.summary = "Chapter summary"973mock_context.book_metadata = MagicMock()974mock_context.book_metadata.name_english = "John"975mock_context.book_metadata.testament = "NT"976mock_context.book_metadata.themes = ["love"]977mock_context.glossary_terms = []978979mock_translator.context_builder.build_context.return_value = mock_context980981from src.models.translation_memory import MatchType, TMMatch, TMSegment982from src.models.verse import VerseReference as VR983tm_segment = TMSegment.create(984source_text="source1",985target_text="target1",986reference=VR(book="john", chapter=1, verse=1),987)988tm_match = TMMatch(segment=tm_segment, similarity=0.9, match_type=MatchType.FUZZY)989mock_translator._get_tm_matches.return_value = [tm_match]990mock_translator.prompt_builder.build_prompt.return_value = "prompt"991mock_translator.prompt_builder.get_system_prompt.return_value = "system"992mock_translator.prompt_builder._style = MagicMock()993mock_translator.prompt_builder._style.to_dict.return_value = {"key": "val"}994mock_translator._call_claude.return_value = "Result"995mock_translator._add_to_tm = MagicMock()996997from src.models.verse import VerseReference998ref = VerseReference(book="john", chapter=1, verse=1)9991000output, provenance = _translate_verse_with_provenance(1001mock_translator, ref, MagicMock(), Path("/tmp")1002)10031004assert len(provenance.tm_matches_used) == 11005assert provenance.tm_matches_used[0]["similarity"] == 0.91006assert provenance.chapter_summary == "Chapter summary"1007assert provenance.book_metadata["name"] == "John"10081009def test_translate_verse_all_source_types(self) -> None:1010"""Test provenance captures all source text types."""1011mock_translator = MagicMock()1012mock_translator.model = "test-model"10131014mock_context = MagicMock()1015mock_context.target_verse.greek_text = "Greek"1016mock_context.target_verse.hebrew_text = "Hebrew"1017mock_context.target_verse.english_kjv = "KJV"1018mock_context.target_verse.english_asv = "ASV"1019mock_context.target_verse.japanese_meiji = "Meiji"1020mock_context.target_verse.japanese_taisho = "Taisho"1021mock_context.preceding_verses = []1022mock_context.following_verses = []1023mock_context.chapter_summary = None1024mock_context.book_metadata = None1025mock_context.glossary_terms = []10261027mock_translator.context_builder.build_context.return_value = mock_context1028mock_translator._get_tm_matches.return_value = []1029mock_translator.prompt_builder.build_prompt.return_value = "prompt"1030mock_translator.prompt_builder.get_system_prompt.return_value = "system"1031mock_translator.prompt_builder._style = None1032mock_translator._call_claude.return_value = "Result"1033mock_translator._add_to_tm = MagicMock()10341035from src.models.verse import VerseReference1036ref = VerseReference(book="genesis", chapter=1, verse=1)10371038output, provenance = _translate_verse_with_provenance(1039mock_translator, ref, MagicMock(), Path("/tmp")1040)10411042# All source types should be captured1043assert "greek" in provenance.source_texts1044assert "hebrew" in provenance.source_texts1045assert "kjv" in provenance.source_texts1046assert "asv" in provenance.source_texts1047assert "meiji" in provenance.source_texts1048assert "taisho" in provenance.source_texts104910501051