test: 6 parallel agents — +40 test files across core, agent, translate, services, API routes, dataset_review. core 100%, agent 100%, services 100%, translate plugin mostly done. Pending: ~10 minor failures to fix
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# #region Test.DictionaryValidation [C:2] [TYPE Module] [SEMANTICS test, translate, validation, bcp47]
|
||||
# @BRIEF Tests for dictionary_validation.py — _validate_bcp47.
|
||||
# @RELATION BINDS_TO -> [_validate_bcp47]
|
||||
# @TEST_CONTRACT: _validate_bcp47 -> None | raises ValueError on invalid tag
|
||||
# @TEST_EDGE: empty_tag -> ValueError
|
||||
# @TEST_EDGE: invalid_format -> ValueError
|
||||
# @TEST_EDGE: valid_format -> None
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.dictionary_validation import _validate_bcp47
|
||||
|
||||
|
||||
class TestValidateBcp47:
|
||||
"""_validate_bcp47 — BCP-47 language tag validation."""
|
||||
|
||||
def test_valid_basic_tag(self):
|
||||
"""Simple language code like 'en' passes."""
|
||||
_validate_bcp47("en", "language") # no error
|
||||
|
||||
def test_valid_region_tag(self):
|
||||
"""Tag with region like 'zh-CN' passes."""
|
||||
_validate_bcp47("zh-CN", "language")
|
||||
|
||||
def test_valid_script_tag(self):
|
||||
"""Tag with script like 'zh-Hans' passes."""
|
||||
_validate_bcp47("zh-Hans", "language")
|
||||
|
||||
def test_valid_multi_subtag(self):
|
||||
"""Complex tag like 'sr-Latn-RS' passes."""
|
||||
_validate_bcp47("sr-Latn-RS", "language")
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
"""Empty tag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a non-empty"):
|
||||
_validate_bcp47("", "language")
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
"""Whitespace-only tag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a non-empty"):
|
||||
_validate_bcp47(" ", "language")
|
||||
|
||||
def test_none_raises(self):
|
||||
"""None tag raises ValueError."""
|
||||
with pytest.raises(ValueError, match="must be a non-empty"):
|
||||
_validate_bcp47(None, "language") # type: ignore[arg-type]
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
"""Tag with invalid characters raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not a valid BCP-47"):
|
||||
_validate_bcp47("en@utf8", "language")
|
||||
|
||||
def test_invalid_numeric_start_raises(self):
|
||||
"""Tag starting with a number raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not a valid BCP-47"):
|
||||
_validate_bcp47("123", "language")
|
||||
|
||||
def test_error_message_includes_field_name(self):
|
||||
"""Error message includes the field_name parameter."""
|
||||
with pytest.raises(ValueError, match="source_language"):
|
||||
_validate_bcp47("", "source_language")
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
"""Whitespace around tag is stripped."""
|
||||
_validate_bcp47(" en ", "language") # no error after strip
|
||||
# #endregion Test.DictionaryValidation
|
||||
275
backend/tests/plugins/translate/test_metrics.py
Normal file
275
backend/tests/plugins/translate/test_metrics.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# #region Test.Metrics [C:3] [TYPE Module] [SEMANTICS test, translate, metrics, job, statistics]
|
||||
# @BRIEF Tests for metrics.py — TranslationMetrics.get_job_metrics, get_all_metrics.
|
||||
# @RELATION BINDS_TO -> [TranslationMetrics]
|
||||
# @TEST_CONTRACT: get_job_metrics -> dict | aggregated job metrics
|
||||
# @TEST_CONTRACT: get_all_metrics -> list[dict] | aggregated metrics for all jobs
|
||||
# @TEST_EDGE: no_runs
|
||||
# @TEST_EDGE: per_language_aggregation
|
||||
# @TEST_EDGE: metric_snapshot_merge
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.metrics import TranslationMetrics
|
||||
|
||||
|
||||
class _MockDB:
|
||||
"""Helper to create a DB mock with independent query mocks per call."""
|
||||
|
||||
@staticmethod
|
||||
def create(query_count: int = 8):
|
||||
"""Create db mock where each db.query() call returns an independent mock."""
|
||||
db = MagicMock()
|
||||
query_mocks = [MagicMock() for _ in range(query_count)]
|
||||
db.query.side_effect = query_mocks
|
||||
return db, query_mocks
|
||||
|
||||
|
||||
class TestGetJobMetrics:
|
||||
"""TranslationMetrics.get_job_metrics — aggregated job metrics."""
|
||||
|
||||
def _make_lang_stat(self, code: str, **kwargs):
|
||||
ls = MagicMock()
|
||||
ls.language_code = code
|
||||
ls.token_count = 1000
|
||||
ls.estimated_cost = 0.002
|
||||
ls.translated_rows = 50
|
||||
for k, v in kwargs.items():
|
||||
setattr(ls, k, v)
|
||||
return ls
|
||||
|
||||
def test_basic_metrics(self):
|
||||
"""Returns dict with all required keys."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
# 0: run_counts — group_by().all()
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = [
|
||||
("COMPLETED", 5), ("FAILED", 2),
|
||||
]
|
||||
# 1: record_stats — first()
|
||||
q[1].filter.return_value.first.return_value = (500, 480, 10, 10)
|
||||
# 2: avg_duration — filter(cond1, cond2, cond3).scalar() — single filter with 3 args
|
||||
q[2].filter.return_value.scalar.return_value = 120000.0
|
||||
# 3: last_run — filter().order_by().first()
|
||||
last_run = MagicMock()
|
||||
last_run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = last_run
|
||||
# 4: next_schedule — filter().first()
|
||||
next_schedule = MagicMock()
|
||||
next_schedule.last_run_at = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc)
|
||||
q[4].filter.return_value.first.return_value = next_schedule
|
||||
# 5: latest_snapshot (1st call) — filter().order_by().first()
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
# 6: lang_stats_rows — join().filter().all()
|
||||
lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")]
|
||||
q[6].join.return_value.filter.return_value.all.return_value = lang_stats
|
||||
# 7: latest_snapshot (2nd call, same query) — filter().order_by().first()
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert result["job_id"] == "job-1"
|
||||
assert result["total_runs"] == 7
|
||||
assert result["successful_runs"] == 5
|
||||
assert result["failed_runs"] == 2
|
||||
assert result["cancelled_runs"] == 0
|
||||
assert result["total_records"] == 500
|
||||
assert result["successful_records"] == 480
|
||||
assert result["failed_records"] == 10
|
||||
assert result["skipped_records"] == 10
|
||||
assert result["cumulative_tokens"] == 2000
|
||||
assert result["cumulative_cost"] == 0.004
|
||||
assert result["avg_duration_ms"] == 120000
|
||||
assert result["last_run_at"] is not None
|
||||
assert result["next_scheduled_run"] is not None
|
||||
assert len(result["per_language_metrics"]) == 2
|
||||
assert "ru" in result["per_language_metrics"]
|
||||
assert "de" in result["per_language_metrics"]
|
||||
|
||||
def test_no_runs(self):
|
||||
"""No runs returns zero metrics."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = []
|
||||
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
|
||||
q[2].filter.return_value.scalar.return_value = None
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[4].filter.return_value.first.return_value = None
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[6].join.return_value.filter.return_value.all.return_value = []
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert result["total_runs"] == 0
|
||||
assert result["total_records"] == 0
|
||||
assert result["avg_duration_ms"] is None
|
||||
assert result["last_run_at"] is None
|
||||
assert result["next_scheduled_run"] is None
|
||||
assert result["per_language_metrics"] == {}
|
||||
|
||||
def test_avg_duration_none_when_no_timed_runs(self):
|
||||
"""No completed runs => avg_duration_ms is None."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = [("PENDING", 1)]
|
||||
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
|
||||
q[2].filter.return_value.scalar.return_value = None
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[4].filter.return_value.first.return_value = None
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[6].join.return_value.filter.return_value.all.return_value = []
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert result["avg_duration_ms"] is None
|
||||
|
||||
def test_no_next_schedule(self):
|
||||
"""No active schedule => next_scheduled_run is None."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = []
|
||||
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
|
||||
q[2].filter.return_value.scalar.return_value = None
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[4].filter.return_value.first.return_value = None
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[6].join.return_value.filter.return_value.all.return_value = []
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert result["next_scheduled_run"] is None
|
||||
|
||||
def test_per_language_with_snapshot_merge(self):
|
||||
"""MetricSnapshot per_language_metrics merged into result."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)]
|
||||
q[1].filter.return_value.first.return_value = (100, 95, 3, 2)
|
||||
q[2].filter.return_value.scalar.return_value = 50000.0
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run
|
||||
q[4].filter.return_value.first.return_value = None # no next_schedule
|
||||
# 5: latest_snapshot (1st)
|
||||
snapshot1 = MagicMock()
|
||||
snapshot1.per_language_metrics = None
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = snapshot1
|
||||
# 6: lang_stats_rows (empty)
|
||||
q[6].join.return_value.filter.return_value.all.return_value = []
|
||||
# 7: latest_snapshot (2nd) with per_language_metrics
|
||||
snapshot2 = MagicMock()
|
||||
snapshot2.per_language_metrics = {
|
||||
"ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10},
|
||||
}
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = snapshot2
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert "ru" in result["per_language_metrics"]
|
||||
assert result["cumulative_tokens"] == 5000
|
||||
assert result["cumulative_cost"] == 0.01
|
||||
|
||||
def test_per_language_with_live_and_snapshot(self):
|
||||
"""Live stats + snapshot metrics merge correctly."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)]
|
||||
q[1].filter.return_value.first.return_value = (100, 95, 3, 2)
|
||||
q[2].filter.return_value.scalar.return_value = 50000.0
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run
|
||||
q[4].filter.return_value.first.return_value = None # no next_schedule
|
||||
# 5: latest_snapshot (1st)
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
# 6: lang_stats_rows — live data
|
||||
q[6].join.return_value.filter.return_value.all.return_value = [
|
||||
self._make_lang_stat("ru", token_count=500, estimated_cost=0.001, translated_rows=25),
|
||||
self._make_lang_stat("fr", token_count=200, estimated_cost=0.0005, translated_rows=10),
|
||||
]
|
||||
# 7: latest_snapshot (2nd)
|
||||
snapshot = MagicMock()
|
||||
snapshot.per_language_metrics = {
|
||||
"ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10},
|
||||
"de": {"cumulative_tokens": 3000, "cumulative_cost": 0.006, "runs": 5},
|
||||
}
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = snapshot
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert "ru" in result["per_language_metrics"]
|
||||
assert "de" in result["per_language_metrics"]
|
||||
assert "fr" in result["per_language_metrics"]
|
||||
# ru: live 500 + snapshot 5000 = 5500
|
||||
assert result["per_language_metrics"]["ru"]["tokens"] == 5500
|
||||
assert result["per_language_metrics"]["ru"]["cost"] == 0.011
|
||||
assert result["per_language_metrics"]["fr"]["tokens"] == 200
|
||||
assert result["per_language_metrics"]["de"]["tokens"] == 3000
|
||||
|
||||
def test_no_last_run_returns_none(self):
|
||||
"""No last run => last_run_at is None."""
|
||||
db, q = _MockDB.create()
|
||||
|
||||
q[0].filter.return_value.group_by.return_value.all.return_value = []
|
||||
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
|
||||
q[2].filter.return_value.scalar.return_value = None
|
||||
q[3].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[4].filter.return_value.first.return_value = None
|
||||
q[5].filter.return_value.order_by.return_value.first.return_value = None
|
||||
q[6].join.return_value.filter.return_value.all.return_value = []
|
||||
q[7].filter.return_value.order_by.return_value.first.return_value = None
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_job_metrics("job-1")
|
||||
|
||||
assert result["last_run_at"] is None
|
||||
|
||||
|
||||
class TestGetAllMetrics:
|
||||
"""TranslationMetrics.get_all_metrics — metrics for all jobs."""
|
||||
|
||||
def test_all_metrics(self):
|
||||
"""Returns list of per-job metrics."""
|
||||
db = MagicMock()
|
||||
db.query.return_value.distinct.return_value.all.return_value = [
|
||||
("job-1",), ("job-2",),
|
||||
]
|
||||
|
||||
with patch.object(TranslationMetrics, "get_job_metrics") as mock_get:
|
||||
mock_get.side_effect = [
|
||||
{"job_id": "job-1", "total_runs": 5},
|
||||
{"job_id": "job-2", "total_runs": 3},
|
||||
]
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_all_metrics()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["job_id"] == "job-1"
|
||||
assert result[1]["job_id"] == "job-2"
|
||||
|
||||
def test_empty_jobs(self):
|
||||
"""No jobs returns empty list."""
|
||||
db = MagicMock()
|
||||
db.query.return_value.distinct.return_value.all.return_value = []
|
||||
|
||||
metrics = TranslationMetrics(db)
|
||||
result = metrics.get_all_metrics()
|
||||
|
||||
assert result == []
|
||||
# #endregion Test.Metrics
|
||||
212
backend/tests/plugins/translate/test_orchestrator.py
Normal file
212
backend/tests/plugins/translate/test_orchestrator.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# #region Test.Orchestrator [C:3] [TYPE Module] [SEMANTICS test, translate, orchestrator, lifecycle]
|
||||
# @BRIEF Tests for orchestrator.py — TranslationOrchestrator delegates to sub-components.
|
||||
# @RELATION BINDS_TO -> [TranslationOrchestrator]
|
||||
# @TEST_CONTRACT: start_run -> TranslationRun | delegates to TranslationPlanner
|
||||
# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationStageRunner
|
||||
# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationStageRunner
|
||||
# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationStageRunner
|
||||
# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationStageRunner
|
||||
# @TEST_CONTRACT: get_run_status -> dict | delegates to TranslationResultAggregator
|
||||
# @TEST_CONTRACT: get_run_records -> dict | delegates to TranslationResultAggregator
|
||||
# @TEST_CONTRACT: get_run_history -> tuple | delegates to TranslationResultAggregator
|
||||
# @TEST_EDGE: sql_insert_service_backward_compat
|
||||
# @TEST_EDGE: language_stats_updates
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator import TranslationOrchestrator
|
||||
|
||||
|
||||
class TestTranslationOrchestrator:
|
||||
"""TranslationOrchestrator — delegates all operations to sub-components."""
|
||||
|
||||
def _make_orchestrator(self):
|
||||
"""Create orchestrator with mocked sub-components."""
|
||||
db = MagicMock()
|
||||
config = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator.TranslationPlanner"
|
||||
) as MockPlanner, patch(
|
||||
"src.plugins.translate.orchestrator.TranslationStageRunner"
|
||||
) as MockRunner, patch(
|
||||
"src.plugins.translate.orchestrator.TranslationResultAggregator"
|
||||
) as MockAggregator:
|
||||
orch = TranslationOrchestrator(db, config, "user")
|
||||
return orch, db, config, MockPlanner, MockRunner, MockAggregator
|
||||
|
||||
def test_start_run_delegates(self):
|
||||
"""start_run delegates to TranslationPlanner."""
|
||||
orch, _, _, MockPlanner, _, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._planner = MockPlanner.return_value
|
||||
orch._planner.plan_run.return_value = mock_run
|
||||
|
||||
result = orch.start_run("job-1", is_scheduled=True, trigger_type="cron", full_translation=True)
|
||||
|
||||
orch._planner.plan_run.assert_called_once_with(
|
||||
job_id="job-1", is_scheduled=True, trigger_type="cron", full_translation=True,
|
||||
)
|
||||
assert result == mock_run
|
||||
|
||||
def test_start_run_minimal(self):
|
||||
"""start_run with only job_id uses defaults."""
|
||||
orch, _, _, MockPlanner, _, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._planner = MockPlanner.return_value
|
||||
orch._planner.plan_run.return_value = mock_run
|
||||
|
||||
result = orch.start_run("job-1")
|
||||
|
||||
orch._planner.plan_run.assert_called_once_with(
|
||||
job_id="job-1", is_scheduled=False, trigger_type=None, full_translation=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_run_delegates(self):
|
||||
"""execute_run delegates to TranslationStageRunner."""
|
||||
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._runner = MockRunner.return_value
|
||||
orch._runner.execute_run = AsyncMock(return_value=mock_run)
|
||||
|
||||
progress = MagicMock()
|
||||
result = await orch.execute_run(mock_run, on_batch_progress=progress, skip_insert=True)
|
||||
|
||||
orch._runner.execute_run.assert_called_once_with(
|
||||
run=mock_run, on_batch_progress=progress, skip_insert=True,
|
||||
)
|
||||
assert result == mock_run
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_batches_delegates(self):
|
||||
"""retry_failed_batches delegates to TranslationStageRunner."""
|
||||
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._runner = MockRunner.return_value
|
||||
orch._runner.retry_failed_batches = AsyncMock(return_value=mock_run)
|
||||
|
||||
result = await orch.retry_failed_batches("run-1")
|
||||
|
||||
orch._runner.retry_failed_batches.assert_called_once_with("run-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_insert_delegates(self):
|
||||
"""retry_insert delegates to TranslationStageRunner."""
|
||||
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._runner = MockRunner.return_value
|
||||
orch._runner.retry_insert = AsyncMock(return_value=mock_run)
|
||||
|
||||
result = await orch.retry_insert("run-1")
|
||||
|
||||
orch._runner.retry_insert.assert_called_once_with("run-1")
|
||||
|
||||
def test_cancel_run_delegates(self):
|
||||
"""cancel_run delegates to TranslationStageRunner."""
|
||||
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
|
||||
mock_run = MagicMock()
|
||||
orch._runner = MockRunner.return_value
|
||||
orch._runner.cancel_run.return_value = mock_run
|
||||
|
||||
result = orch.cancel_run("run-1")
|
||||
|
||||
orch._runner.cancel_run.assert_called_once_with("run-1")
|
||||
|
||||
def test_get_run_status_delegates(self):
|
||||
"""get_run_status delegates to TranslationResultAggregator."""
|
||||
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
|
||||
orch._aggregator = MockAggregator.return_value
|
||||
orch._aggregator.get_run_status.return_value = {"status": "COMPLETED"}
|
||||
|
||||
result = orch.get_run_status("run-1")
|
||||
|
||||
orch._aggregator.get_run_status.assert_called_once_with("run-1")
|
||||
|
||||
def test_get_run_records_delegates(self):
|
||||
"""get_run_records delegates to TranslationResultAggregator."""
|
||||
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
|
||||
orch._aggregator = MockAggregator.return_value
|
||||
orch._aggregator.get_run_records.return_value = {"items": [], "total": 0}
|
||||
|
||||
result = orch.get_run_records("run-1", page=2, page_size=10, status_filter="failed",
|
||||
deduplicate=True)
|
||||
|
||||
orch._aggregator.get_run_records.assert_called_once_with(
|
||||
"run-1", 2, 10, "failed", deduplicate=True,
|
||||
)
|
||||
|
||||
def test_get_run_history_delegates(self):
|
||||
"""get_run_history delegates to TranslationResultAggregator."""
|
||||
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
|
||||
orch._aggregator = MockAggregator.return_value
|
||||
orch._aggregator.get_run_history.return_value = (5, [{"id": "r1"}])
|
||||
|
||||
total, items = orch.get_run_history("job-1", page=1, page_size=20)
|
||||
|
||||
orch._aggregator.get_run_history.assert_called_once_with("job-1", 1, 20)
|
||||
assert total == 5
|
||||
assert items == [{"id": "r1"}]
|
||||
|
||||
def test_generate_and_insert_sql_backward_compat(self):
|
||||
"""_generate_and_insert_sql creates SQLInsertService and delegates."""
|
||||
orch, db, config, _, _, _ = self._make_orchestrator()
|
||||
mock_job = MagicMock()
|
||||
mock_run = MagicMock()
|
||||
|
||||
# SQLInsertService is imported INSIDE the method from .orchestrator_sql
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_sql.SQLInsertService"
|
||||
) as MockSQL:
|
||||
mock_svc = MockSQL.return_value
|
||||
mock_svc.generate_and_insert_sql.return_value = {"status": "success"}
|
||||
|
||||
import asyncio
|
||||
result = asyncio.run(orch._generate_and_insert_sql(mock_job, mock_run))
|
||||
|
||||
MockSQL.assert_called_once_with(db, config, orch.event_log)
|
||||
mock_svc.generate_and_insert_sql.assert_called_once_with(mock_job, mock_run)
|
||||
|
||||
def test_update_language_stats_delegates(self):
|
||||
"""_update_language_stats delegates to TranslationResultAggregator."""
|
||||
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
|
||||
orch._aggregator = MockAggregator.return_value
|
||||
stats_map = {"ru": MagicMock()}
|
||||
|
||||
orch._update_language_stats("run-1", stats_map)
|
||||
|
||||
orch._aggregator.update_language_stats.assert_called_once_with("run-1", stats_map)
|
||||
|
||||
def test_initializes_event_log_and_components(self):
|
||||
"""Constructor initializes all sub-components."""
|
||||
db = MagicMock()
|
||||
config = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator.TranslationPlanner"
|
||||
) as MockPlanner, patch(
|
||||
"src.plugins.translate.orchestrator.TranslationStageRunner"
|
||||
) as MockRunner, patch(
|
||||
"src.plugins.translate.orchestrator.TranslationResultAggregator"
|
||||
) as MockAggregator:
|
||||
orch = TranslationOrchestrator(db, config, "user")
|
||||
|
||||
assert orch.db == db
|
||||
assert orch.config_manager == config
|
||||
assert orch.current_user == "user"
|
||||
assert orch.event_log is not None
|
||||
MockPlanner.assert_called_once_with(db, orch.event_log, "user")
|
||||
MockRunner.assert_called_once_with(db, config, orch.event_log, "user")
|
||||
MockAggregator.assert_called_once_with(db, orch.event_log)
|
||||
# #endregion Test.Orchestrator
|
||||
220
backend/tests/plugins/translate/test_orchestrator_aggregator.py
Normal file
220
backend/tests/plugins/translate/test_orchestrator_aggregator.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# #region Test.OrchestratorAggregator [C:3] [TYPE Module] [SEMANTICS test, translate, aggregator, records, history]
|
||||
# @BRIEF Tests for orchestrator_aggregator.py — TranslationResultAggregator.
|
||||
# @RELATION BINDS_TO -> [TranslationResultAggregator]
|
||||
# @TEST_CONTRACT: get_run_status -> dict | run status with statistics
|
||||
# @TEST_CONTRACT: get_run_records -> dict | delegated to orchestrator_query
|
||||
# @TEST_CONTRACT: get_run_history -> tuple[int, list] | delegated to orchestrator_query
|
||||
# @TEST_CONTRACT: update_language_stats -> None | delegated to orchestrator_lang_stats
|
||||
# @TEST_EDGE: missing_run
|
||||
# @TEST_EDGE: empty_language_stats
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_aggregator import TranslationResultAggregator
|
||||
|
||||
|
||||
class TestGetRunStatus:
|
||||
"""TranslationResultAggregator.get_run_status — run status with stats."""
|
||||
|
||||
def _make_run(self, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
run.status = "COMPLETED"
|
||||
run.trigger_type = "manual"
|
||||
run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc)
|
||||
run.error_message = None
|
||||
run.total_records = 100
|
||||
run.successful_records = 95
|
||||
run.failed_records = 3
|
||||
run.skipped_records = 2
|
||||
run.cache_hits = 5
|
||||
run.insert_status = "success"
|
||||
run.insert_method = "cte_insert"
|
||||
run.connection_snapshot = {"db": "test"}
|
||||
run.config_snapshot = {"batch_size": 50}
|
||||
run.superset_execution_id = "q-1"
|
||||
run.created_by = "user"
|
||||
run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def _make_lang_stat(self, code: str, **kwargs):
|
||||
ls = MagicMock()
|
||||
ls.language_code = code
|
||||
ls.total_rows = 50
|
||||
ls.translated_rows = 48
|
||||
ls.failed_rows = 1
|
||||
ls.skipped_rows = 1
|
||||
ls.token_count = 1000
|
||||
ls.estimated_cost = 0.002
|
||||
for k, v in kwargs.items():
|
||||
setattr(ls, k, v)
|
||||
return ls
|
||||
|
||||
def test_valid_run_returns_full_status(self):
|
||||
"""Valid run returns dict with all fields."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
db.query.return_value.filter.return_value.first.return_value = run
|
||||
|
||||
db.query.return_value.filter.return_value.count.return_value = 5 # batch_count
|
||||
|
||||
event_log.get_run_event_summary.return_value = {
|
||||
"has_run_started": True,
|
||||
"terminal_event_count": 1,
|
||||
"invariant_valid": True,
|
||||
}
|
||||
|
||||
lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")]
|
||||
db.query.return_value.filter.return_value.all.return_value = lang_stats
|
||||
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
result = aggregator.get_run_status("run-1")
|
||||
|
||||
assert result["id"] == "run-1"
|
||||
assert result["status"] == "COMPLETED"
|
||||
assert result["total_records"] == 100
|
||||
assert result["batch_count"] == 5
|
||||
assert len(result["language_stats"]) == 2
|
||||
assert result["language_stats"][0]["language_code"] == "ru"
|
||||
assert result["language_stats"][0]["total_rows"] == 50
|
||||
assert result["event_invariants"]["invariant_valid"] is True
|
||||
|
||||
def test_missing_run_raises(self):
|
||||
"""Missing run raises ValueError."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
with pytest.raises(ValueError, match="Run 'bad-id' not found"):
|
||||
aggregator.get_run_status("bad-id")
|
||||
|
||||
def test_no_language_stats_returns_empty(self):
|
||||
"""No language stats returns empty list."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
db.query.return_value.filter.return_value.first.return_value = run
|
||||
db.query.return_value.filter.return_value.count.return_value = 0
|
||||
event_log.get_run_event_summary.return_value = {
|
||||
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
|
||||
}
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
result = aggregator.get_run_status("run-1")
|
||||
|
||||
assert result["language_stats"] == []
|
||||
|
||||
def test_none_dates_returns_none(self):
|
||||
"""None dates produce None isoformat."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run(started_at=None, completed_at=None)
|
||||
db.query.return_value.filter.return_value.first.return_value = run
|
||||
db.query.return_value.filter.return_value.count.return_value = 0
|
||||
event_log.get_run_event_summary.return_value = {
|
||||
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
|
||||
}
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
result = aggregator.get_run_status("run-1")
|
||||
|
||||
assert result["started_at"] is None
|
||||
assert result["completed_at"] is None
|
||||
|
||||
def test_none_numeric_fields_defaults_zero(self):
|
||||
"""Numeric fields default to 0 when None."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run(
|
||||
total_records=None, successful_records=None,
|
||||
failed_records=None, skipped_records=None, cache_hits=None,
|
||||
)
|
||||
db.query.return_value.filter.return_value.first.return_value = run
|
||||
db.query.return_value.filter.return_value.count.return_value = 0
|
||||
event_log.get_run_event_summary.return_value = {
|
||||
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
|
||||
}
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
result = aggregator.get_run_status("run-1")
|
||||
|
||||
assert result["total_records"] == 0
|
||||
assert result["successful_records"] == 0
|
||||
assert result["failed_records"] == 0
|
||||
assert result["skipped_records"] == 0
|
||||
assert result["cache_hits"] == 0
|
||||
|
||||
|
||||
class TestGetRunRecords:
|
||||
"""TranslationResultAggregator.get_run_records — delegates to orchestrator_query."""
|
||||
|
||||
def test_delegates_to_query_function(self):
|
||||
"""Calls get_run_records from orchestrator_query."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
|
||||
with patch("src.plugins.translate.orchestrator_aggregator._get_run_records") as mock_fn:
|
||||
mock_fn.return_value = {"items": [], "total": 0}
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
result = aggregator.get_run_records("run-1", 2, 25, "failed", True)
|
||||
|
||||
mock_fn.assert_called_once_with(db, "run-1", 2, 25, "failed", deduplicate=True)
|
||||
assert result == {"items": [], "total": 0}
|
||||
|
||||
|
||||
class TestGetRunHistory:
|
||||
"""TranslationResultAggregator.get_run_history — delegates to orchestrator_query."""
|
||||
|
||||
def test_delegates_to_query_function(self):
|
||||
"""Calls get_run_history from orchestrator_query."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
|
||||
with patch("src.plugins.translate.orchestrator_aggregator._get_run_history") as mock_fn:
|
||||
mock_fn.return_value = (5, [{"id": "r1"}])
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
total, items = aggregator.get_run_history("job-1", 1, 20)
|
||||
|
||||
mock_fn.assert_called_once_with(db, "job-1", 1, 20)
|
||||
assert total == 5
|
||||
assert items == [{"id": "r1"}]
|
||||
|
||||
|
||||
class TestUpdateLanguageStats:
|
||||
"""TranslationResultAggregator.update_language_stats — delegates to orchestrator_lang_stats."""
|
||||
|
||||
def test_delegates_to_update_function(self):
|
||||
"""Calls update_language_stats from orchestrator_lang_stats."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
stats_map = {"ru": MagicMock()}
|
||||
|
||||
with patch("src.plugins.translate.orchestrator_aggregator.update_language_stats") as mock_fn:
|
||||
aggregator = TranslationResultAggregator(db, event_log)
|
||||
aggregator.update_language_stats("run-1", stats_map)
|
||||
|
||||
mock_fn.assert_called_once_with(
|
||||
db=db, event_log=event_log, run_id="run-1", language_stats_map=stats_map,
|
||||
)
|
||||
# #endregion Test.OrchestratorAggregator
|
||||
214
backend/tests/plugins/translate/test_orchestrator_exec.py
Normal file
214
backend/tests/plugins/translate/test_orchestrator_exec.py
Normal file
@@ -0,0 +1,214 @@
|
||||
# #region Test.OrchestratorExec [C:3] [TYPE Module] [SEMANTICS test, translate, execution, run, engine]
|
||||
# @BRIEF Tests for orchestrator_exec.py — TranslationExecutionEngine.
|
||||
# @RELATION BINDS_TO -> [TranslationExecutionEngine]
|
||||
# @TEST_CONTRACT: execute_run -> TranslationRun | dispatches executor, handles outcomes
|
||||
# @TEST_CONTRACT: _init_language_stats -> dict | initializes per-language stats
|
||||
# @TEST_EDGE: missing_job
|
||||
# @TEST_EDGE: wrong_status
|
||||
# @TEST_EDGE: executor_failure
|
||||
# @TEST_EDGE: cancelled_run
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_exec import TranslationExecutionEngine
|
||||
|
||||
|
||||
class TestTranslationExecutionEngine:
|
||||
"""TranslationExecutionEngine — execute runs, handle outcomes."""
|
||||
|
||||
def _make_engine(self):
|
||||
"""Create engine with mocked deps."""
|
||||
db = MagicMock()
|
||||
config = MagicMock()
|
||||
event_log = MagicMock()
|
||||
engine = TranslationExecutionEngine(db, config, event_log, "user")
|
||||
return engine, db, config, event_log
|
||||
|
||||
def _make_run(self, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
run.status = "PENDING"
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def _make_job(self, **kwargs):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.target_languages = ["ru", "de"]
|
||||
job.target_dialect = "en"
|
||||
for k, v in kwargs.items():
|
||||
setattr(job, k, v)
|
||||
return job
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_run_success(self):
|
||||
"""Happy path: run executed, success completion handled."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor.execute_run = AsyncMock(return_value=run)
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec._complete_success"
|
||||
) as mock_success:
|
||||
mock_success.return_value = run
|
||||
result = await engine.execute_run(run)
|
||||
|
||||
event_log.log_event.assert_called_once_with(
|
||||
job_id="job-1", run_id="run-1", event_type="TRANSLATION_PHASE_STARTED",
|
||||
payload={}, created_by="user",
|
||||
)
|
||||
mock_success.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_run_missing_job_raises(self):
|
||||
"""Missing job raises ValueError."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Job 'job-1' not found"):
|
||||
await engine.execute_run(run)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_run_wrong_status_raises(self):
|
||||
"""Non-PENDING run raises ValueError."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run(status="RUNNING")
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot execute run in status 'RUNNING'"):
|
||||
await engine.execute_run(run)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_failure_handled(self):
|
||||
"""Executor exception triggers failure handler."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor.execute_run = AsyncMock(side_effect=ValueError("LLM failed"))
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec._handle_executor_failure"
|
||||
) as mock_failure:
|
||||
mock_failure.return_value = run
|
||||
result = await engine.execute_run(run)
|
||||
|
||||
mock_failure.assert_called_once()
|
||||
assert result == run
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_run_handled(self):
|
||||
"""Run with CANCELLED status after executor triggers complete_cancelled."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
cancelled_run = self._make_run(status="CANCELLED")
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor.execute_run = AsyncMock(return_value=cancelled_run)
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec._complete_cancelled"
|
||||
) as mock_cancelled:
|
||||
mock_cancelled.return_value = cancelled_run
|
||||
result = await engine.execute_run(run)
|
||||
|
||||
mock_cancelled.assert_called_once()
|
||||
assert result == cancelled_run
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_passed_progress_callback(self):
|
||||
"""Progress callback and skip_insert passed to executor."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
|
||||
progress_cb = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor.execute_run = AsyncMock(return_value=run)
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_exec._complete_success"
|
||||
) as mock_success:
|
||||
mock_success.return_value = run
|
||||
result = await engine.execute_run(run, on_batch_progress=progress_cb, skip_insert=True)
|
||||
|
||||
# Verify executor was created with callback
|
||||
MockExecutor.assert_called_once()
|
||||
_, kwargs = MockExecutor.call_args
|
||||
assert kwargs["on_batch_progress"] == progress_cb
|
||||
|
||||
def test_init_language_stats(self):
|
||||
"""_init_language_stats creates stats for each target language."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job(target_languages=["ru", "de"])
|
||||
|
||||
result = engine._init_language_stats(run, job)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "ru" in result
|
||||
assert "de" in result
|
||||
assert result["ru"].language_code == "ru"
|
||||
assert result["ru"].run_id == "run-1"
|
||||
assert db.add.call_count == 2
|
||||
db.flush.assert_called_once()
|
||||
|
||||
def test_init_language_stats_string_languages(self):
|
||||
"""String target_languages is converted to list."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job(target_languages=None, target_dialect="fr")
|
||||
|
||||
result = engine._init_language_stats(run, job)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "fr" in result
|
||||
|
||||
def test_init_language_stats_default_fallback(self):
|
||||
"""No target_languages and no target_dialect defaults to 'en'."""
|
||||
engine, db, config, event_log = self._make_engine()
|
||||
run = self._make_run()
|
||||
job = self._make_job(target_languages=None, target_dialect=None)
|
||||
|
||||
result = engine._init_language_stats(run, job)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "en" in result
|
||||
# #endregion Test.OrchestratorExec
|
||||
227
backend/tests/plugins/translate/test_orchestrator_planner.py
Normal file
227
backend/tests/plugins/translate/test_orchestrator_planner.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# #region Test.OrchestratorPlanner [C:3] [TYPE Module] [SEMANTICS test, translate, planner, run, creation]
|
||||
# @BRIEF Tests for orchestrator_planner.py — TranslationPlanner.plan_run.
|
||||
# @RELATION BINDS_TO -> [TranslationPlanner]
|
||||
# @TEST_CONTRACT: plan_run -> TranslationRun | creates run with config snapshot and hashes
|
||||
# @TEST_EDGE: missing_job
|
||||
# @TEST_EDGE: scheduled_trigger
|
||||
# @TEST_EDGE: full_translation
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_planner import TranslationPlanner
|
||||
|
||||
|
||||
class TestTranslationPlanner:
|
||||
"""TranslationPlanner.plan_run — create TranslationRun with hashes."""
|
||||
|
||||
def _make_job(self, **kwargs):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.source_dialect = "postgresql"
|
||||
job.target_dialect = "clickhouse"
|
||||
job.database_dialect = "postgresql"
|
||||
job.source_datasource_id = "ds-1"
|
||||
job.source_table = "source_table"
|
||||
job.target_schema = "target_schema"
|
||||
job.target_table = "target_table"
|
||||
job.source_key_cols = ["id"]
|
||||
job.target_key_cols = ["id"]
|
||||
job.translation_column = "name"
|
||||
job.target_column = "name_translated"
|
||||
job.context_columns = ["desc"]
|
||||
job.provider_id = "provider-1"
|
||||
job.batch_size = 50
|
||||
job.upsert_strategy = "MERGE"
|
||||
job.target_languages = ["ru", "de"]
|
||||
for k, v in kwargs.items():
|
||||
setattr(job, k, v)
|
||||
return job
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_plan_run_success(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""Creates run with all required fields."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "config_hash_1234"
|
||||
mock_dict_hash.return_value = "dict_hash_5678"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1")
|
||||
|
||||
assert result.job_id == "job-1"
|
||||
assert result.status == "PENDING"
|
||||
assert result.trigger_type == "manual"
|
||||
assert result.config_hash == "config_hash_1234"
|
||||
assert result.dict_snapshot_hash == "dict_hash_5678"
|
||||
assert result.key_hash is not None
|
||||
assert result.created_by == "user"
|
||||
assert result.config_snapshot is not None
|
||||
assert result.config_snapshot["source_dialect"] == "postgresql"
|
||||
assert result.config_snapshot["target_dialect"] == "clickhouse"
|
||||
assert result.config_snapshot["batch_size"] == 50
|
||||
assert result.config_snapshot["full_translation"] is False
|
||||
db.add.assert_called_once_with(result)
|
||||
db.flush.assert_called()
|
||||
db.commit.assert_called()
|
||||
db.refresh.assert_called_once_with(result)
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_missing_job_raises(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""Missing job raises ValueError."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
with pytest.raises(ValueError, match="Translation job 'bad-id' not found"):
|
||||
planner.plan_run("bad-id")
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_scheduled_trigger(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""is_scheduled=True sets trigger_type to 'scheduled'."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "hash1"
|
||||
mock_dict_hash.return_value = "hash2"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1", is_scheduled=True)
|
||||
|
||||
assert result.trigger_type == "scheduled"
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_custom_trigger_type(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""Custom trigger_type is preserved."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "hash1"
|
||||
mock_dict_hash.return_value = "hash2"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1", trigger_type="api_webhook")
|
||||
|
||||
assert result.trigger_type == "api_webhook"
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_full_translation_flag(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""full_translation=True sets flag in config_snapshot."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "hash1"
|
||||
mock_dict_hash.return_value = "hash2"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1", full_translation=True)
|
||||
|
||||
assert result.config_snapshot["full_translation"] is True
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_event_logged_on_creation(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""RUN_STARTED event logged after run creation."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "ch"
|
||||
mock_dict_hash.return_value = "dh"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1")
|
||||
|
||||
event_log.log_event.assert_called_once()
|
||||
call_args = event_log.log_event.call_args[1]
|
||||
assert call_args["event_type"] == "RUN_STARTED"
|
||||
assert call_args["run_id"] == result.id
|
||||
assert call_args["payload"]["config_hash"] == "ch"
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_validates_preconditions(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""Calls validate_job_preconditions with correct args."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "ch"
|
||||
mock_dict_hash.return_value = "dh"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
planner.plan_run("job-1", is_scheduled=True)
|
||||
|
||||
mock_validate.assert_called_once_with(job, db, is_scheduled=True)
|
||||
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
|
||||
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
|
||||
def test_config_snapshot_includes_all_fields(
|
||||
self, mock_validate, mock_dict_hash, mock_config_hash
|
||||
):
|
||||
"""Config snapshot contains all relevant job fields."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
job = self._make_job()
|
||||
db.query.return_value.filter.return_value.first.return_value = job
|
||||
mock_config_hash.return_value = "ch"
|
||||
mock_dict_hash.return_value = "dh"
|
||||
|
||||
planner = TranslationPlanner(db, event_log, "user")
|
||||
result = planner.plan_run("job-1")
|
||||
|
||||
snapshot = result.config_snapshot
|
||||
assert snapshot["source_dialect"] == "postgresql"
|
||||
assert snapshot["target_dialect"] == "clickhouse"
|
||||
assert snapshot["source_datasource_id"] == "ds-1"
|
||||
assert snapshot["source_table"] == "source_table"
|
||||
assert snapshot["target_schema"] == "target_schema"
|
||||
assert snapshot["target_table"] == "target_table"
|
||||
assert snapshot["source_key_cols"] == ["id"]
|
||||
assert snapshot["translation_column"] == "name"
|
||||
assert snapshot["provider_id"] == "provider-1"
|
||||
assert snapshot["upsert_strategy"] == "MERGE"
|
||||
# #endregion Test.OrchestratorPlanner
|
||||
280
backend/tests/plugins/translate/test_orchestrator_query.py
Normal file
280
backend/tests/plugins/translate/test_orchestrator_query.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# #region Test.OrchestratorQuery [C:3] [TYPE Module] [SEMANTICS test, translate, query, records, history]
|
||||
# @BRIEF Tests for orchestrator_query.py — get_run_records, get_run_history.
|
||||
# @RELATION BINDS_TO -> [orchestrator_query]
|
||||
# @TEST_CONTRACT: get_run_records -> dict | paginated records with optional deduplicate
|
||||
# @TEST_CONTRACT: get_run_history -> tuple[int, list[dict]] | paginated run history
|
||||
# @TEST_EDGE: deduplicate_empty_source_hash
|
||||
# @TEST_EDGE: status_filter
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_query import get_run_records, get_run_history
|
||||
|
||||
|
||||
class TestGetRunRecords:
|
||||
"""get_run_records — paginated run records."""
|
||||
|
||||
def _make_record(self, rec_id: str, **kwargs):
|
||||
rec = MagicMock()
|
||||
rec.id = rec_id
|
||||
rec.batch_id = "batch-1"
|
||||
rec.source_sql = "SELECT * FROM t"
|
||||
rec.target_sql = "SELECT * FROM t_translated"
|
||||
rec.source_object_type = "table"
|
||||
rec.source_object_id = "123"
|
||||
rec.source_object_name = "my_table"
|
||||
rec.source_data = {"col": "val"}
|
||||
rec.source_hash = f"hash_{rec_id}"
|
||||
rec.status = "translated"
|
||||
rec.error_message = None
|
||||
rec.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
rec.languages = []
|
||||
for k, v in kwargs.items():
|
||||
setattr(rec, k, v)
|
||||
return rec
|
||||
|
||||
def test_basic_query(self):
|
||||
"""Returns paginated records with metadata."""
|
||||
db = MagicMock()
|
||||
# Mock chain: db.query().options().filter()
|
||||
options_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
options_filter_mock = MagicMock()
|
||||
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value = options_filter_mock
|
||||
options_filter_mock.count.return_value = 2
|
||||
options_filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
|
||||
self._make_record("r1"), self._make_record("r2"),
|
||||
]
|
||||
|
||||
result = get_run_records(db, "run-1")
|
||||
|
||||
assert result["total"] == 2
|
||||
assert len(result["items"]) == 2
|
||||
assert result["page"] == 1
|
||||
assert result["page_size"] == 50
|
||||
assert result["status_filter"] is None
|
||||
assert result["deduplicate"] is False
|
||||
|
||||
def test_with_status_filter(self):
|
||||
"""Status filter applied to query."""
|
||||
db = MagicMock()
|
||||
options_mock = MagicMock()
|
||||
filter_mock1 = MagicMock()
|
||||
filter_mock2 = MagicMock()
|
||||
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value = filter_mock1
|
||||
filter_mock1.filter.return_value = filter_mock2
|
||||
filter_mock2.count.return_value = 1
|
||||
filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
|
||||
self._make_record("r1", status="failed"),
|
||||
]
|
||||
|
||||
result = get_run_records(db, "run-1", status_filter="failed")
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["status_filter"] == "failed"
|
||||
|
||||
def test_deduplicate_applied(self):
|
||||
"""Deduplicate flag applies exclusion logic."""
|
||||
db = MagicMock()
|
||||
options_mock = MagicMock()
|
||||
filter_mock1 = MagicMock()
|
||||
filter_mock2 = MagicMock()
|
||||
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value = filter_mock1
|
||||
filter_mock1.filter.return_value = filter_mock2
|
||||
filter_mock2.count.return_value = 1
|
||||
filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
|
||||
self._make_record("r1", source_hash="abc123"),
|
||||
]
|
||||
|
||||
result = get_run_records(db, "run-1", deduplicate=True)
|
||||
|
||||
assert result["deduplicate"] is True
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_empty_records(self):
|
||||
"""No records returns empty list."""
|
||||
db = MagicMock()
|
||||
options_mock = MagicMock()
|
||||
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value.count.return_value = 0
|
||||
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
||||
|
||||
result = get_run_records(db, "run-1")
|
||||
|
||||
assert result["total"] == 0
|
||||
assert result["items"] == []
|
||||
|
||||
def test_record_languages_included(self):
|
||||
"""Language entries included in record dict."""
|
||||
db = MagicMock()
|
||||
lang = MagicMock()
|
||||
lang.language_code = "ru"
|
||||
lang.translated_value = "привет"
|
||||
lang.final_value = None
|
||||
lang.source_language_detected = "en"
|
||||
lang.status = "translated"
|
||||
lang.needs_review = False
|
||||
|
||||
rec = self._make_record("r1", languages=[lang])
|
||||
options_mock = MagicMock()
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value.count.return_value = 1
|
||||
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
|
||||
|
||||
result = get_run_records(db, "run-1")
|
||||
|
||||
assert len(result["items"][0]["languages"]) == 1
|
||||
assert result["items"][0]["languages"][0]["language_code"] == "ru"
|
||||
assert result["items"][0]["languages"][0]["final_value"] == "привет"
|
||||
|
||||
def test_record_without_created_at(self):
|
||||
"""Record without created_at returns None for isoformat."""
|
||||
db = MagicMock()
|
||||
rec = self._make_record("r1", created_at=None)
|
||||
options_mock = MagicMock()
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value.count.return_value = 1
|
||||
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
|
||||
|
||||
result = get_run_records(db, "run-1")
|
||||
|
||||
assert result["items"][0]["created_at"] is None
|
||||
|
||||
def test_pagination_params(self):
|
||||
"""Pagination params affect offset and limit."""
|
||||
db = MagicMock()
|
||||
rec = self._make_record("r1")
|
||||
options_mock = MagicMock()
|
||||
db.query.return_value.options.return_value = options_mock
|
||||
options_mock.filter.return_value.count.return_value = 10
|
||||
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
|
||||
|
||||
result = get_run_records(db, "run-1", page=2, page_size=10)
|
||||
|
||||
assert result["page"] == 2
|
||||
assert result["page_size"] == 10
|
||||
|
||||
|
||||
class TestGetRunHistory:
|
||||
"""get_run_history — paginated run history for a job."""
|
||||
|
||||
def _make_run(self, run_id: str, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = run_id
|
||||
run.job_id = "job-1"
|
||||
run.status = "COMPLETED"
|
||||
run.trigger_type = "manual"
|
||||
run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc)
|
||||
run.error_message = None
|
||||
run.total_records = 100
|
||||
run.successful_records = 95
|
||||
run.failed_records = 3
|
||||
run.skipped_records = 2
|
||||
run.cache_hits = 5
|
||||
run.insert_status = "success"
|
||||
run.insert_method = "cte_insert"
|
||||
run.connection_snapshot = {"db": "test"}
|
||||
run.superset_execution_id = "q-1"
|
||||
run.created_by = "user"
|
||||
run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def test_basic_history(self):
|
||||
"""Returns total count and run list."""
|
||||
db = MagicMock()
|
||||
runs = [self._make_run("r1"), self._make_run("r2")]
|
||||
filter_mock = MagicMock()
|
||||
db.query.return_value.filter.return_value = filter_mock
|
||||
filter_mock.count.return_value = 2
|
||||
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs
|
||||
|
||||
total, items = get_run_history(db, "job-1")
|
||||
|
||||
assert total == 2
|
||||
assert len(items) == 2
|
||||
assert items[0]["id"] == "r1"
|
||||
assert items[0]["status"] == "COMPLETED"
|
||||
|
||||
def test_empty_history(self):
|
||||
"""No runs returns total=0 and empty list."""
|
||||
db = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
db.query.return_value.filter.return_value = filter_mock
|
||||
filter_mock.count.return_value = 0
|
||||
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
||||
|
||||
total, items = get_run_history(db, "job-1")
|
||||
|
||||
assert total == 0
|
||||
assert items == []
|
||||
|
||||
def test_pagination_params(self):
|
||||
"""Pagination affects offset and limit."""
|
||||
db = MagicMock()
|
||||
runs = [self._make_run("r1")]
|
||||
filter_mock = MagicMock()
|
||||
db.query.return_value.filter.return_value = filter_mock
|
||||
filter_mock.count.return_value = 10
|
||||
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs
|
||||
|
||||
total, items = get_run_history(db, "job-1", page=3, page_size=5)
|
||||
|
||||
assert total == 10
|
||||
assert len(items) == 1
|
||||
|
||||
def test_run_without_dates(self):
|
||||
"""Run without started_at/completed_at returns None."""
|
||||
db = MagicMock()
|
||||
run = self._make_run("r1", started_at=None, completed_at=None)
|
||||
filter_mock = MagicMock()
|
||||
db.query.return_value.filter.return_value = filter_mock
|
||||
filter_mock.count.return_value = 1
|
||||
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run]
|
||||
|
||||
total, items = get_run_history(db, "job-1")
|
||||
|
||||
assert items[0]["started_at"] is None
|
||||
assert items[0]["completed_at"] is None
|
||||
|
||||
def test_all_numeric_fields_default_to_zero(self):
|
||||
"""Numeric fields default to 0 when None."""
|
||||
db = MagicMock()
|
||||
run = self._make_run(
|
||||
"r1", total_records=None, successful_records=None,
|
||||
failed_records=None, skipped_records=None, cache_hits=None,
|
||||
)
|
||||
filter_mock = MagicMock()
|
||||
db.query.return_value.filter.return_value = filter_mock
|
||||
filter_mock.count.return_value = 1
|
||||
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run]
|
||||
|
||||
total, items = get_run_history(db, "job-1")
|
||||
|
||||
assert items[0]["total_records"] == 0
|
||||
assert items[0]["successful_records"] == 0
|
||||
assert items[0]["failed_records"] == 0
|
||||
assert items[0]["skipped_records"] == 0
|
||||
assert items[0]["cache_hits"] == 0
|
||||
# #endregion Test.OrchestratorQuery
|
||||
253
backend/tests/plugins/translate/test_orchestrator_retry.py
Normal file
253
backend/tests/plugins/translate/test_orchestrator_retry.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# #region Test.OrchestratorRetry [C:3] [TYPE Module] [SEMANTICS test, translate, retry, cancellation]
|
||||
# @BRIEF Tests for orchestrator_retry.py — TranslationRunRetryManager.
|
||||
# @RELATION BINDS_TO -> [TranslationRunRetryManager]
|
||||
# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | retries failed batches
|
||||
# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to orchestrator_cancel.retry_insert
|
||||
# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to orchestrator_cancel.cancel_run
|
||||
# @TEST_EDGE: no_failed_batches
|
||||
# @TEST_EDGE: empty_failed_records
|
||||
# @TEST_EDGE: all_retry_successful
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_retry import TranslationRunRetryManager
|
||||
|
||||
|
||||
class TestTranslationRunRetryManager:
|
||||
"""TranslationRunRetryManager — retry and cancellation."""
|
||||
|
||||
def _make_retry_mgr(self):
|
||||
"""Create retry manager with mock deps."""
|
||||
db = MagicMock()
|
||||
config = MagicMock()
|
||||
event_log = MagicMock()
|
||||
mgr = TranslationRunRetryManager(db, config, event_log, "user")
|
||||
return mgr, db, config, event_log
|
||||
|
||||
def _make_run(self, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
run.status = "COMPLETED"
|
||||
run.successful_records = 90
|
||||
run.failed_records = 5
|
||||
run.skipped_records = 5
|
||||
run.completed_at = None
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def _make_batch(self, batch_index: int, status: str = "FAILED"):
|
||||
batch = MagicMock()
|
||||
batch.id = f"batch-{batch_index}"
|
||||
batch.batch_index = batch_index
|
||||
batch.status = status
|
||||
return batch
|
||||
|
||||
def _make_record(self, record_id: str, **kwargs):
|
||||
rec = MagicMock()
|
||||
rec.id = record_id
|
||||
rec.source_object_id = "row_1"
|
||||
rec.source_sql = "SELECT *"
|
||||
rec.source_object_name = "table"
|
||||
rec.status = "FAILED"
|
||||
for k, v in kwargs.items():
|
||||
setattr(rec, k, v)
|
||||
return rec
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_batches_success(self):
|
||||
"""Retry failed batches updates run stats."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
mgr.event_log = event_log
|
||||
run = self._make_run()
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, job]
|
||||
|
||||
batch1 = self._make_batch(1)
|
||||
failed_rec = self._make_record("rec-1")
|
||||
# Use side_effect on .all() to return different values per call
|
||||
db.query.return_value.filter.return_value.all.side_effect = [
|
||||
[batch1], # 1st .all(): failed batches
|
||||
[failed_rec], # 2nd .all(): failed records
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor._process_batch = AsyncMock(return_value={
|
||||
"successful": 3, "failed": 1, "skipped": 0,
|
||||
})
|
||||
|
||||
result = await mgr.retry_failed_batches("run-1")
|
||||
|
||||
# failed=5+1=6, successful=90+3=93 (both non-zero -> COMPLETED)
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.successful_records >= 90
|
||||
assert result.failed_records >= 5
|
||||
assert result.completed_at is not None
|
||||
event_log.log_event.assert_any_call(
|
||||
job_id="job-1", run_id="run-1", event_type="RUN_RETRYING", payload={"batch_count": 1},
|
||||
created_by="user",
|
||||
)
|
||||
event_log.log_event.assert_any_call(
|
||||
job_id="job-1", run_id="run-1", event_type="RUN_COMPLETED",
|
||||
payload={"retry": True}, created_by="user",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_missing_run_raises(self):
|
||||
"""Missing run raises ValueError."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Run 'bad-id' not found"):
|
||||
await mgr.retry_failed_batches("bad-id")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_missing_job_raises(self):
|
||||
"""Missing job raises ValueError."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
run = self._make_run()
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, None]
|
||||
|
||||
with pytest.raises(ValueError, match="Job 'job-1' not found"):
|
||||
await mgr.retry_failed_batches("run-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_failed_batches_raises(self):
|
||||
"""No failed batches raises ValueError."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
run = self._make_run()
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, job]
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
with pytest.raises(ValueError, match="No failed batches found for run 'run-1'"):
|
||||
await mgr.retry_failed_batches("run-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_skips_batches_with_no_failed_records(self):
|
||||
"""Batch with no failed records is skipped."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
run = self._make_run()
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, job]
|
||||
|
||||
batch1 = self._make_batch(1)
|
||||
# Use .all.side_effect — don't overwrite .filter.return_value
|
||||
db.query.return_value.filter.return_value.all.side_effect = [
|
||||
[batch1], # first .all(): failed batches
|
||||
[], # second .all(): failed records
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor._process_batch = AsyncMock()
|
||||
|
||||
result = await mgr.retry_failed_batches("run-1")
|
||||
|
||||
# Since no records, _process_batch should NOT be called
|
||||
mock_executor._process_batch.assert_not_called()
|
||||
assert result.completed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_completed_without_errors(self):
|
||||
"""All retries successful -> status COMPLETED."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
run = self._make_run(successful_records=90, failed_records=0, skipped_records=0)
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, job]
|
||||
|
||||
batch1 = self._make_batch(1)
|
||||
failed_rec = self._make_record("rec-1")
|
||||
db.query.return_value.filter.return_value.all.side_effect = [
|
||||
[batch1], # failed batches
|
||||
[failed_rec], # failed records
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor._process_batch = AsyncMock(return_value={
|
||||
"successful": 5, "failed": 0, "skipped": 0,
|
||||
})
|
||||
result = await mgr.retry_failed_batches("run-1")
|
||||
|
||||
# failed=0+0=0 -> COMPLETED
|
||||
assert result.status == "COMPLETED"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_all_failed(self):
|
||||
"""All retries fail -> status FAILED."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
run = self._make_run(successful_records=0, failed_records=10, skipped_records=0)
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
db.query.return_value.filter.return_value.first.side_effect = [run, job]
|
||||
|
||||
batch1 = self._make_batch(1)
|
||||
failed_rec = self._make_record("rec-1")
|
||||
# Use .all.side_effect — don't overwrite .filter.return_value
|
||||
db.query.return_value.filter.return_value.all.side_effect = [
|
||||
[batch1], # failed batches
|
||||
[failed_rec], # failed records
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry.TranslationExecutor"
|
||||
) as MockExecutor:
|
||||
mock_executor = MockExecutor.return_value
|
||||
mock_executor._process_batch = AsyncMock(return_value={
|
||||
"successful": 0, "failed": 5, "skipped": 0,
|
||||
})
|
||||
result = await mgr.retry_failed_batches("run-1")
|
||||
|
||||
# failed=10+5=15 != 0, successful=0+0=0 == 0 -> FAILED
|
||||
assert result.status == "FAILED"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_insert_delegates(self):
|
||||
"""retry_insert delegates to orchestrator_cancel.retry_insert."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry._retry_insert"
|
||||
) as mock_retry_insert:
|
||||
mock_retry_insert.return_value = MagicMock(id="run-1")
|
||||
result = await mgr.retry_insert("run-1")
|
||||
|
||||
mock_retry_insert.assert_called_once_with(db, config, event_log, "user", "run-1")
|
||||
|
||||
def test_cancel_run_delegates(self):
|
||||
"""cancel_run delegates to orchestrator_cancel.cancel_run."""
|
||||
mgr, db, config, event_log = self._make_retry_mgr()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_retry._cancel_run"
|
||||
) as mock_cancel:
|
||||
mock_cancel.return_value = MagicMock(id="run-1")
|
||||
result = mgr.cancel_run("run-1")
|
||||
|
||||
mock_cancel.assert_called_once_with(db, event_log, "user", "run-1")
|
||||
# #endregion Test.OrchestratorRetry
|
||||
@@ -0,0 +1,378 @@
|
||||
# #region Test.OrchestratorRunCompletion [C:3] [TYPE Module] [SEMANTICS test, translate, run, completion, failure]
|
||||
# @BRIEF Tests for orchestrator_run_completion.py — handle_executor_failure, complete_cancelled, complete_success.
|
||||
# @RELATION BINDS_TO -> [orchestrator_run_completion]
|
||||
# @TEST_CONTRACT: handle_executor_failure -> TranslationRun | marks run as FAILED
|
||||
# @TEST_CONTRACT: complete_cancelled -> TranslationRun | finalizes cancelled run
|
||||
# @TEST_CONTRACT: complete_success -> TranslationRun | finalizes successful run
|
||||
# @TEST_EDGE: rollback_fails
|
||||
# @TEST_EDGE: skip_insert
|
||||
# @TEST_EDGE: insert_failed
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_run_completion import (
|
||||
handle_executor_failure,
|
||||
complete_cancelled,
|
||||
complete_success,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleExecutorFailure:
|
||||
"""handle_executor_failure — mark run as FAILED with error info."""
|
||||
|
||||
def _make_run(self, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.status = "RUNNING"
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def _make_job(self):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
return job
|
||||
|
||||
def test_marks_run_failed(self):
|
||||
"""Run status set to FAILED."""
|
||||
db = MagicMock()
|
||||
db.merge.return_value = db.merge.return_value # default: returns new mock
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
error = ValueError("LLM timeout")
|
||||
|
||||
# Make db.merge return the original run so .id is consistent
|
||||
db.merge.return_value = run
|
||||
result = handle_executor_failure(db, event_log, run, job, error, "test-user")
|
||||
|
||||
assert result.status == "FAILED"
|
||||
assert "LLM timeout" in result.error_message
|
||||
assert result.completed_at is not None
|
||||
db.rollback.assert_called_once()
|
||||
db.flush.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_logs_event(self):
|
||||
"""RUN_FAILED event logged."""
|
||||
db = MagicMock()
|
||||
db.merge.return_value = self._make_run(id="run-1")
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
error = RuntimeError("fail")
|
||||
|
||||
handle_executor_failure(db, event_log, run, job, error, "test-user")
|
||||
|
||||
event_log.log_event.assert_called_once()
|
||||
call_kwargs = event_log.log_event.call_args[1]
|
||||
assert call_kwargs["job_id"] == "job-1"
|
||||
assert call_kwargs["run_id"] == "run-1"
|
||||
assert call_kwargs["event_type"] == "RUN_FAILED"
|
||||
assert call_kwargs["payload"] == {"error": "fail", "phase": "translation"}
|
||||
|
||||
def test_rollback_error_handled_gracefully(self):
|
||||
"""If db.rollback() raises, it's silently caught."""
|
||||
db = MagicMock()
|
||||
db.rollback.side_effect = Exception("rollback error")
|
||||
db.merge.return_value = self._make_run()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
error = Exception("exec error")
|
||||
|
||||
result = handle_executor_failure(db, event_log, run, job, error, "test-user")
|
||||
assert result.status == "FAILED"
|
||||
db.rollback.assert_called_once()
|
||||
assert db.merge.called # still continues
|
||||
|
||||
def test_run_merged_after_rollback(self):
|
||||
"""Run is merged after rollback attempt."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
error = Exception()
|
||||
|
||||
handle_executor_failure(db, event_log, run, job, error)
|
||||
db.merge.assert_called_once_with(run)
|
||||
|
||||
def test_error_message_includes_phase(self):
|
||||
"""Error message includes 'Translation execution failed:' prefix."""
|
||||
db = MagicMock()
|
||||
db.merge.return_value = self._make_run()
|
||||
event_log = MagicMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
|
||||
result = handle_executor_failure(db, event_log, run, job, ValueError("bad"), "user")
|
||||
assert result.error_message.startswith("Translation execution failed:")
|
||||
|
||||
|
||||
class TestCompleteCancelled:
|
||||
"""complete_cancelled — finalize cancelled run."""
|
||||
|
||||
def test_updates_stats_and_logs(self):
|
||||
"""Aggregator stats updated and event logged."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
stats_map = {"ru": MagicMock()}
|
||||
|
||||
result = complete_cancelled(db, event_log, aggregator, run, stats_map, "test-user")
|
||||
|
||||
aggregator.update_language_stats.assert_called_once_with("run-1", stats_map)
|
||||
event_log.log_event.assert_called_once_with(
|
||||
job_id="job-1", run_id="run-1", event_type="RUN_CANCELLED",
|
||||
payload={"reason": "cancellation_flag"}, created_by="test-user",
|
||||
)
|
||||
db.commit.assert_called_once()
|
||||
assert result == run
|
||||
|
||||
def test_no_current_user(self):
|
||||
"""Works without current_user."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
|
||||
result = complete_cancelled(db, event_log, aggregator, run, {}, None)
|
||||
event_log.log_event.assert_called_once()
|
||||
assert result == run
|
||||
|
||||
|
||||
class TestCompleteSuccess:
|
||||
"""complete_success — finalize successful run."""
|
||||
|
||||
def _make_run(self, **kwargs):
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.job_id = "job-1"
|
||||
run.status = "RUNNING"
|
||||
run.total_records = 100
|
||||
run.successful_records = 95
|
||||
run.failed_records = 3
|
||||
run.skipped_records = 2
|
||||
for k, v in kwargs.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
def _make_job(self):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
return job
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_insert_completes_run(self):
|
||||
"""skip_insert=True -> COMPLETED without SQL."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=True,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
assert result.completed_at is not None
|
||||
aggregator.update_language_stats.assert_called_once()
|
||||
sql_service.generate_and_insert_sql.assert_not_called()
|
||||
assert db.commit.call_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_success(self):
|
||||
"""SQL insert succeeds -> COMPLETED."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "success", "query_id": "q-1",
|
||||
}
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
# After commit, run is re-queried
|
||||
final_run = MagicMock(status="COMPLETED")
|
||||
final_run.insert_status = "success"
|
||||
final_run.superset_execution_id = "q-1"
|
||||
final_run.error_message = None
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
sql_service.generate_and_insert_sql.assert_called_once_with(job, run)
|
||||
assert result.insert_status == "success"
|
||||
assert result.superset_execution_id == "q-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_failed_sets_status_failed(self):
|
||||
"""Insert status 'failed' -> run status FAILED."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "failed", "error_message": "DB timeout",
|
||||
}
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
final_run = MagicMock(status="FAILED")
|
||||
final_run.error_message = "DB timeout"
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.status == "FAILED"
|
||||
assert "DB timeout" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_timeout_sets_status_failed(self):
|
||||
"""Insert status 'timeout' -> run status FAILED."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "timeout", "error_message": None,
|
||||
}
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
final_run = MagicMock(status="FAILED")
|
||||
final_run.error_message = "SQL insert phase timeout"
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.status == "FAILED"
|
||||
assert "SQL insert phase timeout" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_skipped_non_failure(self):
|
||||
"""Insert status 'skipped' -> not a failure, COMPLETED."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "skipped", "query_id": None,
|
||||
}
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
final_run = MagicMock(status="COMPLETED")
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.status == "COMPLETED"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_with_error_message_appended(self):
|
||||
"""error_message from insert appended to run."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "success", "error_message": "warning: slow query",
|
||||
}
|
||||
run = self._make_run()
|
||||
run.error_message = None
|
||||
job = self._make_job()
|
||||
final_run = MagicMock(status="COMPLETED")
|
||||
final_run.error_message = "warning: slow query"
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert result.error_message == "warning: slow query"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_failed_without_error_message_uses_default(self):
|
||||
"""Insert failed without error_message -> default message."""
|
||||
db = MagicMock()
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "failed", "error_message": None,
|
||||
}
|
||||
run = self._make_run()
|
||||
run.error_message = None
|
||||
job = self._make_job()
|
||||
final_run = MagicMock(status="FAILED")
|
||||
final_run.error_message = "SQL insert phase failed"
|
||||
db.query.return_value.filter.return_value.first.return_value = final_run
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
assert "SQL insert phase failed" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refreshes_run_after_commit(self):
|
||||
"""Run is re-queried after commit."""
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = MagicMock(status="COMPLETED")
|
||||
event_log = MagicMock()
|
||||
aggregator = MagicMock()
|
||||
sql_service = AsyncMock()
|
||||
sql_service.generate_and_insert_sql.return_value = {
|
||||
"status": "success", "query_id": "q-1",
|
||||
}
|
||||
run = self._make_run()
|
||||
job = self._make_job()
|
||||
|
||||
result = await complete_success(
|
||||
db, event_log, aggregator, sql_service,
|
||||
run, job, skip_insert=False,
|
||||
language_stats_map={}, current_user="u",
|
||||
)
|
||||
|
||||
# Should re-query run after commit
|
||||
db.query.assert_called()
|
||||
# #endregion Test.OrchestratorRunCompletion
|
||||
86
backend/tests/plugins/translate/test_orchestrator_runner.py
Normal file
86
backend/tests/plugins/translate/test_orchestrator_runner.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# #region Test.OrchestratorRunner [C:3] [TYPE Module] [SEMANTICS test, translate, runner, execution]
|
||||
# @BRIEF Tests for orchestrator_runner.py — TranslationStageRunner delegates to sub-components.
|
||||
# @RELATION BINDS_TO -> [TranslationStageRunner]
|
||||
# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationExecutionEngine
|
||||
# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationRunRetryManager
|
||||
# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationRunRetryManager
|
||||
# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationRunRetryManager
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.orchestrator_runner import TranslationStageRunner
|
||||
|
||||
|
||||
class TestTranslationStageRunner:
|
||||
"""TranslationStageRunner — delegates to sub-components."""
|
||||
|
||||
def _make_runner(self) -> tuple[TranslationStageRunner, MagicMock, MagicMock]:
|
||||
"""Create a runner with mocked sub-components."""
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
event_log = MagicMock()
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.orchestrator_runner.TranslationExecutionEngine"
|
||||
) as MockExec, patch(
|
||||
"src.plugins.translate.orchestrator_runner.TranslationRunRetryManager"
|
||||
) as MockRetry:
|
||||
runner = TranslationStageRunner(db, config_manager, event_log, "user")
|
||||
mock_exec = MockExec.return_value
|
||||
mock_retry = MockRetry.return_value
|
||||
return runner, mock_exec, mock_retry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_run_delegates(self):
|
||||
"""execute_run delegates to TranslationExecutionEngine."""
|
||||
runner, mock_exec, _ = self._make_runner()
|
||||
mock_run = MagicMock()
|
||||
mock_exec.execute_run = AsyncMock(return_value=mock_run)
|
||||
|
||||
result = await runner.execute_run(mock_run, on_batch_progress=None, skip_insert=False)
|
||||
|
||||
mock_exec.execute_run.assert_called_once_with(
|
||||
run=mock_run, on_batch_progress=None, skip_insert=False,
|
||||
)
|
||||
assert result == mock_run
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_batches_delegates(self):
|
||||
"""retry_failed_batches delegates to TranslationRunRetryManager."""
|
||||
runner, _, mock_retry = self._make_runner()
|
||||
mock_retry.retry_failed_batches = AsyncMock(return_value=MagicMock())
|
||||
|
||||
result = await runner.retry_failed_batches("run-1")
|
||||
|
||||
mock_retry.retry_failed_batches.assert_called_once_with("run-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_insert_delegates(self):
|
||||
"""retry_insert delegates to TranslationRunRetryManager."""
|
||||
runner, _, mock_retry = self._make_runner()
|
||||
mock_retry.retry_insert = AsyncMock(return_value=MagicMock())
|
||||
|
||||
result = await runner.retry_insert("run-1")
|
||||
|
||||
mock_retry.retry_insert.assert_called_once_with("run-1")
|
||||
|
||||
def test_cancel_run_delegates(self):
|
||||
"""cancel_run delegates to TranslationRunRetryManager."""
|
||||
runner, _, mock_retry = self._make_runner()
|
||||
mock_retry.cancel_run.return_value = MagicMock()
|
||||
|
||||
result = runner.cancel_run("run-1")
|
||||
|
||||
mock_retry.cancel_run.assert_called_once_with("run-1")
|
||||
# #endregion Test.OrchestratorRunner
|
||||
340
backend/tests/plugins/translate/test_preview_prompt_builder.py
Normal file
340
backend/tests/plugins/translate/test_preview_prompt_builder.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# #region Test.PreviewPromptBuilder [C:3] [TYPE Module] [SEMANTICS test, translate, preview, prompt, builder]
|
||||
# @BRIEF Tests for preview_prompt_builder.py — PreviewPromptBuilder.build_prompt_from_rows.
|
||||
# @RELATION BINDS_TO -> [PreviewPromptBuilder]
|
||||
# @TEST_CONTRACT: build_prompt_from_rows -> dict | builds LLM prompt with glossary
|
||||
# @TEST_CONTRACT: estimate_token_budget_for_rows -> dict | delegates to helper
|
||||
# @TEST_EDGE: dictionary_matches
|
||||
# @TEST_EDGE: no_dictionary_matches
|
||||
# @TEST_EDGE: custom_prompt_template
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.preview_prompt_builder import PreviewPromptBuilder
|
||||
|
||||
|
||||
class TestBuildPromptFromRows:
|
||||
"""PreviewPromptBuilder.build_prompt_from_rows — builds prompt with dictionary glossary."""
|
||||
|
||||
def _make_job(self, **kwargs):
|
||||
job = MagicMock()
|
||||
job.id = "job-1"
|
||||
job.source_dialect = "postgresql"
|
||||
job.target_dialect = "clickhouse"
|
||||
job.target_languages = ["ru", "de"]
|
||||
job.translation_column = "name"
|
||||
job.context_columns = ["description"]
|
||||
for k, v in kwargs.items():
|
||||
setattr(job, k, v)
|
||||
return job
|
||||
|
||||
def _make_db(self):
|
||||
return MagicMock()
|
||||
|
||||
def test_build_prompt_basic(self):
|
||||
"""Builds prompt with row data and no dictionary section."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = [
|
||||
{"name": "hello", "description": "greeting"},
|
||||
{"name": "world", "description": "earth"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "rendered prompt"
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {
|
||||
"prompt": "rendered prompt", "row_meta": [],
|
||||
"target_languages": ["ru", "de"], "num_languages": 2,
|
||||
"dictionary_section": "", "sample_prompt_tokens": 100,
|
||||
"sample_output_tokens": 200, "sample_total_tokens": 300,
|
||||
"sample_cost": 0.001, "total_est_rows": 50, "total_est_tokens": 500,
|
||||
"total_est_cost": 0.01, "cost_warning": None, "actual_row_count": 2,
|
||||
}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=5)
|
||||
|
||||
assert result["prompt"] == "rendered prompt"
|
||||
assert result["sample_prompt_tokens"] == 100
|
||||
mock_filter.assert_called_once()
|
||||
mock_render.assert_called_once()
|
||||
|
||||
def test_build_prompt_with_dictionary(self):
|
||||
"""Dictionary matches produce glossary section."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = [{"name": "hello", "description": "greeting"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = [
|
||||
{"source_term": "hello", "target_term": "привет", "context_notes": "informal"},
|
||||
{"source_term": "world", "target_term": "мир", "context_notes": None},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.side_effect = lambda template, ctx: ctx.get("dictionary_section", "")
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {
|
||||
"prompt": "", "row_meta": [], "target_languages": ["ru", "de"],
|
||||
"num_languages": 2, "dictionary_section": "...",
|
||||
"sample_prompt_tokens": 0, "sample_output_tokens": 0,
|
||||
"sample_total_tokens": 0, "sample_cost": 0.0,
|
||||
"total_est_rows": 10, "total_est_tokens": 0, "total_est_cost": 0.0,
|
||||
"cost_warning": None, "actual_row_count": 1,
|
||||
}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
# Verify dictionary was included in render
|
||||
mock_render.assert_called_once()
|
||||
variables = mock_render.call_args[0][1]
|
||||
assert "Terminology dictionary" in variables["dictionary_section"]
|
||||
assert "hello" in variables["dictionary_section"]
|
||||
assert "привет" in variables["dictionary_section"]
|
||||
assert "informal" in variables["dictionary_section"]
|
||||
|
||||
def test_build_prompt_no_context_columns(self):
|
||||
"""Job without context columns still works."""
|
||||
db = self._make_db()
|
||||
job = self._make_job(context_columns=None)
|
||||
source_rows = [{"name": "hello"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "prompt"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "prompt"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
assert result["prompt"] == "prompt"
|
||||
|
||||
def test_custom_prompt_template(self):
|
||||
"""Custom prompt template overrides default."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = [{"name": "hello"}]
|
||||
custom_template = "Custom: {source_language} -> {target_languages}"
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "custom rendered"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "custom rendered"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(
|
||||
job, source_rows, sample_size=1, prompt_template=custom_template,
|
||||
)
|
||||
|
||||
# Verify custom template was used
|
||||
mock_render.assert_called_once()
|
||||
assert mock_render.call_args[0][0] == custom_template
|
||||
|
||||
def test_default_prompt_template(self):
|
||||
"""Default prompt template when none provided."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = [{"name": "hello"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "default rendered"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "default rendered"}
|
||||
from src.plugins.translate.preview_constants import DEFAULT_PREVIEW_PROMPT_TEMPLATE
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
assert mock_render.call_args[0][0] == DEFAULT_PREVIEW_PROMPT_TEMPLATE
|
||||
|
||||
def test_string_target_languages_converted(self):
|
||||
"""String target_languages converted to list."""
|
||||
db = self._make_db()
|
||||
job = self._make_job(target_languages="ru") # string, not list
|
||||
source_rows = [{"name": "hello"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "prompt"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "prompt"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
# Verify target_languages was converted to list
|
||||
mock_render.assert_called_once()
|
||||
variables = mock_render.call_args[0][1]
|
||||
assert "ru" in variables["target_languages"]
|
||||
|
||||
def test_empty_source_rows(self):
|
||||
"""Empty source rows handled."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "prompt"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "prompt"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=5)
|
||||
|
||||
assert result["prompt"] == "prompt"
|
||||
|
||||
def test_json_rows_format(self):
|
||||
"""Rows are serialized to JSON in prompt context."""
|
||||
db = self._make_db()
|
||||
job = self._make_job()
|
||||
source_rows = [{"name": "hello", "description": "greeting"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "prompt"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "prompt"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
variables = mock_render.call_args[0][1]
|
||||
assert "row_id" in variables["rows_json"]
|
||||
assert "text" in variables["rows_json"]
|
||||
assert "hello" in variables["rows_json"]
|
||||
|
||||
def test_row_meta_contains_context(self):
|
||||
"""Row meta includes context_data from context columns."""
|
||||
db = self._make_db()
|
||||
job = self._make_job(context_columns=["description", "category"])
|
||||
source_rows = [{"name": "hello", "description": "greeting", "category": "common"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
|
||||
) as mock_filter:
|
||||
mock_filter.return_value = []
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.render_prompt"
|
||||
) as mock_render:
|
||||
mock_render.return_value = "prompt"
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
|
||||
) as mock_meta:
|
||||
mock_meta.return_value = {"prompt": "prompt"}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
|
||||
|
||||
# Verify the row_context was passed to filter_for_batch
|
||||
mock_filter.assert_called_once()
|
||||
filter_kwargs = mock_filter.call_args[1]
|
||||
assert filter_kwargs["row_context"] == {"description": "greeting", "category": "common"}
|
||||
|
||||
|
||||
class TestEstimateTokenBudgetForRows:
|
||||
"""PreviewPromptBuilder.estimate_token_budget_for_rows — delegates to helper."""
|
||||
|
||||
def test_delegates_to_helper(self):
|
||||
"""Calls estimate_token_budget_for_rows helper."""
|
||||
db = MagicMock()
|
||||
job = MagicMock()
|
||||
source_rows = [{"name": "hello"}]
|
||||
|
||||
with patch(
|
||||
"src.plugins.translate.preview_prompt_builder.estimate_token_budget_for_rows"
|
||||
) as mock_helper:
|
||||
mock_helper.return_value = {"actual_row_count": 1, "source_rows": source_rows}
|
||||
|
||||
builder = PreviewPromptBuilder(db)
|
||||
result = builder.estimate_token_budget_for_rows(
|
||||
source_rows, ["ru"], job, "gpt-4o-mini",
|
||||
)
|
||||
|
||||
mock_helper.assert_called_once_with(
|
||||
source_rows=source_rows, target_languages=["ru"],
|
||||
job=job, provider_model="gpt-4o-mini",
|
||||
)
|
||||
assert result == {"actual_row_count": 1, "source_rows": source_rows}
|
||||
# #endregion Test.PreviewPromptBuilder
|
||||
135
backend/tests/plugins/translate/test_preview_token_estimator.py
Normal file
135
backend/tests/plugins/translate/test_preview_token_estimator.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# #region Test.PreviewTokenEstimator [C:2] [TYPE Module] [SEMANTICS test, translate, token, cost, estimation]
|
||||
# @BRIEF Tests for preview_token_estimator.py — TokenEstimator static methods.
|
||||
# @RELATION BINDS_TO -> [TokenEstimator]
|
||||
# @TEST_CONTRACT: TokenEstimator.estimate_prompt_tokens -> int | token estimate from string
|
||||
# @TEST_CONTRACT: TokenEstimator.estimate_output_tokens -> int | output token estimate
|
||||
# @TEST_CONTRACT: TokenEstimator.estimate_cost -> float | cost estimate from tokens
|
||||
# @TEST_CONTRACT: TokenEstimator.check_cost_warning -> str | None | cost threshold warning
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.translate.preview_token_estimator import TokenEstimator
|
||||
|
||||
|
||||
class TestEstimatePromptTokens:
|
||||
"""TokenEstimator.estimate_prompt_tokens — estimate tokens from string."""
|
||||
|
||||
def test_empty_string_returns_zero(self):
|
||||
"""Empty string returns 0."""
|
||||
assert TokenEstimator.estimate_prompt_tokens("") == 0
|
||||
|
||||
def test_short_string_returns_at_least_one(self):
|
||||
"""Short string returns at least 1."""
|
||||
assert TokenEstimator.estimate_prompt_tokens("a") == 1
|
||||
|
||||
def test_long_string_estimates(self):
|
||||
"""Longer string produces token estimate."""
|
||||
text = "hello world " * 100 # ~1300 chars
|
||||
tokens = TokenEstimator.estimate_prompt_tokens(text)
|
||||
assert tokens > 0
|
||||
assert tokens <= len(text) # tokens shouldn't exceed chars
|
||||
|
||||
def test_empty_string_handled(self):
|
||||
"""Empty string returns 0. Whitespace is truthy so gets min 1."""
|
||||
assert TokenEstimator.estimate_prompt_tokens("") == 0
|
||||
assert TokenEstimator.estimate_prompt_tokens(" ") == 1
|
||||
|
||||
|
||||
class TestEstimateOutputTokens:
|
||||
"""TokenEstimator.estimate_output_tokens — estimate output tokens."""
|
||||
|
||||
def test_zero_rows_returns_zero(self):
|
||||
"""0 rows returns 0."""
|
||||
assert TokenEstimator.estimate_output_tokens(0) == 0
|
||||
|
||||
def test_single_row_single_language(self):
|
||||
"""1 row, 1 language gives output estimate."""
|
||||
result = TokenEstimator.estimate_output_tokens(1, 1)
|
||||
expected = int(1 * 1 * 120 * 1.2)
|
||||
assert result == expected
|
||||
|
||||
def test_multi_row_single_language(self):
|
||||
"""10 rows, 1 language."""
|
||||
result = TokenEstimator.estimate_output_tokens(10, 1)
|
||||
expected = int(10 * 1 * 120 * 1.2)
|
||||
assert result == expected
|
||||
|
||||
def test_multi_row_multi_language(self):
|
||||
"""5 rows, 3 languages."""
|
||||
result = TokenEstimator.estimate_output_tokens(5, 3)
|
||||
expected = int(5 * 3 * 120 * 1.2)
|
||||
assert result == expected
|
||||
|
||||
def test_default_language(self):
|
||||
"""Default num_languages is 1."""
|
||||
result = TokenEstimator.estimate_output_tokens(2)
|
||||
expected = int(2 * 1 * 120 * 1.2)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestEstimateCost:
|
||||
"""TokenEstimator.estimate_cost — cost from tokens."""
|
||||
|
||||
def test_zero_tokens_zero_cost(self):
|
||||
"""0 tokens costs 0."""
|
||||
assert TokenEstimator.estimate_cost(0) == 0.0
|
||||
|
||||
def test_default_rate(self):
|
||||
"""1000 tokens at default rate."""
|
||||
cost = TokenEstimator.estimate_cost(1000)
|
||||
assert cost == pytest.approx(0.002, 0.000001)
|
||||
|
||||
def test_custom_rate(self):
|
||||
"""Custom cost per 1k tokens."""
|
||||
cost = TokenEstimator.estimate_cost(1000, 0.01)
|
||||
assert cost == pytest.approx(0.01, 0.000001)
|
||||
|
||||
def test_large_token_count(self):
|
||||
"""Large token count calculates correctly."""
|
||||
cost = TokenEstimator.estimate_cost(100000)
|
||||
assert cost == pytest.approx(0.2, 0.000001)
|
||||
|
||||
def test_rounding_precision(self):
|
||||
"""Result is rounded to 6 decimal places."""
|
||||
cost = TokenEstimator.estimate_cost(333)
|
||||
assert isinstance(cost, float)
|
||||
|
||||
|
||||
class TestCheckCostWarning:
|
||||
"""TokenEstimator.check_cost_warning — cost threshold warning."""
|
||||
|
||||
def test_below_threshold_no_warning(self):
|
||||
"""sample_size <= 30 returns None."""
|
||||
assert TokenEstimator.check_cost_warning(10, 1, 100, 0.001) is None
|
||||
assert TokenEstimator.check_cost_warning(30, 1, 100, 0.001) is None
|
||||
|
||||
def test_above_threshold_returns_warning(self):
|
||||
"""sample_size > 30 returns warning string."""
|
||||
warning = TokenEstimator.check_cost_warning(50, 2, 5000, 0.05)
|
||||
assert warning is not None
|
||||
assert "Large preview" in warning
|
||||
assert "5000 tokens" in warning
|
||||
assert "$0.0500" in warning
|
||||
assert "2 languages" in warning
|
||||
|
||||
def test_warning_format_high_cost(self):
|
||||
"""Warning includes formatted cost."""
|
||||
warning = TokenEstimator.check_cost_warning(100, 1, 10000, 0.5)
|
||||
assert warning is not None
|
||||
assert "$0.5000" in warning
|
||||
|
||||
def test_single_language_format(self):
|
||||
"""Warning for single language."""
|
||||
warning = TokenEstimator.check_cost_warning(40, 1, 2000, 0.004)
|
||||
assert warning is not None
|
||||
assert "1 languages" in warning
|
||||
# #endregion Test.PreviewTokenEstimator
|
||||
Reference in New Issue
Block a user