"""Tests for the job orchestrator.""" from __future__ import annotations import tempfile from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch import pytest from src.orchestration.models import ( ALL_BOOKS, NEW_TESTAMENT_BOOKS, OLD_TESTAMENT_BOOKS, JobStatus, ) from src.orchestration.orchestrator import JobOrchestrator from src.orchestration.state import JobStateStore @pytest.fixture def temp_data_dir() -> Path: """Create a temporary data directory.""" with tempfile.TemporaryDirectory() as tmpdir: data_dir = Path(tmpdir) # Create required subdirectories (data_dir / "sources" / "greek").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) # Create minimal config import json with (data_dir / "config" / "style.json").open("w") as f: json.dump({ "equivalence_approach": "optimal", "readability_level": "general_public", }, f) yield data_dir @pytest.fixture def orchestrator(temp_data_dir: Path) -> JobOrchestrator: """Create an orchestrator with temporary directory.""" return JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=1, max_concurrent_api_calls=2, ) class TestJobOrchestratorInit: """Tests for JobOrchestrator initialization.""" def test_init(self, temp_data_dir: Path) -> None: """Test basic initialization.""" orch = JobOrchestrator(data_dir=temp_data_dir) assert orch.data_dir == temp_data_dir assert orch.max_parallel_books == 4 # Default assert orch.max_concurrent_api_calls == 5 # Default def test_custom_parallelism(self, temp_data_dir: Path) -> None: """Test initialization with custom parallelism.""" orch = JobOrchestrator( data_dir=temp_data_dir, max_parallel_books=8, max_concurrent_api_calls=10, ) assert orch.max_parallel_books == 8 assert orch.max_concurrent_api_calls == 10 class TestCreateJob: """Tests for job creation.""" def test_create_job_all_books(self, orchestrator: JobOrchestrator) -> None: """Test creating a job for all books.""" job_id = orchestrator.create_job() job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert len(job.books) == 66 assert job.status == JobStatus.PENDING def test_create_job_specific_books( self, orchestrator: JobOrchestrator ) -> None: """Test creating a job for specific books.""" job_id = orchestrator.create_job(books=["john", "matthew"]) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert job.books == ["john", "matthew"] def test_create_job_nt_only(self, orchestrator: JobOrchestrator) -> None: """Test creating a job for NT only.""" 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 job.books == list(NEW_TESTAMENT_BOOKS) def test_create_job_ot_only(self, orchestrator: JobOrchestrator) -> None: """Test creating a job for OT only.""" 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 job.books == list(OLD_TESTAMENT_BOOKS) def test_create_job_with_custom_id( self, orchestrator: JobOrchestrator ) -> None: """Test creating a job with a custom ID.""" job_id = orchestrator.create_job( books=["john"], job_id="my-custom-job-id", ) assert job_id == "my-custom-job-id" def test_create_job_invalid_book( self, orchestrator: JobOrchestrator ) -> None: """Test that invalid book names raise an error.""" with pytest.raises(ValueError, match="Invalid book names"): orchestrator.create_job(books=["invalid_book", "john"]) def test_create_job_config_snapshot( self, orchestrator: JobOrchestrator, temp_data_dir: Path ) -> None: """Test that job captures config snapshot.""" job_id = orchestrator.create_job(books=["john"]) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert "style" in job.config_snapshot or "model" in job.config_snapshot class TestGetStatus: """Tests for job status retrieval.""" def test_get_status_existing_job( self, orchestrator: JobOrchestrator ) -> None: """Test getting status of an existing job.""" job_id = orchestrator.create_job(books=["john", "matthew"]) status = orchestrator.get_status(job_id) assert status["job_id"] == job_id assert status["status"] == "pending" assert status["total_books"] == 2 assert len(status["books"]) == 2 def test_get_status_nonexistent_job( self, orchestrator: JobOrchestrator ) -> None: """Test getting status of non-existent job.""" with pytest.raises(ValueError, match="Job not found"): orchestrator.get_status("nonexistent-id") class TestRunJob: """Tests for running jobs.""" def test_run_nonexistent_job(self, orchestrator: JobOrchestrator) -> None: """Test running a non-existent job raises error.""" with pytest.raises(ValueError, match="Job not found"): orchestrator.run("nonexistent-job") def test_run_completed_job(self, orchestrator: JobOrchestrator) -> None: """Test running an already completed job raises error.""" job_id = orchestrator.create_job(books=["john"]) orchestrator.state_store.update_job_status(job_id, JobStatus.COMPLETED) with pytest.raises(ValueError, match="already completed"): orchestrator.run(job_id) class TestResumeJob: """Tests for job resumption.""" def test_resume_nonexistent_job( self, orchestrator: JobOrchestrator ) -> None: """Test resuming a non-existent job by ID.""" with pytest.raises(ValueError, match="Job not found"): orchestrator.resume(job_id="nonexistent-id") def test_resume_no_incomplete_jobs( self, orchestrator: JobOrchestrator ) -> None: """Test resuming when no incomplete jobs exist.""" with pytest.raises(ValueError, match="No incomplete jobs"): orchestrator.resume() class TestStateStoreIntegration: """Tests for state store integration.""" def test_state_persists_across_instances( self, temp_data_dir: Path ) -> None: """Test that state persists across orchestrator instances.""" # Create job with first orchestrator orch1 = JobOrchestrator(data_dir=temp_data_dir) job_id = orch1.create_job(books=["john"]) # Load with second orchestrator orch2 = JobOrchestrator(data_dir=temp_data_dir) job = orch2.state_store.get_bible_job(job_id) assert job is not None assert job.books == ["john"] class TestOrphanedVerseRecovery: """Tests for orphaned verse recovery.""" def test_reset_orphaned_on_resume( self, orchestrator: JobOrchestrator ) -> None: """Test that orphaned verses are reset on resume.""" # Create job and initialize verses job_id = orchestrator.create_job(books=["john"]) orchestrator.state_store.initialize_verse_jobs( job_id, "john", [(1, 1), (1, 2)] ) # Claim verses (simulating in-progress state) orchestrator.state_store.atomic_claim_verse( job_id, "john", 1, 1, worker_pid=12345 ) # Verify verses are in_progress pending_before = orchestrator.state_store.get_pending_verses( job_id, "john" ) assert len(pending_before) == 1 # Only unclaimed verse # Reset orphaned verses reset_count = orchestrator.state_store.reset_orphaned_verses(job_id) assert reset_count == 1 # Verify verses are pending again pending_after = orchestrator.state_store.get_pending_verses( job_id, "john" ) assert len(pending_after) == 2 class TestConfigSnapshot: """Tests for configuration snapshotting.""" def test_snapshot_includes_model( self, orchestrator: JobOrchestrator ) -> None: """Test that config snapshot includes model info.""" job_id = orchestrator.create_job(books=["john"]) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert "model" in job.config_snapshot def test_snapshot_includes_style( self, orchestrator: JobOrchestrator, temp_data_dir: Path ) -> None: """Test that config snapshot includes style if available.""" job_id = orchestrator.create_job(books=["john"]) job = orchestrator.state_store.get_bible_job(job_id) assert job is not None assert "style" in job.config_snapshot class TestBookJobCreation: """Tests for book job creation.""" def test_book_jobs_created_with_bible_job( self, orchestrator: JobOrchestrator ) -> None: """Test that book jobs are created when Bible job is created.""" job_id = orchestrator.create_job(books=["john", "matthew", "luke"]) book_jobs = orchestrator.state_store.get_book_jobs(job_id) assert len(book_jobs) == 3 book_names = {bj.book for bj in book_jobs} assert book_names == {"john", "matthew", "luke"} def test_all_book_jobs_pending( self, orchestrator: JobOrchestrator ) -> None: """Test that all book jobs start as pending.""" job_id = orchestrator.create_job(books=["john", "matthew"]) book_jobs = orchestrator.state_store.get_book_jobs(job_id) for bj in book_jobs: assert bj.status == JobStatus.PENDING