276 lines
12 KiB
Python
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
|