From 4008b5019f7ba607f21a425f0bf393a9a7da01b5 Mon Sep 17 00:00:00 2001 From: jze9 Date: Fri, 28 Aug 2026 16:58:42 +0500 Subject: [PATCH] =?UTF-8?q?test(admin):=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B8=D1=82=D1=8C=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0=20+=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тесты на queued_s/duration_s фиксируют ровно ту путаницу, из-за которой метрика и разъехалась: ожидание в очереди и время работы — разные величины, а у прогонов до миграции 006 длительности просто нет (вместо неё раньше показывалось время в очереди). Панель отладки теперь показывает, сколько идущий прогон уже работает — по этому и виден застрявший, а не только по отсутствию heartbeat. README: фактические числа тестов (150, проверено прогоном run_tests.sh). Co-Authored-By: Claude Opus 5 --- README.md | 4 +- services/api/tests/test_progress.py | 47 ++++++++++++++++++++- services/frontend/src/pages/admin/Debug.tsx | 14 +++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index be095e4..3f83f98 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe 1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика). Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini). -2. **Юнит-тесты** — `pytest` по сервисам: 132 теста на ядро детекции, скоринга, +2. **Юнит-тесты** — `pytest` по сервисам: 150 тестов на ядро детекции, скоринга, парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры (БД/Redis/GPU/Ollama замоканы либо не нужны). @@ -265,7 +265,7 @@ make test-one SVC=worker-gost # тесты одного сервиса | Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | `scripts/parsers/` | 19 | | OAuth-ссылки (Google/Яндекс) | `api/app/core/oauth.py` | 6 | | Прогресс заливки (счётчики, бюджет) | `worker-indexer/app/progress.py` | 7 | -| Шкала загрузки источников | `api/app/core/progress.py` | 7 | +| Шкала загрузки и тайминги прогонов | `api/app/core/progress.py`, `schemas/admin.py` | 11 | ## Лицензия diff --git a/services/api/tests/test_progress.py b/services/api/tests/test_progress.py index d14d3ed..9a35044 100644 --- a/services/api/tests/test_progress.py +++ b/services/api/tests/test_progress.py @@ -1,6 +1,9 @@ -"""Юнит-тесты шкалы загрузки источников (app.core.progress) — чистая логика.""" +"""Юнит-тесты шкалы загрузки и таймингов прогонов — чистая логика, без БД.""" + +from datetime import datetime from app.core.progress import run_percent +from app.schemas.admin import ParseRunResponse def test_queued_run_shows_nothing_done(): @@ -39,3 +42,45 @@ def test_finished_runs_are_always_full(): """Шкала показывает «работа окончена», исход виден по статусу рядом.""" for status in ("done", "partial", "error", "cancelled"): assert run_percent(status, "finished", target=100, fetched=3, processed=1) == 100.0 + + +# ─── Тайминги прогона в схеме ответа ───────────────────────────────────────── +# Регрессия, ради которой они и разделены: в отладке «длительность прогона» +# показывала время ожидания в очереди (часы) вместо времени работы (секунды). + + +def _run(**over) -> ParseRunResponse: + base = { + "id": 1, "source_id": 1, "status": "done", "stage": "finished", "target": 100, + "fetched": 100, "processed": 100, "added": 10, "duplicates": 90, + "skipped": 0, "failed": 0, + "started_at": datetime(2026, 8, 27, 12, 0, 0), + "run_started_at": datetime(2026, 8, 27, 14, 0, 0), + "finished_at": datetime(2026, 8, 27, 14, 0, 50), + } + base.update(over) + return ParseRunResponse(**base) + + +def test_queued_and_duration_are_measured_separately(): + r = _run() + assert r.queued_s == 7200.0 # два часа в очереди + assert r.duration_s == 50.0 # полминуты работы + + +def test_no_durations_until_worker_picked_run_up(): + r = _run(status="queued", stage="queued", run_started_at=None, finished_at=None) + assert r.queued_s is None + assert r.duration_s is None + + +def test_running_run_has_wait_but_no_duration_yet(): + r = _run(status="running", stage="index", finished_at=None) + assert r.queued_s == 7200.0 + assert r.duration_s is None + + +def test_legacy_runs_report_no_duration_instead_of_queue_time(): + """Прогоны до миграции 006: длительности нет — но и вранья тоже.""" + r = _run(run_started_at=None) + assert r.duration_s is None diff --git a/services/frontend/src/pages/admin/Debug.tsx b/services/frontend/src/pages/admin/Debug.tsx index 20dabf8..321b38d 100644 --- a/services/frontend/src/pages/admin/Debug.tsx +++ b/services/frontend/src/pages/admin/Debug.tsx @@ -12,7 +12,8 @@ interface Run { id: number; source_id: number; status: string; stage: string; target: number; fetched: number; processed: number; added: number; duplicates: number; skipped: number; failed: number; error: string | null; percent: number; - started_at: string; heartbeat_at: string | null; + started_at: string; run_started_at: string | null; heartbeat_at: string | null; + queued_s: number | null; duration_s: number | null; } interface DebugData { @@ -42,6 +43,13 @@ function fmtNum(n: number | null | undefined): string { return n == null ? '—' : n.toLocaleString('ru-RU'); } +/** Сколько идущий прогон уже работает — по нему видно застрявший. */ +function runningFor(run: Run): string { + if (!run.run_started_at) return 'ещё не начат'; + const sec = Math.max(0, (Date.now() - new Date(run.run_started_at + 'Z').getTime()) / 1000); + return sec < 90 ? `${Math.round(sec)}с` : `${Math.round(sec / 60)} мин`; +} + export function Debug() { const { data, isFetching, error } = useQuery({ queryKey: ['admin-debug'], @@ -87,8 +95,9 @@ export function Debug() {
{data.sources.active_runs.map((r) => (
-
+
#{r.id} · источник {r.source_id} + · {runningFor(r)}
{r.status} получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed} + {r.duration_s != null && ` · работал ${Math.round(r.duration_s)}с`} {r.error && {r.error}}