# #region Test.TranslationExecutor [C:3] [TYPE Module] [SEMANTICS test, translate, executor, orchestration] # @BRIEF Verify TranslationExecutor contracts — execute_run, _prepare_run, _process_batches, _finalize_run, delegation wrappers. # @RELATION BINDS_TO -> [TranslationExecutor] # @TEST_EDGE: missing_job -> Raises ValueError # @TEST_EDGE: no_source_rows -> Run completes immediately # @TEST_EDGE: cancellation_during_batch -> Run marked CANCELLED # @TEST_EDGE: batch_insert_failure -> Non-fatal, continues # @TEST_EDGE: all_failed -> Run marked FAILED import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import UTC, datetime from src.models.translate import TranslationJob, TranslationRun, TranslationBatch from src.plugins.translate.executor import TranslationExecutor from src.plugins.translate._run_service import RunExecutionService from .conftest import JOB_ID, RUN_ID def _make_job(session, target_langs=None): job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first() if job is None: job = TranslationJob( id=JOB_ID, name="Executor Test", source_dialect="en", target_dialect=target_langs[0] if target_langs else "fr", target_languages=target_langs or ["fr"], status="ACTIVE", provider_id="test-provider", ) session.add(job) else: if target_langs: job.target_languages = target_langs job.target_dialect = target_langs[0] session.commit() return job class TestInit: """Verify initialization.""" def test_init(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config, current_user="test-user") assert executor.db is db_session assert executor.config_manager is config assert executor.current_user == "test-user" assert executor.on_batch_progress is None assert executor._run_service is not None def test_init_with_callback(self, db_session): config = MagicMock() callback = MagicMock() executor = TranslationExecutor(db_session, config, current_user="test", on_batch_progress=callback) assert executor.on_batch_progress is callback class TestExecuteRun: """Verify execute_run full orchestration.""" @pytest.mark.asyncio async def test_missing_job_raises_error(self, db_session): """Negative: run references nonexistent job.""" config = MagicMock() executor = TranslationExecutor(db_session, config) # Create a run with a valid FK-compatible job_id, then change it to nonexistent run = TranslationRun(id="bad-run", job_id=JOB_ID, status="PENDING") db_session.add(run) db_session.commit() # Now change the job_id to a nonexistent one to trigger the ValueError run.job_id = "nonexistent" with pytest.raises(ValueError, match="not found"): await executor.execute_run(run) @pytest.mark.asyncio async def test_no_source_rows_returns_early(self, db_with_run): """Edge: no source rows to translate.""" session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=[])): result = await executor.execute_run(run) assert result.status == "COMPLETED" @pytest.mark.asyncio async def test_happy_path_full_translation(self, db_with_run): """Happy: full translation flow with batches.""" session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() run.config_snapshot = {"full_translation": True} source_rows = [ {"row_index": "0", "source_text": "Hello", "source_data": {"table": "t"}}, {"row_index": "1", "source_text": "World", "source_data": {"table": "t"}}, ] with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=source_rows)): with patch.object(executor, '_process_batch', new=AsyncMock(return_value={ "successful": 2, "failed": 0, "skipped": 0, "retries": 0, "cache_hits": 0, "batch_id": "batch-1", })): with patch.object(executor, '_insert_batch_to_target', new=AsyncMock(return_value=None)): result = await executor.execute_run(run) assert result.status == "COMPLETED" assert result.successful_records == 2 @pytest.mark.asyncio async def test_new_key_only_filtering(self, db_with_run): """Edge: new_key_only trigger filters existing keys.""" session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() run.trigger_type = "new_key_only" with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=[{"row_index": "0", "source_text": "Hello"}])): with patch.object(executor._run_service, '_filter_new_keys', return_value=[]): result = await executor.execute_run(run) assert result.status == "COMPLETED" assert result.insert_status == "skipped" @pytest.mark.asyncio async def test_cancellation_during_batches(self, db_with_run): """Edge: cancel requested during batch processing.""" session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() run.config_snapshot = {"full_translation": True} source_rows = [ {"row_index": "0", "source_text": "Hello", "source_data": {"table": "t"}}, ] with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=source_rows)): with patch.object(executor, '_process_batch', new=AsyncMock(return_value={ "successful": 1, "failed": 0, "skipped": 0, "retries": 0, "cache_hits": 0, "batch_id": "batch-1", })): with patch.object(executor, '_insert_batch_to_target', new=AsyncMock(return_value=None)): run.error_message = "CANCEL_REQUESTED" result = await executor.execute_run(run) assert result.status == "CANCELLED" class TestPrepareRun: """Verify _prepare_run.""" @pytest.mark.asyncio async def test_prepare_run_sets_running(self, db_with_run): session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=[{"row_index": "0", "source_text": "Hello"}])): result_run, rows, result_job = await executor._prepare_run(run, job) assert result_run.status == "RUNNING" assert len(rows) == 1 class TestProcessBatches: """Verify _process_batches.""" @pytest.mark.asyncio async def test_batch_insert_failure_non_fatal(self, db_with_run): """Edge: batch insert fails but processing continues.""" session, run_id = db_with_run config = MagicMock() executor = TranslationExecutor(session, config) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() batches = [ [{"row_index": "0", "source_text": "Hello", "source_data": {"table": "t"}}], ] with patch.object(executor, '_process_batch', new=AsyncMock(return_value={ "successful": 1, "failed": 0, "skipped": 0, "retries": 0, "cache_hits": 0, "batch_id": "batch-1", })): with patch.object(executor, '_insert_batch_to_target', new=AsyncMock(side_effect=ValueError("DB timeout"))): # Should not raise — insert failure is non-fatal result = await executor._process_batches(run, job, batches, ["fr"]) assert result.status is not None @pytest.mark.asyncio async def test_progress_callback_called(self, db_with_run): """Verify on_batch_progress callback.""" session, run_id = db_with_run config = MagicMock() callback = MagicMock() executor = TranslationExecutor(session, config, on_batch_progress=callback) job = _make_job(session) run = session.query(TranslationRun).filter(TranslationRun.id == run_id).first() batches = [ [{"row_index": "0", "source_text": "Hello", "source_data": {"table": "t"}}], ] with patch.object(executor, '_process_batch', new=AsyncMock(return_value={ "successful": 1, "failed": 0, "skipped": 0, "retries": 0, "cache_hits": 0, "batch_id": "batch-1", })): with patch.object(executor, '_insert_batch_to_target', new=AsyncMock(return_value=None)): await executor._process_batches(run, job, batches, ["fr"]) callback.assert_called_once() class TestFinalizeRun: """Verify _finalize_run.""" def test_all_success(self, db_session): run = TranslationRun(id="r1", job_id=JOB_ID, status="RUNNING") result = TranslationExecutor._finalize_run(run, 10, 0, 0) assert result.status == "COMPLETED" def test_all_failed(self, db_session): run = TranslationRun(id="r1", job_id=JOB_ID, status="RUNNING") result = TranslationExecutor._finalize_run(run, 0, 5, 0) assert result.status == "FAILED" assert "All 5 record(s) failed" in result.error_message def test_mixed_failed(self, db_session): run = TranslationRun(id="r1", job_id=JOB_ID, status="RUNNING") result = TranslationExecutor._finalize_run(run, 8, 2, 0) assert result.status == "COMPLETED" class TestDelegationWrappers: """Verify delegation wrapper methods (lazy imports patched at source).""" @pytest.mark.asyncio async def test_fetch_source_rows_delegates(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) with patch.object(executor._run_service, '_fetch_source_rows', new=AsyncMock(return_value=[{"row_index": "0"}])): result = await executor._fetch_source_rows("job-1", "run-1") assert result == [{"row_index": "0"}] def test_filter_new_keys_delegates(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) rows = [{"row_index": "0"}] with patch.object(executor._run_service, '_filter_new_keys', return_value=[]): result = executor._filter_new_keys(MagicMock(), "run-1", rows) assert result == [] def test_extract_chart_data_rows_static(self): rows = TranslationExecutor._extract_chart_data_rows({"data": [{"id": 1}]}) assert rows == [{"id": 1}] def test_load_preview_edits(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) with patch.object(executor._run_service, '_load_preview_edits') as mock_load: executor._load_preview_edits("job-1") mock_load.assert_called_once_with("job-1") def test_compute_key_hash_static(self): h = TranslationExecutor._compute_key_hash({"table": "test"}) assert isinstance(h, str) assert len(h) > 0 def test_resolve_provider_model_with_provider(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = "test-provider" with patch('src.plugins.translate.executor.LLMProviderService') as MockSvc: mock_instance = MockSvc.return_value mock_instance.get_provider.return_value = MagicMock(default_model="gpt-4") result = executor._resolve_provider_model(job) assert result == "gpt-4" def test_resolve_provider_model_no_provider(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = None result = executor._resolve_provider_model(job) assert result is None def test_resolve_provider_model_exception(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = "test-provider" with patch('src.plugins.translate.executor.LLMProviderService') as MockSvc: mock_instance = MockSvc.return_value mock_instance.get_provider.side_effect = ValueError("DB error") result = executor._resolve_provider_model(job) assert result is None def test_resolve_provider_config(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = "test-provider" with patch('src.plugins.translate.executor.LLMProviderService') as MockSvc: mock_instance = MockSvc.return_value mock_instance.get_provider_token_config.return_value = { "model": "gpt-4", "context_window": 8192, "max_output_tokens": 4096, } result = executor._resolve_provider_config(job) assert result["model"] == "gpt-4" def test_resolve_provider_config_no_provider(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = None result = executor._resolve_provider_config(job) assert result == {"model": None, "context_window": None, "max_output_tokens": None} def test_resolve_provider_model_alias(self, db_session): """resolve_provider_model backward compat alias.""" config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = "test-provider" with patch.object(executor, '_resolve_provider_model', return_value="gpt-4"): result = executor.resolve_provider_model(job) assert result == "gpt-4" def test_auto_size_batches(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) job = MagicMock() job.provider_id = "test-provider" with patch('src.plugins.translate._batch_sizer.AdaptiveBatchSizer') as MockSizer: mock_instance = MockSizer.return_value mock_instance.auto_size_batches.return_value = [{"rows": ["a"]}] result = executor._auto_size_batches(job, ["a"], ["fr"], "gpt-4") assert len(result) == 1 @pytest.mark.asyncio async def test_process_batch_delegates(self, db_session): """Patch at source since executor uses lazy import inside method.""" config = MagicMock() executor = TranslationExecutor(db_session, config) with patch('src.plugins.translate._batch_proc.BatchProcessingService') as MockBPS: mock_instance = MockBPS.return_value mock_instance.process_batch = AsyncMock(return_value={"successful": 1}) result = await executor._process_batch( MagicMock(), "run-1", 0, [{"row_index": "0"}], "hash1", "hash2", ) assert result["successful"] == 1 @pytest.mark.asyncio async def test_insert_batch_to_target_delegates(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) with patch('src.plugins.translate._batch_proc.BatchProcessingService') as MockBPS: mock_instance = MockBPS.return_value mock_instance.insert_batch_to_target = AsyncMock(return_value=None) await executor._insert_batch_to_target(MagicMock(), "batch-1", "run-1") @pytest.mark.asyncio async def test_call_llm_for_batch_delegates(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) with patch('src.plugins.translate._llm_call.LLMTranslationService') as MockLLM: mock_instance = MockLLM.return_value mock_instance.call_llm_for_batch = AsyncMock(return_value={"successful": 1}) result = await executor._call_llm_for_batch( MagicMock(), "run-1", [], [], "batch-1", ) assert result["successful"] == 1 @pytest.mark.asyncio async def test_call_llm_delegates(self, db_session): config = MagicMock() executor = TranslationExecutor(db_session, config) with patch('src.plugins.translate._llm_call.LLMTranslationService') as MockLLM: mock_instance = MockLLM.return_value mock_instance.call_llm = AsyncMock(return_value=("response", "stop")) result = await executor._call_llm(MagicMock(), "prompt") assert result == ("response", "stop") @pytest.mark.asyncio async def test_call_openai_compatible_static(self): with patch('src.plugins.translate._llm_call.LLMTranslationService') as MockLLM: MockLLM.call_openai_compatible = AsyncMock(return_value=("response", "stop")) result = await TranslationExecutor._call_openai_compatible( "url", "key", "model", "prompt", ) assert result == ("response", "stop") def test_parse_llm_response_static(self): with patch('src.plugins.translate._llm_call.LLMTranslationService') as MockLLM: MockLLM._parse_llm_response.return_value = {"0": {"fr": "test"}} result = TranslationExecutor._parse_llm_response('{"fr": "test"}', 1, ["fr"]) assert result == {"0": {"fr": "test"}} # #endregion Test.TranslationExecutor