Pre-publication review draft
日常訳NICHIJOYAKU日本語 ↗

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

software/tests/test_orchestration_extended.py1051 linesDownload this file ↓

This is the same source included in the download. Each line number is a link you can save or share.

File verification detailsSHA-256 4ea379501e163ee3987533ca3cee36795f90480d878904ff9e0c8832e795ced5
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 (16    JobOrchestrator,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."""29    with tempfile.TemporaryDirectory() as tmpdir:30        data_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 config43        import json44        with (data_dir / "config" / "style.json").open("w") as f:45            json.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 TM59        with (data_dir / "memory" / "segments.json").open("w") as f:60            json.dump({"segments": []}, f)6162        # Create empty pericopes63        with (data_dir / "metadata" / "pericopes.json").open("w") as f:64            json.dump({}, f)6566        # Create glossary67        with (data_dir / "sources" / "glossary.json").open("w") as f:68            json.dump([], f)6970        yield data_dir717273class TestStateStoreEdgeCases:74    """Tests for state store edge cases."""7576    def test_update_job_verse_counts_empty(self) -> None:77        """Test update_job_verse_counts with no updates."""78        with tempfile.TemporaryDirectory() as tmpdir:79            store = JobStateStore(Path(tmpdir) / "test.db")80            job = store.create_bible_job(books=["john"], config={})8182            # Should not raise83            store.update_job_verse_counts(job.job_id)8485            # Verify nothing changed86            updated = store.get_bible_job(job.job_id)87            assert updated is not None88            assert updated.total_verses == 08990    def test_update_book_verse_counts_empty(self) -> None:91        """Test update_book_verse_counts with no updates."""92        with tempfile.TemporaryDirectory() as tmpdir:93            store = JobStateStore(Path(tmpdir) / "test.db")94            job = store.create_bible_job(books=["john"], config={})9596            # Should not raise97            store.update_book_verse_counts(job.job_id, "john")9899            # Verify nothing changed100            book_job = store.get_book_job(job.job_id, "john")101            assert book_job is not None102            assert book_job.total_verses == 0103104    def test_get_book_progress_nonexistent(self) -> None:105        """Test get_book_progress for non-existent book."""106        with tempfile.TemporaryDirectory() as tmpdir:107            store = JobStateStore(Path(tmpdir) / "test.db")108            job = store.create_bible_job(books=["john"], config={})109110            progress = store.get_book_progress(job.job_id, "nonexistent")111            assert progress == {}112113114class TestShutdownCallbackException:115    """Tests for shutdown callback exception handling."""116117    def test_callback_raises_exception(self) -> None:118        """Test callback that raises exception."""119        shutdown = GracefulShutdown()120        callbacks = ShutdownCallback(shutdown)121122        def bad_callback() -> None:123            raise RuntimeError("Test error")124125        callbacks.register(bad_callback)126        exceptions = callbacks.run_all()127128        assert len(exceptions) == 1129        assert isinstance(exceptions[0], RuntimeError)130131132class TestRateLimiterEdgeCases:133    """Tests for rate limiter edge cases."""134135    def test_shared_rate_limiter_shutdown_error(self) -> None:136        """Test that shutdown handles manager errors gracefully."""137        limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)138139        # Shutdown multiple times should not raise140        limiter.shutdown()141        limiter.shutdown()142143144class TestOrchestratorRunMocked:145    """Tests for orchestrator run with mocked components."""146147    def test_run_job_with_no_verses(self, temp_data_dir: Path) -> None:148        """Test running a job where books have no verses."""149        orchestrator = JobOrchestrator(150            data_dir=temp_data_dir,151            max_parallel_books=1,152        )153154        # Create job for a book that has no source data155        job_id = orchestrator.create_job(books=["philemon"])156157        # Mock DataLoader to return no chapters158        with patch("src.data.loader.DataLoader") as mock_loader:159            mock_instance = MagicMock()160            mock_instance.get_book_chapters.return_value = []161            mock_loader.return_value = mock_instance162163            result = orchestrator.run(job_id)164165        # Job should complete (with no verses translated)166        assert result.total_verses_translated == 0167168    def test_run_sequential_mode(self, temp_data_dir: Path) -> None:169        """Test running in sequential mode (1 worker)."""170        orchestrator = JobOrchestrator(171            data_dir=temp_data_dir,172            max_parallel_books=1,173        )174175        job_id = orchestrator.create_job(books=["titus"])176177        # Mock to skip actual translation178        with patch.object(orchestrator, "_initialize_all_books"):179            with patch.object(orchestrator, "_run_sequential") as mock_seq:180                mock_seq.return_value = []181                orchestrator.run(job_id)182183        mock_seq.assert_called_once()184185    def test_run_parallel_mode(self, temp_data_dir: Path) -> None:186        """Test running in parallel mode (>1 workers)."""187        orchestrator = JobOrchestrator(188            data_dir=temp_data_dir,189            max_parallel_books=2,190        )191192        job_id = orchestrator.create_job(books=["titus", "philemon"])193194        # Mock to skip actual translation195        with patch.object(orchestrator, "_initialize_all_books"):196            with patch.object(orchestrator, "_run_parallel") as mock_par:197                mock_par.return_value = []198                orchestrator.run(job_id)199200        mock_par.assert_called_once()201202203class TestOrchestratorRunWithShutdown:204    """Tests for orchestrator with shutdown handling."""205206    def test_run_with_immediate_shutdown(self, temp_data_dir: Path) -> None:207        """Test that shutdown is handled during run."""208        orchestrator = JobOrchestrator(209            data_dir=temp_data_dir,210            max_parallel_books=1,211        )212213        job_id = orchestrator.create_job(books=["titus"])214215        # Mock _run_sequential to signal shutdown216        def mock_run_with_shutdown(job_id, books, rate_limiter, shutdown):217            shutdown.request_stop()218            return []219220        with patch.object(orchestrator, "_initialize_all_books"):221            with patch.object(222                orchestrator, "_run_sequential", side_effect=mock_run_with_shutdown223            ):224                result = orchestrator.run(job_id)225226        # Should complete without error (just no verses)227        assert result is not None228229230class TestBookWorker:231    """Tests for book worker function."""232233    def test_worker_with_empty_pending(self, temp_data_dir: Path) -> None:234        """Test worker when no pending verses."""235        state_store = JobStateStore(temp_data_dir / "jobs" / "test.db")236        job = state_store.create_bible_job(books=["titus"], config={})237238        # Initialize with no verses239        state_store.initialize_verse_jobs(job.job_id, "titus", [])240241        # Create dummy rate limiter242        limiter = DummyRateLimiter(delay=0.01)243244        # Mock all the translation components245        with patch("src.data.loader.DataLoader") as mock_loader:246            with patch("src.data.storage.Storage"):247                with patch("src.config.style_config.StyleConfigManager"):248                    with patch("src.context.glossary.GlossaryManager"):249                        with patch("src.context.translation_memory.TranslationMemoryManager"):250                            with patch("src.context.pericope.PericopeManager"):251                                with patch("src.context.builder.ContextBuilder"):252                                    with patch("src.pipeline.prompt.PromptBuilder"):253                                        with patch("src.evaluation.evaluator.TranslationEvaluator"):254                                            with patch("src.evaluation.storage.EvaluationStorage"):255                                                with patch("src.pipeline.translator.Translator"):256                                                    mock_loader_instance = MagicMock()257                                                    mock_loader_instance.load_glossary.return_value = []258                                                    mock_loader.return_value = mock_loader_instance259260                                                    result = _translate_book_worker(261                                                        job.job_id,262                                                        "titus",263                                                        str(temp_data_dir),264                                                        str(state_store.db_path),265                                                        limiter,266                                                    )267268        assert result.book == "titus"269        assert result.verses_translated == 0270271272class TestProvenanceExtended:273    """Extended tests for provenance tracking."""274275    def test_provenance_with_evaluation(self) -> None:276        """Test provenance with evaluation data."""277        prov = TranslationProvenance(278            evaluation_score=85.5,279            evaluation_flags=["consistency_warning"],280        )281282        data = prov.to_dict()283        restored = TranslationProvenance.from_dict(data)284285        assert restored.evaluation_score == 85.5286        assert restored.evaluation_flags == ["consistency_warning"]287288    def test_provenance_with_book_metadata(self) -> None:289        """Test provenance with book metadata."""290        prov = TranslationProvenance(291            book_metadata={292                "name": "John",293                "testament": "NT",294                "themes": ["love", "belief"],295            }296        )297298        summary = prov.get_inputs_summary()299        assert summary["pericope"] is False300301    def test_provenance_no_pericope(self) -> None:302        """Test get_inputs_summary with no pericope."""303        prov = TranslationProvenance(pericope_id=None)304305        summary = prov.get_inputs_summary()306        assert summary["pericope"] is False307308309class TestSignalsExtended:310    """Extended tests for signal handling."""311312    def test_signal_handler_idempotent(self) -> None:313        """Test that signal handler is idempotent."""314        shutdown = GracefulShutdown()315316        # Simulate signal received twice317        shutdown._signal_handler(2, None)  # SIGINT318        shutdown._signal_handler(15, None)  # SIGTERM319320        # Should still only have first signal321        assert shutdown.should_stop is True322        assert shutdown.signal_received == 2  # First signal323324    def test_context_manager_exception(self) -> None:325        """Test context manager with exception inside."""326        shutdown = GracefulShutdown()327328        with pytest.raises(ValueError):329            with shutdown:330                raise ValueError("Test error")331332        # Handlers should be restored even with exception333        assert not shutdown.should_stop or shutdown.should_stop334335336class TestOrchestratorResume:337    """Tests for resume functionality."""338339    def test_resume_resets_orphaned(self, temp_data_dir: Path) -> None:340        """Test that resume resets orphaned verses."""341        orchestrator = JobOrchestrator(342            data_dir=temp_data_dir,343            max_parallel_books=1,344        )345346        # Create job and initialize347        job_id = orchestrator.create_job(books=["titus"])348        orchestrator.state_store.initialize_verse_jobs(349            job_id, "titus", [(1, 1), (1, 2)]350        )351352        # Claim verse (simulating in-progress from crashed worker)353        orchestrator.state_store.atomic_claim_verse(354            job_id, "titus", 1, 1, worker_pid=99999355        )356357        # Resume should reset orphaned358        with patch.object(orchestrator, "run") as mock_run:359            mock_run.return_value = MagicMock(360                success=True,361                books_completed=1,362                books_failed=0,363            )364            orchestrator.resume(job_id)365366        # Verify orphaned was reset before run367        pending = orchestrator.state_store.get_pending_verses(job_id, "titus")368        # The run mock doesn't actually translate, so both should be pending369        assert len(pending) == 2370371372class TestStateStoreConnection:373    """Tests for state store connection handling."""374375    def test_connection_context_manager_commits(self) -> None:376        """Test that connection context manager commits on success."""377        with tempfile.TemporaryDirectory() as tmpdir:378            store = JobStateStore(Path(tmpdir) / "test.db")379380            # Create job (uses connection context manager)381            job = store.create_bible_job(books=["john"], config={})382383            # Verify committed by loading384            loaded = store.get_bible_job(job.job_id)385            assert loaded is not None386387    def test_connection_context_manager_rollback(self) -> None:388        """Test that connection context manager rolls back on error."""389        with tempfile.TemporaryDirectory() as tmpdir:390            store = JobStateStore(Path(tmpdir) / "test.db")391392            # Try to create job with invalid data (should fail at some point)393            # This tests the rollback path394            with pytest.raises(Exception):395                with store._connection() as conn:396                    conn.execute("INSERT INTO nonexistent_table VALUES (1)")397398399class TestInitializeBooks:400    """Tests for book initialization."""401402    def test_initialize_skips_already_initialized(403        self, temp_data_dir: Path404    ) -> None:405        """Test that initialization skips already initialized books."""406        orchestrator = JobOrchestrator(407            data_dir=temp_data_dir,408            max_parallel_books=1,409        )410411        job_id = orchestrator.create_job(books=["titus"])412413        # Pre-initialize with verses414        orchestrator.state_store.initialize_verse_jobs(415            job_id, "titus", [(1, 1), (1, 2)]416        )417418        # Mock DataLoader419        with patch("src.data.loader.DataLoader") as mock_loader:420            mock_instance = MagicMock()421            mock_loader.return_value = mock_instance422423            # This should skip titus since it's already initialized424            orchestrator._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."""432433    def test_verse_status_updates_book_counts(self) -> None:434        """Test that verse status updates also update book counts."""435        with tempfile.TemporaryDirectory() as tmpdir:436            store = JobStateStore(Path(tmpdir) / "test.db")437            job = store.create_bible_job(books=["john"], config={})438            store.initialize_verse_jobs(job.job_id, "john", [(1, 1), (1, 2)])439440            # Claim and complete first verse441            store.atomic_claim_verse(job.job_id, "john", 1, 1, 12345)442            store.update_verse_status(443                job.job_id, "john", 1, 1, JobStatus.COMPLETED444            )445446            book_job = store.get_book_job(job.job_id, "john")447            assert book_job is not None448            assert book_job.completed_verses == 1449450            # Claim and fail second verse451            store.atomic_claim_verse(job.job_id, "john", 1, 2, 12345)452            store.update_verse_status(453                job.job_id, "john", 1, 2, JobStatus.FAILED, error="Test"454            )455456            book_job = store.get_book_job(job.job_id, "john")457            assert book_job is not None458            assert book_job.failed_verses == 1459460461class TestOrchestratorParallelRun:462    """Tests for parallel book translation."""463464    def test_run_parallel_with_shutdown(self, temp_data_dir: Path) -> None:465        """Test parallel run handles shutdown."""466        orchestrator = JobOrchestrator(467            data_dir=temp_data_dir,468            max_parallel_books=2,469        )470471        job_id = orchestrator.create_job(books=["titus", "philemon"])472473        # Mock _run_parallel to return empty results474        def mock_parallel(job_id, books, rate_limiter, shutdown):475            from src.orchestration.models import BookResult476            return [477                BookResult(478                    book=book,479                    success=True,480                    verses_translated=0,481                    verses_failed=0,482                    duration_seconds=0.1,483                )484                for book in books485            ]486487        with patch.object(orchestrator, "_initialize_all_books"):488            with patch.object(orchestrator, "_run_parallel", side_effect=mock_parallel):489                result = orchestrator.run(job_id)490491        assert result.success is True492        assert result.books_completed == 2493494495class TestOrchestratorSequentialRun:496    """Tests for sequential book translation."""497498    def test_run_sequential_with_book_results(self, temp_data_dir: Path) -> None:499        """Test sequential run with mixed results."""500        orchestrator = JobOrchestrator(501            data_dir=temp_data_dir,502            max_parallel_books=1,503        )504505        job_id = orchestrator.create_job(books=["titus", "philemon"])506507        # Mock _run_sequential to return mixed results508        def mock_sequential(job_id, books, rate_limiter, shutdown):509            from src.orchestration.models import BookResult510            return [511                BookResult(512                    book="titus",513                    success=True,514                    verses_translated=46,515                    verses_failed=0,516                    duration_seconds=10.0,517                ),518                BookResult(519                    book="philemon",520                    success=False,521                    verses_translated=10,522                    verses_failed=15,523                    duration_seconds=5.0,524                    error_message="Test error",525                ),526            ]527528        with patch.object(orchestrator, "_initialize_all_books"):529            with patch.object(orchestrator, "_run_sequential", side_effect=mock_sequential):530                result = orchestrator.run(job_id)531532        assert result.success is False533        assert result.books_completed == 1534        assert result.books_failed == 1535        assert result.total_verses_translated == 56536        assert result.total_verses_failed == 15537538539class TestStateStoreMoreEdgeCases:540    """Additional state store edge case tests."""541542    def test_update_book_status_with_all_fields(self) -> None:543        """Test update_book_status with all optional fields."""544        with tempfile.TemporaryDirectory() as tmpdir:545            store = JobStateStore(Path(tmpdir) / "test.db")546            job = store.create_bible_job(books=["john"], config={})547548            now = datetime.utcnow()549            store.update_book_status(550                job.job_id,551                "john",552                JobStatus.FAILED,553                worker_pid=12345,554                started_at=now,555                completed_at=now,556                error_message="Test error",557            )558559            book_job = store.get_book_job(job.job_id, "john")560            assert book_job is not None561            assert book_job.status == JobStatus.FAILED562            assert book_job.worker_pid == 12345563            assert book_job.error_message == "Test error"564565    def test_update_job_status_with_completed(self) -> None:566        """Test update_job_status with completed_at."""567        with tempfile.TemporaryDirectory() as tmpdir:568            store = JobStateStore(Path(tmpdir) / "test.db")569            job = store.create_bible_job(books=["john"], config={})570571            now = datetime.utcnow()572            store.update_job_status(573                job.job_id,574                JobStatus.COMPLETED,575                started_at=now,576                completed_at=now,577            )578579            updated = store.get_bible_job(job.job_id)580            assert updated is not None581            assert updated.status == JobStatus.COMPLETED582            assert updated.completed_at is not None583584585class TestOrchestratorConfigSnapshot:586    """Tests for configuration snapshot in orchestrator."""587588    def test_snapshot_without_style_config(self, temp_data_dir: Path) -> None:589        """Test config snapshot when style.json doesn't exist."""590        # Remove style.json591        style_path = temp_data_dir / "config" / "style.json"592        if style_path.exists():593            style_path.unlink()594595        orchestrator = JobOrchestrator(data_dir=temp_data_dir)596597        config = orchestrator._snapshot_config()598599        # Should still have model600        assert "model" in config601602603class TestRunSequentialShutdown:604    """Tests for sequential run with shutdown."""605606    def test_sequential_books_cancelled_on_shutdown(607        self, temp_data_dir: Path608    ) -> None:609        """Test that remaining books are cancelled when shutdown is requested."""610        orchestrator = JobOrchestrator(611            data_dir=temp_data_dir,612            max_parallel_books=1,613        )614615        job_id = orchestrator.create_job(books=["titus", "philemon", "jude"])616617        # Create shutdown that's already signaled618        shutdown = GracefulShutdown()619        shutdown.request_stop()620621        # Create limiter622        limiter = DummyRateLimiter(delay=0.01)623624        # Run sequential (should immediately return cancelled for all)625        results = orchestrator._run_sequential(626            job_id, ["titus", "philemon", "jude"], limiter, shutdown627        )628629        # All should be cancelled630        assert len(results) == 3631        for result in results:632            assert result.success is False633            assert "Cancelled" in result.error_message634635636class TestRunParallelShutdown:637    """Tests for parallel run with shutdown."""638639    def test_parallel_shutdown_during_wait(self, temp_data_dir: Path) -> None:640        """Test parallel run when shutdown occurs while waiting."""641        orchestrator = JobOrchestrator(642            data_dir=temp_data_dir,643            max_parallel_books=2,644        )645646        job_id = orchestrator.create_job(books=["titus", "philemon"])647648        # Create shutdown649        shutdown = GracefulShutdown()650651        # Create limiter652        limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)653654        try:655            # Signal shutdown before running656            shutdown.request_stop()657658            results = orchestrator._run_parallel(659                job_id, ["titus", "philemon"], limiter, shutdown660            )661662            # Books should be cancelled663            for result in results:664                assert result.success is False665        finally:666            limiter.shutdown()667668669class TestBookWorkerException:670    """Tests for book worker exception handling."""671672    def test_worker_handles_general_exception(self, temp_data_dir: Path) -> None:673        """Test that worker handles general exceptions gracefully."""674        state_store = JobStateStore(temp_data_dir / "jobs" / "test.db")675        job = state_store.create_bible_job(books=["titus"], config={})676677        limiter = DummyRateLimiter(delay=0.01)678679        # Mock to raise exception680        with patch("src.data.loader.DataLoader") as mock_loader:681            mock_loader.side_effect = RuntimeError("Test error")682683            result = _translate_book_worker(684                job.job_id,685                "titus",686                str(temp_data_dir),687                str(state_store.db_path),688                limiter,689            )690691        assert result.success is False692        assert "Test error" in result.error_message693694695class TestRunParallelExecution:696    """Tests for parallel execution details."""697698    def test_run_parallel_future_exception(self, temp_data_dir: Path) -> None:699        """Test parallel run when a future raises exception."""700        orchestrator = JobOrchestrator(701            data_dir=temp_data_dir,702            max_parallel_books=2,703        )704705        job_id = orchestrator.create_job(books=["titus"])706        shutdown = GracefulShutdown()707708        # Create limiter709        limiter = SharedRateLimiter(max_concurrent=2, delay=0.01)710711        try:712            # Mock _translate_book_worker to raise an exception713            with patch(714                "src.orchestration.orchestrator._translate_book_worker",715                side_effect=RuntimeError("Worker error"),716            ):717                results = orchestrator._run_parallel(718                    job_id, ["titus"], limiter, shutdown719                )720721            # Should have one failed result722            assert len(results) == 1723            assert results[0].success is False724            assert "Worker error" in results[0].error_message725        finally:726            limiter.shutdown()727728729class TestGetStatusDetails:730    """Tests for job status retrieval details."""731732    def test_get_status_with_started_job(self, temp_data_dir: Path) -> None:733        """Test getting status of a started job."""734        orchestrator = JobOrchestrator(data_dir=temp_data_dir)735736        job_id = orchestrator.create_job(books=["titus", "philemon"])737738        # Update to in_progress with started_at739        now = datetime.utcnow()740        orchestrator.state_store.update_job_status(741            job_id, JobStatus.IN_PROGRESS, started_at=now742        )743744        status = orchestrator.get_status(job_id)745746        assert status["status"] == "in_progress"747        assert status["started_at"] is not None748        assert status["completed_at"] is None749750    def test_get_status_with_completed_job(self, temp_data_dir: Path) -> None:751        """Test getting status of a completed job."""752        orchestrator = JobOrchestrator(data_dir=temp_data_dir)753754        job_id = orchestrator.create_job(books=["titus"])755756        # Update to completed757        now = datetime.utcnow()758        orchestrator.state_store.update_job_status(759            job_id, JobStatus.COMPLETED, started_at=now, completed_at=now760        )761762        status = orchestrator.get_status(job_id)763764        assert status["status"] == "completed"765        assert status["completed_at"] is not None766767    def test_get_status_book_progress(self, temp_data_dir: Path) -> None:768        """Test getting status shows book progress."""769        orchestrator = JobOrchestrator(data_dir=temp_data_dir)770771        job_id = orchestrator.create_job(books=["titus"])772773        # Initialize and complete some verses774        orchestrator.state_store.initialize_verse_jobs(775            job_id, "titus", [(1, 1), (1, 2), (1, 3)]776        )777        orchestrator.state_store.atomic_claim_verse(job_id, "titus", 1, 1, 12345)778        orchestrator.state_store.update_verse_status(779            job_id, "titus", 1, 1, JobStatus.COMPLETED780        )781782        status = orchestrator.get_status(job_id)783784        book_status = status["books"][0]785        assert book_status["completed_verses"] == 1786        assert book_status["total_verses"] == 3787788789class TestCreateJobAllBooks:790    """Tests for creating jobs with all books."""791792    def test_create_job_default_all_books(self, temp_data_dir: Path) -> None:793        """Test that default creates all 66 books."""794        orchestrator = JobOrchestrator(data_dir=temp_data_dir)795796        # Don't pass any book specification797        job_id = orchestrator.create_job()798799        job = orchestrator.state_store.get_bible_job(job_id)800        assert job is not None801        assert len(job.books) == 66802803    def test_create_job_nt_only_branch(self, temp_data_dir: Path) -> None:804        """Test nt_only branch in create_job."""805        orchestrator = JobOrchestrator(data_dir=temp_data_dir)806807        # Use nt_only flag808        job_id = orchestrator.create_job(nt_only=True)809810        job = orchestrator.state_store.get_bible_job(job_id)811        assert job is not None812        assert len(job.books) == 27813        assert "matthew" in job.books814815    def test_create_job_ot_only_branch(self, temp_data_dir: Path) -> None:816        """Test ot_only branch in create_job."""817        orchestrator = JobOrchestrator(data_dir=temp_data_dir)818819        # Use ot_only flag820        job_id = orchestrator.create_job(ot_only=True)821822        job = orchestrator.state_store.get_bible_job(job_id)823        assert job is not None824        assert len(job.books) == 39825        assert "genesis" in job.books826827828class TestInitializeAllBooksDetails:829    """Tests for book initialization details."""830831    def test_initialize_logs_warning_for_no_verses(832        self, temp_data_dir: Path833    ) -> None:834        """Test that initialization logs warning when book has no verses."""835        orchestrator = JobOrchestrator(data_dir=temp_data_dir)836        job_id = orchestrator.create_job(books=["titus"])837838        # Mock loader to return no chapters839        with patch("src.data.loader.DataLoader") as mock_loader:840            mock_instance = MagicMock()841            mock_instance.get_book_chapters.return_value = []842            mock_loader.return_value = mock_instance843844            # Should not raise, just log warning845            orchestrator._initialize_all_books(job_id, ["titus"])846847    def test_initialize_with_verses(self, temp_data_dir: Path) -> None:848        """Test initialization with actual verses."""849        orchestrator = JobOrchestrator(data_dir=temp_data_dir)850        job_id = orchestrator.create_job(books=["titus"])851852        # Mock loader to return some verses853        with patch("src.data.loader.DataLoader") as mock_loader:854            mock_instance = MagicMock()855            mock_instance.get_book_chapters.return_value = [1, 2]856857            # Create mock verses858            mock_verse1 = MagicMock()859            mock_verse1.reference.chapter = 1860            mock_verse1.reference.verse = 1861862            mock_verse2 = MagicMock()863            mock_verse2.reference.chapter = 1864            mock_verse2.reference.verse = 2865866            mock_instance.load_chapter_verses.side_effect = [867                [mock_verse1],868                [mock_verse2],869            ]870            mock_loader.return_value = mock_instance871872            orchestrator._initialize_all_books(job_id, ["titus"])873874        # Verify verses were initialized875        pending = orchestrator.state_store.get_pending_verses(job_id, "titus")876        assert len(pending) == 2877878879class TestBookStatusUpdates:880    """Tests for book status update edge cases."""881882    def test_update_book_completed_with_no_error(self) -> None:883        """Test completing a book without error."""884        with tempfile.TemporaryDirectory() as tmpdir:885            store = JobStateStore(Path(tmpdir) / "test.db")886            job = store.create_bible_job(books=["john"], config={})887888            now = datetime.utcnow()889            store.update_book_status(890                job.job_id,891                "john",892                JobStatus.COMPLETED,893                completed_at=now,894            )895896            book_job = store.get_book_job(job.job_id, "john")897            assert book_job is not None898            assert book_job.status == JobStatus.COMPLETED899            assert book_job.error_message is None900901902class TestTranslateVerseWithProvenanceFunction:903    """Tests for the _translate_verse_with_provenance function."""904905    def test_translate_verse_captures_provenance(self) -> None:906        """Test that provenance is captured during translation."""907        # Create mock translator908        mock_translator = MagicMock()909        mock_translator.model = "test-model"910911        # Mock context912        mock_context = MagicMock()913        mock_context.target_verse.greek_text = "Greek text"914        mock_context.target_verse.hebrew_text = None915        mock_context.target_verse.english_kjv = "KJV text"916        mock_context.target_verse.english_asv = None917        mock_context.target_verse.japanese_meiji = None918        mock_context.target_verse.japanese_taisho = None919        mock_context.preceding_verses = []920        mock_context.following_verses = []921        mock_context.chapter_summary = None922        mock_context.book_metadata = None923        mock_context.glossary_terms = []924925        mock_translator.context_builder.build_context.return_value = mock_context926        mock_translator._get_tm_matches.return_value = None927        mock_translator.prompt_builder.build_prompt.return_value = "user prompt"928        mock_translator.prompt_builder.get_system_prompt.return_value = "system prompt"929        mock_translator.prompt_builder._style = None930        mock_translator._call_claude.return_value = "Translation result"931        mock_translator._add_to_tm = MagicMock()932933        # Create mock reference934        from src.models.verse import VerseReference935        ref = VerseReference(book="john", chapter=1, verse=1)936937        # Create mock storage938        mock_storage = MagicMock()939940        # Call the function941        output, provenance = _translate_verse_with_provenance(942            mock_translator, ref, mock_storage, Path("/tmp")943        )944945        # Verify output946        assert output.text == "Translation result"947        assert output.model == "test-model"948949        # Verify provenance950        assert "greek" in provenance.source_texts951        assert "kjv" in provenance.source_texts952        assert provenance.system_prompt == "system prompt"953        assert provenance.user_prompt == "user prompt"954955    def test_translate_verse_with_tm_matches(self) -> None:956        """Test provenance captures TM matches."""957        mock_translator = MagicMock()958        mock_translator.model = "test-model"959960        mock_context = MagicMock()961        mock_context.target_verse.greek_text = "Greek"962        mock_context.target_verse.hebrew_text = None963        mock_context.target_verse.english_kjv = None964        mock_context.target_verse.english_asv = None965        mock_context.target_verse.japanese_meiji = None966        mock_context.target_verse.japanese_taisho = None967        mock_context.preceding_verses = [MagicMock()]968        mock_context.following_verses = [MagicMock()]969        mock_context.preceding_verses[0].reference = "john:1:0"970        mock_context.following_verses[0].reference = "john:1:2"971        mock_context.chapter_summary = MagicMock()972        mock_context.chapter_summary.summary = "Chapter summary"973        mock_context.book_metadata = MagicMock()974        mock_context.book_metadata.name_english = "John"975        mock_context.book_metadata.testament = "NT"976        mock_context.book_metadata.themes = ["love"]977        mock_context.glossary_terms = []978979        mock_translator.context_builder.build_context.return_value = mock_context980981        from src.models.translation_memory import MatchType, TMMatch, TMSegment982        from src.models.verse import VerseReference as VR983        tm_segment = TMSegment.create(984            source_text="source1",985            target_text="target1",986            reference=VR(book="john", chapter=1, verse=1),987        )988        tm_match = TMMatch(segment=tm_segment, similarity=0.9, match_type=MatchType.FUZZY)989        mock_translator._get_tm_matches.return_value = [tm_match]990        mock_translator.prompt_builder.build_prompt.return_value = "prompt"991        mock_translator.prompt_builder.get_system_prompt.return_value = "system"992        mock_translator.prompt_builder._style = MagicMock()993        mock_translator.prompt_builder._style.to_dict.return_value = {"key": "val"}994        mock_translator._call_claude.return_value = "Result"995        mock_translator._add_to_tm = MagicMock()996997        from src.models.verse import VerseReference998        ref = VerseReference(book="john", chapter=1, verse=1)9991000        output, provenance = _translate_verse_with_provenance(1001            mock_translator, ref, MagicMock(), Path("/tmp")1002        )10031004        assert len(provenance.tm_matches_used) == 11005        assert provenance.tm_matches_used[0]["similarity"] == 0.91006        assert provenance.chapter_summary == "Chapter summary"1007        assert provenance.book_metadata["name"] == "John"10081009    def test_translate_verse_all_source_types(self) -> None:1010        """Test provenance captures all source text types."""1011        mock_translator = MagicMock()1012        mock_translator.model = "test-model"10131014        mock_context = MagicMock()1015        mock_context.target_verse.greek_text = "Greek"1016        mock_context.target_verse.hebrew_text = "Hebrew"1017        mock_context.target_verse.english_kjv = "KJV"1018        mock_context.target_verse.english_asv = "ASV"1019        mock_context.target_verse.japanese_meiji = "Meiji"1020        mock_context.target_verse.japanese_taisho = "Taisho"1021        mock_context.preceding_verses = []1022        mock_context.following_verses = []1023        mock_context.chapter_summary = None1024        mock_context.book_metadata = None1025        mock_context.glossary_terms = []10261027        mock_translator.context_builder.build_context.return_value = mock_context1028        mock_translator._get_tm_matches.return_value = []1029        mock_translator.prompt_builder.build_prompt.return_value = "prompt"1030        mock_translator.prompt_builder.get_system_prompt.return_value = "system"1031        mock_translator.prompt_builder._style = None1032        mock_translator._call_claude.return_value = "Result"1033        mock_translator._add_to_tm = MagicMock()10341035        from src.models.verse import VerseReference1036        ref = VerseReference(book="genesis", chapter=1, verse=1)10371038        output, provenance = _translate_verse_with_provenance(1039            mock_translator, ref, MagicMock(), Path("/tmp")1040        )10411042        # All source types should be captured1043        assert "greek" in provenance.source_texts1044        assert "hebrew" in provenance.source_texts1045        assert "kjv" in provenance.source_texts1046        assert "asv" in provenance.source_texts1047        assert "meiji" in provenance.source_texts1048        assert "taisho" in provenance.source_texts104910501051