421 lines
19 KiB
Python
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
|