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

276 lines
12 KiB
Python

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