# #region Test.BatchProcessingService [C:3] [TYPE Module] [SEMANTICS test, batch, process, classify, llm] # @BRIEF Verify BatchProcessingService contracts — process_batch, _classify, _detect_languages, _check_cache, _persist_pre. # @RELATION BINDS_TO -> [BatchProcessingService] # @TEST_EDGE: empty_batch -> Returns zeros # @TEST_EDGE: all_same_language -> No LLM call, all in pre_rows # @TEST_EDGE: all_cached -> No LLM call # @TEST_EDGE: preview_edits_cache -> Uses approved translation # @TEST_EDGE: llm_rows -> Calls LLM service import pytest from unittest.mock import AsyncMock, MagicMock, patch from src.models.translate import ( TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord, ) from src.plugins.translate._batch_proc import BatchProcessingService 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="Batch Proc 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 def _batch_rows(count=2, detected_lang="en"): return [ { "row_index": str(i), "source_text": f"Hello world {i}", "source_data": {"table": "test", "row_id": i}, "_detected_lang": detected_lang, } for i in range(count) ] class TestInit: """Verify initialization.""" def test_init(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) assert svc.db is db_session assert svc.config_manager is config assert svc._llm_service is not None class TestCreateBatch: """Verify _create_batch.""" def test_creates_batch_record(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) rows = _batch_rows(3) batch = svc._create_batch(run_id, 0, rows) assert batch.run_id == run_id assert batch.batch_index == 0 assert batch.total_records == 3 assert batch.status == "RUNNING" fetched = session.query(TranslationBatch).filter( TranslationBatch.id == batch.id ).first() assert fetched is not None class TestDetectLanguages: """Verify _detect_languages.""" def test_detects_languages(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = _batch_rows(2) with patch('src.plugins.translate._batch_proc.batch_detect', return_value=["en", "fr"]): svc._detect_languages(rows, ["fr", "de"]) assert rows[0]["_detected_lang"] == "en" assert rows[1]["_detected_lang"] == "fr" class TestCheckCache: """Verify _check_cache.""" def test_cache_miss(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) job = _make_job(db_session) rows = _batch_rows(1) with patch('src.plugins.translate._batch_proc._check_translation_cache', return_value=None): svc._check_cache(job, rows, "hash1", "hash2") assert "_cached_lang_values" not in rows[0] assert "_source_hash" in rows[0] def test_cache_hit(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) job = _make_job(db_session) rows = _batch_rows(1) with patch('src.plugins.translate._batch_proc._check_translation_cache', return_value={"fr": "Bonjour"}): svc._check_cache(job, rows, "hash1", "hash2") assert rows[0]["_cached_lang_values"] == {"fr": "Bonjour"} def test_approved_translation_skips_cache(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) job = _make_job(db_session) rows = [{"row_index": "0", "source_text": "hello", "approved_translation": "Bonjour"}] svc._check_cache(job, rows, "hash1", "hash2") assert "_cached_lang_values" not in rows[0] class TestClassify: """Verify _classify rows into llm_rows and pre_rows.""" def test_same_language_shortcut(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = _batch_rows(1, detected_lang="fr") llm_rows, pre_rows = svc._classify(rows, None, ["fr"]) assert len(pre_rows) == 1 assert len(llm_rows) == 0 assert rows[0].get("_same_language") is True def test_partial_language_match_still_llm(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = _batch_rows(1, detected_lang="fr") llm_rows, pre_rows = svc._classify(rows, None, ["fr", "de"]) assert len(pre_rows) == 0 assert len(llm_rows) == 1 def test_approved_translation_pre_row(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en", "approved_translation": "Bonjour"}] llm_rows, pre_rows = svc._classify(rows, None, ["fr"]) assert len(pre_rows) == 1 assert len(llm_rows) == 0 def test_cached_values_pre_row(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en", "_cached_lang_values": {"fr": "Bonjour"}}] llm_rows, pre_rows = svc._classify(rows, None, ["fr"]) assert len(pre_rows) == 1 assert len(llm_rows) == 0 def test_preview_edits_cache(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en", "source_data": {"table": "test", "key": "val"}}] preview_cache = {"hash_of_val": {"fr": "Bonjour"}} with patch('src.plugins.translate._batch_proc._compute_key_hash', return_value="hash_of_val"): llm_rows, pre_rows = svc._classify(rows, preview_cache, ["fr"]) assert len(pre_rows) == 1 assert len(llm_rows) == 0 assert rows[0].get("approved_translation") is not None def test_llm_row_default(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = _batch_rows(1, detected_lang="en") llm_rows, pre_rows = svc._classify(rows, None, ["fr"]) assert len(pre_rows) == 0 assert len(llm_rows) == 1 def test_und_language_goes_to_llm(self, db_session): config = MagicMock() svc = BatchProcessingService(db_session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "und"}] llm_rows, pre_rows = svc._classify(rows, None, ["fr"]) assert len(llm_rows) == 1 class TestPersistPre: """Verify _persist_pre.""" def test_persist_same_language(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) rows = [{"row_index": "0", "source_text": "Bonjour le monde", "_detected_lang": "fr", "_same_language": True}] count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"]) assert count == 1 records = session.query(TranslationRecord).all() assert len(records) == 1 assert records[0].target_sql == "Bonjour le monde" langs = session.query(TranslationLanguage).all() lang_codes = [l.language_code for l in langs] assert "fr" in lang_codes assert "de" not in lang_codes def test_persist_cached_values(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en", "_cached_lang_values": {"fr": "Bonjour", "de": "Hallo"}}] count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"]) assert count == 1 langs = session.query(TranslationLanguage).all() lang_dict = {l.language_code: l.final_value for l in langs} assert lang_dict["fr"] == "Bonjour" assert lang_dict["de"] == "Hallo" def test_persist_approved_translation(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en", "approved_translation": "Bonjour le monde"}] count = svc._persist_pre(rows, "batch-1", run_id, ["fr"]) assert count == 1 records = session.query(TranslationRecord).all() assert records[0].target_sql == "Bonjour le monde" class TestProcessLlm: """Verify _process_llm.""" @pytest.mark.asyncio async def test_process_llm_calls_service(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session) rows = _batch_rows(1) with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv: mock_prov = MockProv.return_value mock_prov.get_provider_token_config.return_value = { "model": "gpt-4", "context_window": 8192, "max_output_tokens": 4096, } with patch('src.plugins.translate._batch_proc.estimate_token_budget', return_value={ "max_output_needed": 1000, "max_rows_by_output": 10, "estimated_input_tokens": 500, "warning": None, }): with patch.object(svc._llm_service, 'call_llm_for_batch', new=AsyncMock(return_value={"successful": 1, "failed": 0, "skipped": 0, "retries": 0})): result = await svc._process_llm( job, run_id, rows, [], "batch-1", ["fr"], ) assert result["successful"] == 1 @pytest.mark.asyncio async def test_process_llm_token_warning(self, db_with_run): session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session) rows = _batch_rows(1) with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv: mock_prov = MockProv.return_value mock_prov.get_provider_token_config.return_value = { "model": "gpt-4", "context_window": 100, "max_output_tokens": 50, } with patch('src.plugins.translate._batch_proc.estimate_token_budget', return_value={ "max_output_needed": 2000, "max_rows_by_output": 1, "estimated_input_tokens": 5000, "warning": "High token usage", }): with patch.object(svc._llm_service, 'call_llm_for_batch', new=AsyncMock(return_value={"successful": 1, "failed": 0, "skipped": 0, "retries": 0})): result = await svc._process_llm( job, run_id, rows, [], "batch-1", ["fr"], ) assert result["successful"] == 1 class TestProcessBatch: """Verify process_batch orchestration.""" @pytest.mark.asyncio async def test_full_batch_flow(self, db_with_run): """Happy: full process_batch flow with LLM calls.""" session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session, target_langs=["fr"]) rows = _batch_rows(2, detected_lang="en") with patch.object(svc, '_process_llm', new=AsyncMock(return_value={"successful": 2, "failed": 0, "skipped": 0, "retries": 0})): with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch', return_value=[]): result = await svc.process_batch( job=job, run_id=run_id, batch_index=0, batch_rows=rows, ) assert result["successful"] == 2 assert "batch_id" in result batch = session.query(TranslationBatch).filter( TranslationBatch.id == result["batch_id"] ).first() assert batch is not None assert batch.status == "COMPLETED" assert batch.successful_records == 2 @pytest.mark.asyncio async def test_batch_all_same_language(self, db_with_run): """All rows are same-language — no LLM calls.""" session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session, target_langs=["fr"]) rows = _batch_rows(2, detected_lang="fr") result = await svc.process_batch( job=job, run_id=run_id, batch_index=0, batch_rows=rows, ) assert result["successful"] == 2 assert result.get("cache_hits", 0) == 0 records = session.query(TranslationRecord).all() assert len(records) == 2 @pytest.mark.asyncio async def test_batch_with_errors(self, db_with_run): """Some LLM rows fail.""" session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session, target_langs=["fr"]) rows = _batch_rows(2, detected_lang="en") with patch.object(svc, '_process_llm', new=AsyncMock(return_value={"successful": 0, "failed": 2, "skipped": 0, "retries": 3})): with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch', return_value=[]): result = await svc.process_batch( job=job, run_id=run_id, batch_index=0, batch_rows=rows, ) assert result["failed"] == 2 batch = session.query(TranslationBatch).filter( TranslationBatch.id == result["batch_id"] ).first() assert batch.status == "COMPLETED_WITH_ERRORS" @pytest.mark.asyncio async def test_batch_with_cache_hits(self, db_with_run): """Cache hits counted.""" session, run_id = db_with_run config = MagicMock() svc = BatchProcessingService(session, config) job = _make_job(session, target_langs=["fr"]) rows = _batch_rows(2, detected_lang="en") for r in rows: r["_cached_lang_values"] = {"fr": "Bonjour"} result = await svc.process_batch( job=job, run_id=run_id, batch_index=0, batch_rows=rows, ) assert result["successful"] == 2 assert result.get("cache_hits", 0) == 2 # #endregion Test.BatchProcessingService