GET /v1/jobs/view - список последних задач со ссылками на разбор. Токен
подставляется в ссылки, иначе по каждой пришлось бы дописывать его руками.
Отдельный тест следит, чтобы маршрут не перехватывался /v1/jobs/{job_id}.
build/compare_reference.py считает WER относительно эталона, выписанного
на слух. Косвенные метрики ловят крупные сдвиги и слепы к систематическим
мелким потерям - так и пропустили обрыв концов фраз.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
5.2 KiB
Python
129 lines
5.2 KiB
Python
"""Страница разбора: по ней смотрят, чья ошибка - распознавания или разметки."""
|
||
import json
|
||
import re
|
||
|
||
from app.view import render_job
|
||
|
||
|
||
def job(**over):
|
||
base = {
|
||
"job_id": "abc12345", "filename": "call.mp3", "duration_sec": 120.0,
|
||
"turns": [
|
||
{"speaker": 0, "start": 0.0, "end": 4.0, "text": "Первая реплика.",
|
||
"acoustics": {"loudness_db": -18.0}},
|
||
{"speaker": 0, "start": 30.0, "end": 34.0, "text": "Вторая <реплика>.",
|
||
"acoustics": {}, "recovered": True},
|
||
],
|
||
"sentences": [
|
||
{"n": 1, "text": "Первая реплика.", "start": 0.0, "loudness_db": -18.0, "turn": 0},
|
||
{"n": 2, "text": "Вторая <реплика>.", "start": 30.0, "loudness_db": None, "turn": 1},
|
||
],
|
||
"stats": {"turns_count": 2, "speech_sec": 8.0, "diarized": False},
|
||
"timing": {"diarization_sec": 1.0, "asr_sec": 2.0},
|
||
}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
class TestPage:
|
||
def test_is_self_contained(self):
|
||
"""Страница открывается локально: внешних файлов и запросов быть не должно."""
|
||
page = render_job(job())
|
||
assert "<script" in page and "<style" in page
|
||
assert "http://" not in page and "https://" not in page
|
||
|
||
def test_shows_text_of_turns(self):
|
||
page = render_job(job())
|
||
assert "Первая реплика." in page
|
||
|
||
def test_escapes_text(self):
|
||
"""Расшифровка - это данные, а не разметка."""
|
||
page = render_job(job())
|
||
assert "Вторая <реплика>." in page
|
||
assert "<реплика>" not in page
|
||
|
||
def test_marks_gap_between_turns(self):
|
||
page = render_job(job())
|
||
assert "пропуск" in page and "00:04" in page
|
||
|
||
def test_marks_recovered_turn(self):
|
||
assert "второй проход" in render_job(job())
|
||
|
||
def test_shows_mode(self):
|
||
assert "детектор речи" in render_job(job())
|
||
assert "диаризация" in render_job(job(stats={"diarized": True, "turns_count": 2}))
|
||
|
||
def test_sentences_go_into_script(self):
|
||
"""По ним страница собирает диалог из вставленной строки ролей."""
|
||
page = render_job(job())
|
||
payload = re.search(r"const S = (\[.*?\]);", page, re.S).group(1)
|
||
assert len(json.loads(payload)) == 2
|
||
|
||
def test_closing_tag_in_text_cannot_break_out(self):
|
||
"""Строка </script> в расшифровке закрыла бы тег и сломала страницу."""
|
||
evil = 'а тут </script><img src=x onerror=alert(1)>'
|
||
page = render_job(job(
|
||
turns=[{"speaker": 0, "start": 0.0, "end": 1.0, "text": evil, "acoustics": {}}],
|
||
sentences=[{"n": 1, "text": evil, "start": 0.0, "loudness_db": None, "turn": 0}]))
|
||
assert "</script><img" not in page
|
||
assert page.count("</script>") == 1
|
||
|
||
def test_survives_empty_result(self):
|
||
page = render_job({"job_id": "x", "turns": [], "sentences": [], "duration_sec": 0})
|
||
assert "реплик нет" in page
|
||
|
||
def test_old_result_explains_missing_sentences(self):
|
||
"""Результаты до 0.14.0 не содержат предложений - пустой блок выглядел бы поломкой."""
|
||
page = render_job({"job_id": "x", "turns": [], "sentences": [], "duration_sec": 10})
|
||
assert "старше 0.14.0" in page
|
||
assert "<textarea" not in page
|
||
|
||
def test_page_with_sentences_has_input(self):
|
||
assert "<textarea" in render_job(job())
|
||
|
||
|
||
class TestIndex:
|
||
@staticmethod
|
||
def jobs():
|
||
return [
|
||
{"id": "a" * 32, "filename": "call.mp3", "status": "done",
|
||
"created_at": 1755300000, "duration_sec": 903.0, "error": None},
|
||
{"id": "b" * 32, "filename": "<bad>.mp3", "status": "failed",
|
||
"created_at": 1755300500, "duration_sec": 0.0, "error": "нет модели"},
|
||
]
|
||
|
||
def test_done_job_is_a_link(self):
|
||
from app.view import render_index
|
||
|
||
page = render_index(self.jobs(), "tok")
|
||
assert f'href="/v1/jobs/{"a" * 32}/view?token=tok"' in page
|
||
|
||
def test_failed_job_is_not_a_link(self):
|
||
"""Разбирать нечего: результата нет."""
|
||
from app.view import render_index
|
||
|
||
page = render_index(self.jobs(), "tok")
|
||
assert f'/v1/jobs/{"b" * 32}/view' not in page
|
||
|
||
def test_error_is_shown(self):
|
||
from app.view import render_index
|
||
|
||
assert "нет модели" in render_index(self.jobs(), "tok")
|
||
|
||
def test_filename_is_escaped(self):
|
||
from app.view import render_index
|
||
|
||
page = render_index(self.jobs(), "tok")
|
||
assert "<bad>.mp3" in page and "<bad>" not in page
|
||
|
||
def test_token_is_encoded_in_links(self):
|
||
from app.view import render_index
|
||
|
||
page = render_index(self.jobs(), "token s probelom")
|
||
assert "token%20s%20probelom" in page
|
||
|
||
def test_empty_list_says_so(self):
|
||
from app.view import render_index
|
||
|
||
assert "задач пока нет" in render_index([], "tok")
|