"""Extended tests for orchestration coverage.""" from __future__ import annotations import tempfile import threading import time from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch import pytest from src.orchestration.models import JobStatus from src.orchestration.orchestrator import ( JobOrchestrator, _translate_book_worker, _translate_verse_with_provenance, ) from src.orchestration.provenance import TranslationProvenance from src.orchestration.rate_limiter import DummyRateLimiter, SharedRateLimiter from src.orchestration.signals import GracefulShutdown, ShutdownCallback from src.orchestration.state import JobStateStore @pytest.fixture def temp_data_dir() -> Path: """Create a temporary data directory with full structure.""" with tempfile.TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) # Create required subdirectories (data_dir / "sources" / "greek").mkdir(parents=True) (data_dir / "sources" / "hebrew").mkdir(parents=True) (data_dir / "output").mkdir(parents=True) (data_dir / "progress").mkdir(parents=True) (data_dir / "memory").mkdir(parents=True) (data_dir / "metadata").mkdir(parents=True) (data_dir / "config").mkdir(parents=True) (data_dir / "jobs").mkdir(parents=True) (data_dir / "evaluations").mkdir(parents=True) # Create minimal config import json with (data_dir / "config" / "style.json").open("w") as f: json.dump({ "equivalence_approach": "optimal", "readability_level": "general_public", "gender_language": "traditional", "theological_tradition": "ecumenical", "term_consistency": "balanced", "formality_level": "standard", "avoid_archaic": [], "textual_basis": "critical", "target_style_reference": "", "additional_instructions": "", }, f) # Create empty TM with (data_dir / "memory" / "segments.json").open("w") as f: json.dump({"segments": []}, f) # Create empty pericopes with (data_dir / "metadata" / "pericopes.json").open("w") as f: json.dump({}, f) # Create glossary with (data_dir / "sources" / "glossary.json").open("w") as f: json.dump([], f) yield data_dir class TestStateStoreEdgeCases: """Tests for state store edge cases.""" def test_update_job_verse_counts_empty(self) -> None: """Test update_job_verse_counts with no updates.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) # Should not raise store.update_job_verse_counts(job.job_id) # Verify nothing changed updated = store.get_bible_job(job.job_id) assert updated is not None assert updated.total_verses == 0 def test_update_book_verse_counts_empty(self) -> None: """Test update_book_verse_counts with no updates.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) # Should not raise store.update_book_verse_counts(job.job_id, "john") # Verify nothing changed book_job = store.get_book_job(job.job_id, "john") assert book_job is not None assert book_job.total_verses == 0 def test_get_book_progress_nonexistent(self) -> None: """Test get_book_progress for non-existent book.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) progress = store.get_book_progress(job.job_id, "nonexistent") assert progress == {} class TestShutdownCallbackException: """Tests for shutdown callback exception handling.""" def test_callback_raises_exception(self) -> None: """Test callback that raises exception.""" shutdown = GracefulShutdown() callbacks = ShutdownCallback(shutdown) def bad_callback() -> None: raise RuntimeError("Test error") callbacks.register(bad_callback) exceptions = callbacks.run_all() assert len(exceptions) == 1 assert isinstance(exceptions[0], RuntimeError) class TestRateLimiterEdgeCases: """Tests for rate limiter edge cases.""" def test_shared_rate_limiter_shutdown_error(self) -> None: """Test that shutdown handles manager errors gracefully.""" limiter = SharedRateLimiter(max_concurrent=2, delay=0.01) # Shutdown multiple times should not raise limiter.shutdown() limiter.shutdown() class TestOrchestratorRunMocked: """Tests for orchestrator run with mocked components.""" def test_run_job_with_no_verses(self, temp_data_dir: Path) -> None: """Test running a job where books have no verses.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) # Create job for a book that has no source data job_id = orchestrator.create_job(books=["philemon"]) # Mock DataLoader to return no chapters with patch("src.data.loader.DataLoader") as mock_loader: mock_instance = MagicMock() mock_instance.get_book_chapters.return_value = [] mock_loader.return_value = mock_instance result = orchestrator.run(job_id) # Job should complete (with no verses translated) assert result.total_verses_translated == 0 def test_run_sequential_mode(self, temp_data_dir: Path) -> None: """Test running in sequential mode (1 worker).""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) job_id = orchestrator.create_job(books=["titus"]) # Mock to skip actual translation with patch.object(orchestrator, "_initialize_all_books"): with patch.object(orchestrator, "_run_sequential") as mock_seq: mock_seq.return_value = [] orchestrator.run(job_id) mock_seq.assert_called_once() def test_run_parallel_mode(self, temp_data_dir: Path) -> None: """Test running in parallel mode (>1 workers).""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=2, ) job_id = orchestrator.create_job(books=["titus", "philemon"]) # Mock to skip actual translation with patch.object(orchestrator, "_initialize_all_books"): with patch.object(orchestrator, "_run_parallel") as mock_par: mock_par.return_value = [] orchestrator.run(job_id) mock_par.assert_called_once() class TestOrchestratorRunWithShutdown: """Tests for orchestrator with shutdown handling.""" def test_run_with_immediate_shutdown(self, temp_data_dir: Path) -> None: """Test that shutdown is handled during run.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) job_id = orchestrator.create_job(books=["titus"]) # Mock _run_sequential to signal shutdown def mock_run_with_shutdown(job_id, books, rate_limiter, shutdown): shutdown.request_stop() return [] with patch.object(orchestrator, "_initialize_all_books"): with patch.object( orchestrator, "_run_sequential", side_effect=mock_run_with_shutdown ): result = orchestrator.run(job_id) # Should complete without error (just no verses) assert result is not None class TestBookWorker: """Tests for book worker function.""" def test_worker_with_empty_pending(self, temp_data_dir: Path) -> None: """Test worker when no pending verses.""" state_store = JobStateStore(temp_data_dir / "jobs" / "test.db") job = state_store.create_bible_job(books=["titus"], config={}) # Initialize with no verses state_store.initialize_verse_jobs(job.job_id, "titus", []) # Create dummy rate limiter limiter = DummyRateLimiter(delay=0.01) # Mock all the translation components with patch("src.data.loader.DataLoader") as mock_loader: with patch("src.data.storage.Storage"): with patch("src.config.style_config.StyleConfigManager"): with patch("src.context.glossary.GlossaryManager"): with patch("src.context.translation_memory.TranslationMemoryManager"): with patch("src.context.pericope.PericopeManager"): with patch("src.context.builder.ContextBuilder"): with patch("src.pipeline.prompt.PromptBuilder"): with patch("src.evaluation.evaluator.TranslationEvaluator"): with patch("src.evaluation.storage.EvaluationStorage"): with patch("src.pipeline.translator.Translator"): mock_loader_instance = MagicMock() mock_loader_instance.load_glossary.return_value = [] mock_loader.return_value = mock_loader_instance result = _translate_book_worker( job.job_id, "titus", str(temp_data_dir), str(state_store.db_path), limiter, ) assert result.book == "titus" assert result.verses_translated == 0 class TestProvenanceExtended: """Extended tests for provenance tracking.""" def test_provenance_with_evaluation(self) -> None: """Test provenance with evaluation data.""" prov = TranslationProvenance( evaluation_score=85.5, evaluation_flags=["consistency_warning"], ) data = prov.to_dict() restored = TranslationProvenance.from_dict(data) assert restored.evaluation_score == 85.5 assert restored.evaluation_flags == ["consistency_warning"] def test_provenance_with_book_metadata(self) -> None: """Test provenance with book metadata.""" prov = TranslationProvenance( book_metadata={ "name": "John", "testament": "NT", "themes": ["love", "belief"], } ) summary = prov.get_inputs_summary() assert summary["pericope"] is False def test_provenance_no_pericope(self) -> None: """Test get_inputs_summary with no pericope.""" prov = TranslationProvenance(pericope_id=None) summary = prov.get_inputs_summary() assert summary["pericope"] is False class TestSignalsExtended: """Extended tests for signal handling.""" def test_signal_handler_idempotent(self) -> None: """Test that signal handler is idempotent.""" shutdown = GracefulShutdown() # Simulate signal received twice shutdown._signal_handler(2, None) # SIGINT shutdown._signal_handler(15, None) # SIGTERM # Should still only have first signal assert shutdown.should_stop is True assert shutdown.signal_received == 2 # First signal def test_context_manager_exception(self) -> None: """Test context manager with exception inside.""" shutdown = GracefulShutdown() with pytest.raises(ValueError): with shutdown: raise ValueError("Test error") # Handlers should be restored even with exception assert not shutdown.should_stop or shutdown.should_stop class TestOrchestratorResume: """Tests for resume functionality.""" def test_resume_resets_orphaned(self, temp_data_dir: Path) -> None: """Test that resume resets orphaned verses.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) # Create job and initialize job_id = orchestrator.create_job(books=["titus"]) orchestrator.state_store.initialize_verse_jobs( job_id, "titus", [(1, 1), (1, 2)] ) # Claim verse (simulating in-progress from crashed worker) orchestrator.state_store.atomic_claim_verse( job_id, "titus", 1, 1, worker_pid=99999 ) # Resume should reset orphaned with patch.object(orchestrator, "run") as mock_run: mock_run.return_value = MagicMock( success=True, books_completed=1, books_failed=0, ) orchestrator.resume(job_id) # Verify orphaned was reset before run pending = orchestrator.state_store.get_pending_verses(job_id, "titus") # The run mock doesn't actually translate, so both should be pending assert len(pending) == 2 class TestStateStoreConnection: """Tests for state store connection handling.""" def test_connection_context_manager_commits(self) -> None: """Test that connection context manager commits on success.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") # Create job (uses connection context manager) job = store.create_bible_job(books=["john"], config={}) # Verify committed by loading loaded = store.get_bible_job(job.job_id) assert loaded is not None def test_connection_context_manager_rollback(self) -> None: """Test that connection context manager rolls back on error.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") # Try to create job with invalid data (should fail at some point) # This tests the rollback path with pytest.raises(Exception): with store._connection() as conn: conn.execute("INSERT INTO nonexistent_table VALUES (1)") class TestInitializeBooks: """Tests for book initialization.""" def test_initialize_skips_already_initialized( self, temp_data_dir: Path ) -> None: """Test that initialization skips already initialized books.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) job_id = orchestrator.create_job(books=["titus"]) # Pre-initialize with verses orchestrator.state_store.initialize_verse_jobs( job_id, "titus", [(1, 1), (1, 2)] ) # Mock DataLoader with patch("src.data.loader.DataLoader") as mock_loader: mock_instance = MagicMock() mock_loader.return_value = mock_instance # This should skip titus since it's already initialized orchestrator._initialize_all_books(job_id, ["titus"]) # get_book_chapters should not be called since book is initialized # (it has total_verses > 0) class TestJobProgressTracking: """Tests for progress tracking during translation.""" def test_verse_status_updates_book_counts(self) -> None: """Test that verse status updates also update book counts.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) store.initialize_verse_jobs(job.job_id, "john", [(1, 1), (1, 2)]) # Claim and complete first verse store.atomic_claim_verse(job.job_id, "john", 1, 1, 12345) store.update_verse_status( job.job_id, "john", 1, 1, JobStatus.COMPLETED ) book_job = store.get_book_job(job.job_id, "john") assert book_job is not None assert book_job.completed_verses == 1 # Claim and fail second verse store.atomic_claim_verse(job.job_id, "john", 1, 2, 12345) store.update_verse_status( job.job_id, "john", 1, 2, JobStatus.FAILED, error="Test" ) book_job = store.get_book_job(job.job_id, "john") assert book_job is not None assert book_job.failed_verses == 1 class TestOrchestratorParallelRun: """Tests for parallel book translation.""" def test_run_parallel_with_shutdown(self, temp_data_dir: Path) -> None: """Test parallel run handles shutdown.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=2, ) job_id = orchestrator.create_job(books=["titus", "philemon"]) # Mock _run_parallel to return empty results def mock_parallel(job_id, books, rate_limiter, shutdown): from src.orchestration.models import BookResult return [ BookResult( book=book, success=True, verses_translated=0, verses_failed=0, duration_seconds=0.1, ) for book in books ] with patch.object(orchestrator, "_initialize_all_books"): with patch.object(orchestrator, "_run_parallel", side_effect=mock_parallel): result = orchestrator.run(job_id) assert result.success is True assert result.books_completed == 2 class TestOrchestratorSequentialRun: """Tests for sequential book translation.""" def test_run_sequential_with_book_results(self, temp_data_dir: Path) -> None: """Test sequential run with mixed results.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) job_id = orchestrator.create_job(books=["titus", "philemon"]) # Mock _run_sequential to return mixed results def mock_sequential(job_id, books, rate_limiter, shutdown): from src.orchestration.models import BookResult return [ BookResult( book="titus", success=True, verses_translated=46, verses_failed=0, duration_seconds=10.0, ), BookResult( book="philemon", success=False, verses_translated=10, verses_failed=15, duration_seconds=5.0, error_message="Test error", ), ] with patch.object(orchestrator, "_initialize_all_books"): with patch.object(orchestrator, "_run_sequential", side_effect=mock_sequential): result = orchestrator.run(job_id) assert result.success is False assert result.books_completed == 1 assert result.books_failed == 1 assert result.total_verses_translated == 56 assert result.total_verses_failed == 15 class TestStateStoreMoreEdgeCases: """Additional state store edge case tests.""" def test_update_book_status_with_all_fields(self) -> None: """Test update_book_status with all optional fields.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) now = datetime.utcnow() store.update_book_status( job.job_id, "john", JobStatus.FAILED, worker_pid=12345, started_at=now, completed_at=now, error_message="Test error", ) book_job = store.get_book_job(job.job_id, "john") assert book_job is not None assert book_job.status == JobStatus.FAILED assert book_job.worker_pid == 12345 assert book_job.error_message == "Test error" def test_update_job_status_with_completed(self) -> None: """Test update_job_status with completed_at.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) now = datetime.utcnow() store.update_job_status( job.job_id, JobStatus.COMPLETED, started_at=now, completed_at=now, ) updated = store.get_bible_job(job.job_id) assert updated is not None assert updated.status == JobStatus.COMPLETED assert updated.completed_at is not None class TestOrchestratorConfigSnapshot: """Tests for configuration snapshot in orchestrator.""" def test_snapshot_without_style_config(self, temp_data_dir: Path) -> None: """Test config snapshot when style.json doesn't exist.""" # Remove style.json style_path = temp_data_dir / "config" / "style.json" if style_path.exists(): style_path.unlink() orchestrator = JobOrchestrator(data_dir=temp_data_dir) config = orchestrator._snapshot_config() # Should still have model assert "model" in config class TestRunSequentialShutdown: """Tests for sequential run with shutdown.""" def test_sequential_books_cancelled_on_shutdown( self, temp_data_dir: Path ) -> None: """Test that remaining books are cancelled when shutdown is requested.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, ) job_id = orchestrator.create_job(books=["titus", "philemon", "jude"]) # Create shutdown that's already signaled shutdown = GracefulShutdown() shutdown.request_stop() # Create limiter limiter = DummyRateLimiter(delay=0.01) # Run sequential (should immediately return cancelled for all) results = orchestrator._run_sequential( job_id, ["titus", "philemon", "jude"], limiter, shutdown ) # All should be cancelled assert len(results) == 3 for result in results: assert result.success is False assert "Cancelled" in result.error_message class TestRunParallelShutdown: """Tests for parallel run with shutdown.""" def test_parallel_shutdown_during_wait(self, temp_data_dir: Path) -> None: """Test parallel run when shutdown occurs while waiting.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=2, ) job_id = orchestrator.create_job(books=["titus", "philemon"]) # Create shutdown shutdown = GracefulShutdown() # Create limiter limiter = SharedRateLimiter(max_concurrent=2, delay=0.01) try: # Signal shutdown before running shutdown.request_stop() results = orchestrator._run_parallel( job_id, ["titus", "philemon"], limiter, shutdown ) # Books should be cancelled for result in results: assert result.success is False finally: limiter.shutdown() class TestBookWorkerException: """Tests for book worker exception handling.""" def test_worker_handles_general_exception(self, temp_data_dir: Path) -> None: """Test that worker handles general exceptions gracefully.""" state_store = JobStateStore(temp_data_dir / "jobs" / "test.db") job = state_store.create_bible_job(books=["titus"], config={}) limiter = DummyRateLimiter(delay=0.01) # Mock to raise exception with patch("src.data.loader.DataLoader") as mock_loader: mock_loader.side_effect = RuntimeError("Test error") result = _translate_book_worker( job.job_id, "titus", str(temp_data_dir), str(state_store.db_path), limiter, ) assert result.success is False assert "Test error" in result.error_message class TestRunParallelExecution: """Tests for parallel execution details.""" def test_run_parallel_future_exception(self, temp_data_dir: Path) -> None: """Test parallel run when a future raises exception.""" orchestrator = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=2, ) job_id = orchestrator.create_job(books=["titus"]) shutdown = GracefulShutdown() # Create limiter limiter = SharedRateLimiter(max_concurrent=2, delay=0.01) try: # Mock _translate_book_worker to raise an exception with patch( "src.orchestration.orchestrator._translate_book_worker", side_effect=RuntimeError("Worker error"), ): results = orchestrator._run_parallel( job_id, ["titus"], limiter, shutdown ) # Should have one failed result assert len(results) == 1 assert results[0].success is False assert "Worker error" in results[0].error_message finally: limiter.shutdown() class TestGetStatusDetails: """Tests for job status retrieval details.""" def test_get_status_with_started_job(self, temp_data_dir: Path) -> None: """Test getting status of a started job.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) job_id = orchestrator.create_job(books=["titus", "philemon"]) # Update to in_progress with started_at now = datetime.utcnow() orchestrator.state_store.update_job_status( job_id, JobStatus.IN_PROGRESS, started_at=now ) status = orchestrator.get_status(job_id) assert status["status"] == "in_progress" assert status["started_at"] is not None assert status["completed_at"] is None def test_get_status_with_completed_job(self, temp_data_dir: Path) -> None: """Test getting status of a completed job.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) job_id = orchestrator.create_job(books=["titus"]) # Update to completed now = datetime.utcnow() orchestrator.state_store.update_job_status( job_id, JobStatus.COMPLETED, started_at=now, completed_at=now ) status = orchestrator.get_status(job_id) assert status["status"] == "completed" assert status["completed_at"] is not None def test_get_status_book_progress(self, temp_data_dir: Path) -> None: """Test getting status shows book progress.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) job_id = orchestrator.create_job(books=["titus"]) # Initialize and complete some verses orchestrator.state_store.initialize_verse_jobs( job_id, "titus", [(1, 1), (1, 2), (1, 3)] ) orchestrator.state_store.atomic_claim_verse(job_id, "titus", 1, 1, 12345) orchestrator.state_store.update_verse_status( job_id, "titus", 1, 1, JobStatus.COMPLETED ) status = orchestrator.get_status(job_id) book_status = status["books"][0] assert book_status["completed_verses"] == 1 assert book_status["total_verses"] == 3 class TestCreateJobAllBooks: """Tests for creating jobs with all books.""" def test_create_job_default_all_books(self, temp_data_dir: Path) -> None: """Test that default creates all 66 books.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) # Don't pass any book specification job_id = orchestrator.create_job() job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert len(job.books) == 66 def test_create_job_nt_only_branch(self, temp_data_dir: Path) -> None: """Test nt_only branch in create_job.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) # Use nt_only flag job_id = orchestrator.create_job(nt_only=True) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert len(job.books) == 27 assert "matthew" in job.books def test_create_job_ot_only_branch(self, temp_data_dir: Path) -> None: """Test ot_only branch in create_job.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) # Use ot_only flag job_id = orchestrator.create_job(ot_only=True) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert len(job.books) == 39 assert "genesis" in job.books class TestInitializeAllBooksDetails: """Tests for book initialization details.""" def test_initialize_logs_warning_for_no_verses( self, temp_data_dir: Path ) -> None: """Test that initialization logs warning when book has no verses.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) job_id = orchestrator.create_job(books=["titus"]) # Mock loader to return no chapters with patch("src.data.loader.DataLoader") as mock_loader: mock_instance = MagicMock() mock_instance.get_book_chapters.return_value = [] mock_loader.return_value = mock_instance # Should not raise, just log warning orchestrator._initialize_all_books(job_id, ["titus"]) def test_initialize_with_verses(self, temp_data_dir: Path) -> None: """Test initialization with actual verses.""" orchestrator = JobOrchestrator(data_dir=temp_data_dir) job_id = orchestrator.create_job(books=["titus"]) # Mock loader to return some verses with patch("src.data.loader.DataLoader") as mock_loader: mock_instance = MagicMock() mock_instance.get_book_chapters.return_value = [1, 2] # Create mock verses mock_verse1 = MagicMock() mock_verse1.reference.chapter = 1 mock_verse1.reference.verse = 1 mock_verse2 = MagicMock() mock_verse2.reference.chapter = 1 mock_verse2.reference.verse = 2 mock_instance.load_chapter_verses.side_effect = [ [mock_verse1], [mock_verse2], ] mock_loader.return_value = mock_instance orchestrator._initialize_all_books(job_id, ["titus"]) # Verify verses were initialized pending = orchestrator.state_store.get_pending_verses(job_id, "titus") assert len(pending) == 2 class TestBookStatusUpdates: """Tests for book status update edge cases.""" def test_update_book_completed_with_no_error(self) -> None: """Test completing a book without error.""" with tempfile.TemporaryDirectory() as tmpdir: store = JobStateStore(Path(tmpdir) / "test.db") job = store.create_bible_job(books=["john"], config={}) now = datetime.utcnow() store.update_book_status( job.job_id, "john", JobStatus.COMPLETED, completed_at=now, ) book_job = store.get_book_job(job.job_id, "john") assert book_job is not None assert book_job.status == JobStatus.COMPLETED assert book_job.error_message is None class TestTranslateVerseWithProvenanceFunction: """Tests for the _translate_verse_with_provenance function.""" def test_translate_verse_captures_provenance(self) -> None: """Test that provenance is captured during translation.""" # Create mock translator mock_translator = MagicMock() mock_translator.model = "test-model" # Mock context mock_context = MagicMock() mock_context.target_verse.greek_text = "Greek text" mock_context.target_verse.hebrew_text = None mock_context.target_verse.english_kjv = "KJV text" mock_context.target_verse.english_asv = None mock_context.target_verse.japanese_meiji = None mock_context.target_verse.japanese_taisho = None mock_context.preceding_verses = [] mock_context.following_verses = [] mock_context.chapter_summary = None mock_context.book_metadata = None mock_context.glossary_terms = [] mock_translator.context_builder.build_context.return_value = mock_context mock_translator._get_tm_matches.return_value = None mock_translator.prompt_builder.build_prompt.return_value = "user prompt" mock_translator.prompt_builder.get_system_prompt.return_value = "system prompt" mock_translator.prompt_builder._style = None mock_translator._call_claude.return_value = "Translation result" mock_translator._add_to_tm = MagicMock() # Create mock reference from src.models.verse import VerseReference ref = VerseReference(book="john", chapter=1, verse=1) # Create mock storage mock_storage = MagicMock() # Call the function output, provenance = _translate_verse_with_provenance( mock_translator, ref, mock_storage, Path("/tmp") ) # Verify output assert output.text == "Translation result" assert output.model == "test-model" # Verify provenance assert "greek" in provenance.source_texts assert "kjv" in provenance.source_texts assert provenance.system_prompt == "system prompt" assert provenance.user_prompt == "user prompt" def test_translate_verse_with_tm_matches(self) -> None: """Test provenance captures TM matches.""" mock_translator = MagicMock() mock_translator.model = "test-model" mock_context = MagicMock() mock_context.target_verse.greek_text = "Greek" mock_context.target_verse.hebrew_text = None mock_context.target_verse.english_kjv = None mock_context.target_verse.english_asv = None mock_context.target_verse.japanese_meiji = None mock_context.target_verse.japanese_taisho = None mock_context.preceding_verses = [MagicMock()] mock_context.following_verses = [MagicMock()] mock_context.preceding_verses[0].reference = "john:1:0" mock_context.following_verses[0].reference = "john:1:2" mock_context.chapter_summary = MagicMock() mock_context.chapter_summary.summary = "Chapter summary" mock_context.book_metadata = MagicMock() mock_context.book_metadata.name_english = "John" mock_context.book_metadata.testament = "NT" mock_context.book_metadata.themes = ["love"] mock_context.glossary_terms = [] mock_translator.context_builder.build_context.return_value = mock_context from src.models.translation_memory import MatchType, TMMatch, TMSegment from src.models.verse import VerseReference as VR tm_segment = TMSegment.create( source_text="source1", target_text="target1", reference=VR(book="john", chapter=1, verse=1), ) tm_match = TMMatch(segment=tm_segment, similarity=0.9, match_type=MatchType.FUZZY) mock_translator._get_tm_matches.return_value = [tm_match] mock_translator.prompt_builder.build_prompt.return_value = "prompt" mock_translator.prompt_builder.get_system_prompt.return_value = "system" mock_translator.prompt_builder._style = MagicMock() mock_translator.prompt_builder._style.to_dict.return_value = {"key": "val"} mock_translator._call_claude.return_value = "Result" mock_translator._add_to_tm = MagicMock() from src.models.verse import VerseReference ref = VerseReference(book="john", chapter=1, verse=1) output, provenance = _translate_verse_with_provenance( mock_translator, ref, MagicMock(), Path("/tmp") ) assert len(provenance.tm_matches_used) == 1 assert provenance.tm_matches_used[0]["similarity"] == 0.9 assert provenance.chapter_summary == "Chapter summary" assert provenance.book_metadata["name"] == "John" def test_translate_verse_all_source_types(self) -> None: """Test provenance captures all source text types.""" mock_translator = MagicMock() mock_translator.model = "test-model" mock_context = MagicMock() mock_context.target_verse.greek_text = "Greek" mock_context.target_verse.hebrew_text = "Hebrew" mock_context.target_verse.english_kjv = "KJV" mock_context.target_verse.english_asv = "ASV" mock_context.target_verse.japanese_meiji = "Meiji" mock_context.target_verse.japanese_taisho = "Taisho" mock_context.preceding_verses = [] mock_context.following_verses = [] mock_context.chapter_summary = None mock_context.book_metadata = None mock_context.glossary_terms = [] mock_translator.context_builder.build_context.return_value = mock_context mock_translator._get_tm_matches.return_value = [] mock_translator.prompt_builder.build_prompt.return_value = "prompt" mock_translator.prompt_builder.get_system_prompt.return_value = "system" mock_translator.prompt_builder._style = None mock_translator._call_claude.return_value = "Result" mock_translator._add_to_tm = MagicMock() from src.models.verse import VerseReference ref = VerseReference(book="genesis", chapter=1, verse=1) output, provenance = _translate_verse_with_provenance( mock_translator, ref, MagicMock(), Path("/tmp") ) # All source types should be captured assert "greek" in provenance.source_texts assert "hebrew" in provenance.source_texts assert "kjv" in provenance.source_texts assert "asv" in provenance.source_texts assert "meiji" in provenance.source_texts assert "taisho" in provenance.source_texts