Files
ss-tools/backend/tests/plugins/translate/test_executor.py

421 lines
19 KiB
Python

# #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