From 47352897ce1d211194cad452e3c11d6efa286234 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sun, 4 Oct 2026 17:05:06 +0000 Subject: [PATCH] =?UTF-8?q?test:=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=E2=86=92?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B2=20voice-=D0=BF=D0=B0?= =?UTF-8?q?=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=D0=B5=20(t-261004160531-ed?= =?UTF-8?q?25ed)=20=E2=80=94=20extract=5Faudio=5Ftrack=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D1=8B=D1=85=20ffmpeg-=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=83=D1=80=D0=B0=D1=85=20(ogg/opus,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B6=D0=BA=D0=B8?= =?UTF-8?q?=E2=86=92None),=20=D1=80=D0=BE=D1=83=D1=82=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?kind=3Dvideo=20=E2=89=A460=D1=81=20=D1=81=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=83=20=D0=B2=20large-v3-turbo,=20>60=D1=81=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20base-=D0=BB=D0=B5=D1=81=D1=82=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=83,=20audio=5Ftrack=5Ffailed,=20e2e=20MessageReceived?= =?UTF-8?q?=E2=86=92=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82+=D0=B8=D0=B4=D0=B5=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C;=206=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20pytest+plain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_voice_video.py | 229 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/test_voice_video.py diff --git a/tests/test_voice_video.py b/tests/test_voice_video.py new file mode 100644 index 0000000..ff06dc2 --- /dev/null +++ b/tests/test_voice_video.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""t-261004160531: видео→текст в voice-пайплайне (регресс t-261004152714). + +Покрытие: + 1. unit: extract_audio_track — реальный ffmpeg на живых фикстурах + (mp4 с дорожкой → ogg/opus байты; mp4 без дорожки и мусор → None); + 2. unit: роутинг kind=video ≤60с → сразу faster-whisper turbo + (webservice-ярус не зовётся, движок = large-v3-turbo); + 3. unit: длинное видео (>60с) → обычная лестница (webservice base первым); + 4. unit: контейнер без дорожки → MessageTranscribed(audio_track_failed); + 5. e2e на фикстуре: MessageReceived(kind=video) → download (мок) → + ffmpeg-извлечение → ASR (мок) → транскрипт в проекции inbox; + идемпотентность повтора (второй вызов — из read model, без хождений). + +Запуск: ./venv/bin/python tests/test_voice_video.py | pytest tests/test_voice_video.py +ASR-слои мокнуты (нет сети/моделей), ffmpeg — настоящий (он на хосте). +""" +import os +import subprocess +import sys + +# ── изоляция: своя БД в /tmp, прод-стор не трогаем ── +_DST = "/tmp/simbiot-test-voice-video.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, "/root/simbiot-harness") + +from harness import es, tg_inbox +import harness.voice as voice + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "", flush=True) + if not cond: + fails.append(name) + raise AssertionError(f"{name} {extra}") + + +# ── фикстуры: генерируем ffmpeg'ом при запуске (детерминировано, без бинарников в репо) ── +_TMP = "/tmp/simbiot-test-voice-video" +os.makedirs(_TMP, exist_ok=True) +WITH_AUDIO = os.path.join(_TMP, "with_audio.mp4") # testsrc + sine 440Hz +NO_AUDIO = os.path.join(_TMP, "no_audio.mp4") # только видео + + +def _ff(args): + r = subprocess.run(["ffmpeg", "-y", "-v", "error"] + args, + capture_output=True, timeout=120) + if r.returncode != 0: + raise RuntimeError((r.stderr or b"").decode()[:300]) + + +_ff(["-f", "lavfi", "-i", "testsrc=duration=2:size=128x96:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=2", + "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", + "-shortest", WITH_AUDIO]) +_ff(["-f", "lavfi", "-i", "testsrc=duration=2:size=128x96:rate=10", + "-c:v", "libx264", "-preset", "ultrafast", "-an", NO_AUDIO]) + +with open(WITH_AUDIO, "rb") as f: + VIDEO_WITH_AUDIO = f.read() +with open(NO_AUDIO, "rb") as f: + VIDEO_NO_AUDIO = f.read() + +es.init_db() + + +# ────────────────────────── 1. extract_audio_track ────────────────────────── + +def test_extract_audio_track_from_container(): + """mp4 с дорожкой → ogg/opus байты (заголовок OggS + OpusHead, звук есть).""" + track = voice.extract_audio_track(VIDEO_WITH_AUDIO) + check("контейнер с дорожкой → байты", bool(track), "None/пусто") + assert track[:4] == b"OggS", f"не ogg: {track[:8]!r}" + assert b"OpusHead" in track[:64], "не opus-кодек" + check("ogg/opus формат дорожки", True) + + +def test_extract_audio_track_no_track(): + """mp4 без аудио → None (ffmpeg ничего не вынул).""" + r = voice.extract_audio_track(VIDEO_NO_AUDIO) + check("контейнер без дорожки → None", r is None, f"получили {type(r)}") + r = voice.extract_audio_track(b"not a video at all") + check("мусор на входе → None (без исключения)", r is None, f"получили {type(r)}") + + +# ─────────────────────── 2-4. роутинг kind=video ──────────────────────────── + +def _video_msg(mid, duration): + """MessageReceived c video-вложением в Telegram-формате.""" + return {"update_id": int(mid.split("-")[1]), "message": { + "message_id": int(mid.split("-")[1]) % 100000, "date": 1780000000, + "chat": {"id": 42}, "from": {"id": 1, "username": "alex", "first_name": "Alex"}, + "video": {"file_id": f"fid-{mid}", "file_unique_id": f"fuid-{mid}", + "duration": duration}}} + + +class _Rec: + """Записываем вызовы ASR-слоёв и подменяем их ответами.""" + + def __init__(self, fw_text=None, ws_text=None): + self.calls = {"fw": [], "ws": [], "dl": []} + self._orig = {} + self._patch("_download_voice", + lambda fid, fuid="": (self.calls["dl"].append(fid) or VIDEO_WITH_AUDIO, None)) + self._patch("_asr_faster_whisper", + lambda audio, fn: (self.calls["fw"].append(audio) or fw_text)) + self._patch("_asr_webservice", + lambda audio, fn: (self.calls["ws"].append(audio) or ws_text)) + + def _patch(self, name, fn): + self._orig[name] = getattr(voice, name) + setattr(voice, name, fn) + + def restore(self): + for k, v in self._orig.items(): + setattr(voice, k, v) + self._orig = {} + + +def test_short_video_routes_to_turbo(): + """kind=video ≤60с: сразу faster-whisper turbo, webservice не зовётся.""" + tg_inbox.receive_message(_video_msg("m-910001", 5)) + rec = _Rec(fw_text="привет из видео") + # детерминированный маркер: сам ogg-заголовок между запусками ffmpeg + # не байт-стабилен — проверяем передачу ровно того, что вернул extract + marker = b"OggS-EXTRACTED-TRACK" + orig_extract = voice.extract_audio_track + voice.extract_audio_track = lambda c: (rec.calls.setdefault("ex", []).append(1) or marker) + try: + text, engine = voice.transcribe_message( + "m-910001", "fid-m-910001", "fuid-m-910001", duration=5, kind="video") + finally: + voice.extract_audio_track = orig_extract + rec.restore() + check("транскрипт получен", text == "привет из видео", f"text={text!r}") + check("движок = large-v3-turbo", engine == "faster-whisper-large-v3-turbo", + f"engine={engine!r}") + check("turbo-ярус вызван ровно 1 раз", len(rec.calls["fw"]) == 1, + f"{len(rec.calls['fw'])}") + check("webservice-ярус пропущен", len(rec.calls["ws"]) == 0, + f"{len(rec.calls['ws'])}") + check("в ASR ушла ИЗВЛЕЧЁННАЯ дорожка (не контейнер)", + rec.calls["fw"] and rec.calls["fw"][0] == marker, "байты != extract()") + check("extract вызван для видео", len(rec.calls.get("ex", [])) == 1) + m = es.run_projections()["inbox"]["m-910001"] + check("проекция: transcript в read model", m["transcript"] == "привет из видео") + check("проекция: text = транскрипт", m["text"] == "привет из видео") + check("проекция: без transcribe_reason", not m.get("transcribe_reason")) + + +def test_long_video_uses_tier_ladder(): + """kind=video >60с: сначала webservice base (обычная лестница).""" + tg_inbox.receive_message(_video_msg("m-910002", 120)) + rec = _Rec(ws_text="длинное видео base") + try: + text, engine = voice.transcribe_message( + "m-910002", "fid-m-910002", "fuid-m-910002", duration=120, kind="video") + finally: + rec.restore() + check("транскрипт из base-яруса", text == "длинное видео base", f"text={text!r}") + check("движок = whisper-asr-base", engine == "whisper-asr-base", f"engine={engine!r}") + check("webservice вызван", len(rec.calls["ws"]) == 1, f"{len(rec.calls['ws'])}") + check("turbo не понадобился", len(rec.calls["fw"]) == 0, f"{len(rec.calls['fw'])}") + + +def test_video_without_track_marks_failure(): + """Дорожки нет → MessageTranscribed(reason=audio_track_failed), движок None.""" + tg_inbox.receive_message(_video_msg("m-910003", 7)) + rec = _Rec(fw_text="не должен вызываться") + rec._patch("_download_voice", + lambda fid, fuid="": (self_dl := rec.calls["dl"].append(fid) or VIDEO_NO_AUDIO, None)) + try: + text, engine = voice.transcribe_message( + "m-910003", "fid-m-910003", "fuid-m-910003", duration=7, kind="video") + finally: + rec.restore() + check("результат — отказ (None)", text is None, f"text={text!r}") + m = es.run_projections()["inbox"]["m-910003"] + check("reason=audio_track_failed", m.get("transcribe_reason") == "audio_track_failed", + f"reason={m.get('transcribe_reason')!r}") + check("ASR-слои не звались", not rec.calls["fw"] and not rec.calls["ws"]) + + +# ──────────────────────────── 5. e2e на фикстуре ──────────────────────────── + +def test_e2e_video_message_to_transcript(): + """Полный путь: приём видео-сообщения → транскрипция → идемпотентность.""" + mid = tg_inbox.receive_message(_video_msg("m-910004", 12)) + rec = _Rec(fw_text="текст из видео e2e") + try: + text, engine = voice.transcribe_message( + mid, "fid-m-910004", "fuid-m-910004", duration=12, kind="video") + finally: + rec.restore() + check("e2e: транскрипт вернулся", text == "текст из видео e2e", f"text={text!r}") + m = es.run_projections()["inbox"][mid] + check("e2e: kind=video в проекции", m["kind"] == "video", f"kind={m['kind']!r}") + check("e2e: движок записан", m["engine"] == "faster-whisper-large-v3-turbo") + check("e2e: минуты увидат текст", m["text"] == "текст из видео e2e", f"text={m['text']!r}") + # идемпотентность: повтор берёт транскрипт из read model — без download/ASR + rec2 = _Rec(fw_text="другой текст (не должен использоваться)") + try: + text2, engine2 = voice.transcribe_message( + mid, "fid-m-910004", "fuid-m-910004", duration=12, kind="video") + finally: + rec2.restore() + check("повтор: транскрипт из кэша read model", text2 == "текст из видео e2e", + f"text={text2!r}") + check("повтор: без повторного download", not rec2.calls["dl"], "качал заново") + check("повтор: без повторного ASR", not rec2.calls["fw"] and not rec2.calls["ws"]) + + +# ────────────────────────────── plain-run режим ────────────────────────────── + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for t in tests: + try: + t() + except AssertionError: + pass + print(f"\n{'='*60}") + print(f"{'ALL OK' if not fails else 'FAILED: ' + ', '.join(fails)} ({len(tests)} тестов)") + sys.exit(1 if fails else 0)