feat(cli): добавлен батч-режим и конфигурационный файл (шаги 8–12)
- Зачем: - обработка нескольких файлов за один вызов с загрузкой модели один раз. - хранение дефолтов (модель, язык, устройство) в .transcriber.toml. - Что: - добавлен config.py: поиск .transcriber.toml (CWD → ~/.config), парсинг, валидация, приоритет CLI > конфиг > хардкод. - рефакторинг transcriber.py: выделены load_model() и _transcribe_file() с TranscribeFileResult для переиспользования модели в батче. - добавлены expand_globs() с дедупликацией и has_existing_transcript() в utils.py. - CLI: files: list[Path], --force/-f, prescan-first батч с итоговой статистикой и временем, Status-спиннер для прогресса. - README: секции батч-режим, конфигурационный файл, --force в таблице опций. - ADR-002: зафиксированы архитектурные решения (prescan-first, TranscribeFileResult, конфиг без мержа). - Проверка: - uv run pytest -q — 94 passed, 1 skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+480
-63
@@ -5,7 +5,7 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from local_transcriber.cli import app
|
||||
from local_transcriber.transcriber import Segment, TranscribeResult
|
||||
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -20,15 +20,32 @@ def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0)
|
||||
)
|
||||
|
||||
|
||||
def _patches(result=None, tmp_file=None):
|
||||
"""Context managers for a standard CLI happy path."""
|
||||
def _make_model():
|
||||
return MagicMock(name="WhisperModel")
|
||||
|
||||
|
||||
def _make_tfr(result=None, model=None, actual_device="cpu"):
|
||||
if result is None:
|
||||
result = _make_result()
|
||||
if model is None:
|
||||
model = _make_model()
|
||||
return TranscribeFileResult(result=result, model=model, actual_device=actual_device)
|
||||
|
||||
|
||||
def _single_patches(result=None, tmp_file=None, actual_device="cpu"):
|
||||
"""Patches for a standard single-file CLI happy path."""
|
||||
if result is None:
|
||||
result = _make_result(device_used=actual_device)
|
||||
model = _make_model()
|
||||
tfr = TranscribeFileResult(result=result, model=model, actual_device=actual_device)
|
||||
return [
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=tmp_file),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.detect_device", return_value=actual_device),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, actual_device)),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
]
|
||||
|
||||
@@ -36,16 +53,9 @@ def _patches(result=None, tmp_file=None):
|
||||
def test_cli_happy_path_exit_code_zero(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
patches = _single_patches(tmp_file=audio)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7]:
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
@@ -55,38 +65,45 @@ def test_cli_default_options_passed_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/large-v3"
|
||||
assert call_kwargs["device"] == "cpu"
|
||||
assert call_kwargs["compute_type"] == "int8"
|
||||
assert call_kwargs["language"] is None # "auto" → None passed to transcribe
|
||||
assert call_kwargs["language"] is None # "auto" → None
|
||||
assert call_kwargs["on_segment"] is None # verbose=False
|
||||
|
||||
|
||||
def test_cli_custom_options(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
result = _make_result(device_used="cuda")
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model, actual_device="cuda")
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/small"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
|
||||
):
|
||||
@@ -98,9 +115,9 @@ def test_cli_custom_options(tmp_path):
|
||||
"--compute-type", "float16",
|
||||
])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/small"
|
||||
assert call_kwargs["language"] == "ru" # explicit language passed through
|
||||
assert call_kwargs["language"] == "ru"
|
||||
assert call_kwargs["compute_type"] == "float16"
|
||||
|
||||
|
||||
@@ -108,19 +125,23 @@ def test_cli_verbose_passes_on_segment_callback(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--verbose"])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["on_segment"] is not None
|
||||
assert callable(call_kwargs["on_segment"])
|
||||
|
||||
@@ -130,14 +151,8 @@ def test_cli_empty_speech_warning(tmp_path):
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(segments=[])
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
patches = _single_patches(result=result, tmp_file=audio)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7]:
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
@@ -147,15 +162,20 @@ def test_cli_empty_speech_warning(tmp_path):
|
||||
def test_cli_default_output_path(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_write = MagicMock()
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript", mock_write),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
@@ -168,15 +188,20 @@ def test_cli_custom_output_path(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
out_file = tmp_path / "custom.md"
|
||||
result = _make_result()
|
||||
mock_write = MagicMock()
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript", mock_write),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--output", str(out_file)])
|
||||
@@ -189,7 +214,10 @@ def test_cli_error_exit_code_one(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
with patch("local_transcriber.cli.check_ffmpeg", side_effect=SystemExit(1)):
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg", side_effect=SystemExit(1)),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 1
|
||||
@@ -199,19 +227,23 @@ def test_cli_passes_status_callback_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["on_status"] is not None
|
||||
assert callable(call_kwargs["on_status"])
|
||||
|
||||
@@ -220,20 +252,24 @@ def test_cli_resolves_model_before_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3") as mock_ensure_model,
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3") as mock_ensure,
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--model", "large-v3"])
|
||||
|
||||
mock_ensure_model.assert_called_once()
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
mock_ensure.assert_called_once()
|
||||
call_kwargs = mock_transcribe_file.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/large-v3"
|
||||
|
||||
|
||||
@@ -241,13 +277,16 @@ def test_cli_windows_cuda_diagnostic(tmp_path):
|
||||
"""CUDA error on Windows prints choco/winget install hint."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("CUDA error: no device")),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "win32"
|
||||
@@ -262,13 +301,16 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path):
|
||||
"""CUDA error on Linux does NOT print Windows-specific hint."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("CUDA error: no device")),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")),
|
||||
patch("local_transcriber.cli.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.platform = "linux"
|
||||
@@ -283,16 +325,19 @@ def test_cli_device_fallback_warning(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(device_used="cpu")
|
||||
model = _make_model()
|
||||
tfr = TranscribeFileResult(result=result, model=model, actual_device="cpu")
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
# --device auto (default) -> detect_device returns "cuda" but result is "cpu"
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert "fallback" in out.output
|
||||
@@ -303,49 +348,59 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(device_used="cuda")
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
model = _make_model()
|
||||
tfr = TranscribeFileResult(result=result, model=model, actual_device="cuda")
|
||||
mock_transcribe_file = MagicMock(return_value=tfr)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--device", "cuda"])
|
||||
|
||||
assert mock_transcribe.call_args[1]["strict_device"] is True
|
||||
assert mock_transcribe_file.call_args[1]["strict_device"] is True
|
||||
|
||||
mock_transcribe.reset_mock()
|
||||
mock_transcribe_file.reset_mock()
|
||||
result_cpu = _make_result(device_used="cpu")
|
||||
mock_transcribe.return_value = result_cpu
|
||||
tfr_cpu = TranscribeFileResult(result=result_cpu, model=model, actual_device="cpu")
|
||||
mock_transcribe_file.return_value = tfr_cpu
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", mock_transcribe_file),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
assert mock_transcribe.call_args[1]["strict_device"] is False
|
||||
assert mock_transcribe_file.call_args[1]["strict_device"] is False
|
||||
|
||||
|
||||
def test_cli_keyboard_interrupt(tmp_path):
|
||||
"""Ctrl+C → exit code 130, 'Прервано пользователем' in output."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", side_effect=KeyboardInterrupt),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
@@ -358,7 +413,7 @@ def test_cli_user_error_no_traceback(tmp_path):
|
||||
"""FileNotFoundError → clean message, no traceback."""
|
||||
audio = tmp_path / "missing.mp3"
|
||||
|
||||
with patch("local_transcriber.cli.check_ffmpeg"):
|
||||
with patch("local_transcriber.cli.load_config", return_value={}):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 1
|
||||
@@ -370,13 +425,16 @@ def test_cli_unexpected_error_verbose_traceback(tmp_path):
|
||||
"""Unexpected error with --verbose → traceback shown."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("unexpected boom")),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio), "--verbose"])
|
||||
@@ -389,13 +447,16 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path):
|
||||
"""Unexpected error without --verbose → hint to use --verbose."""
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("unexpected boom")),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
@@ -403,3 +464,359 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path):
|
||||
assert out.exit_code == 1
|
||||
assert "Ошибка" in out.output
|
||||
assert "--verbose" in out.output
|
||||
|
||||
|
||||
# === Batch mode tests ===
|
||||
|
||||
|
||||
def test_cli_batch_two_files(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "2 обработано" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_skips_existing(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
# Create transcript for a
|
||||
(tmp_path / "a-transcript.md").write_text("existing")
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пропуск" in out.output
|
||||
assert "1 обработано" in out.output
|
||||
assert "1 пропущено" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_all_skipped_no_model_load(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
(tmp_path / "a-transcript.md").write_text("existing")
|
||||
b = tmp_path / "b.mp3"
|
||||
b.write_bytes(b"fake")
|
||||
(tmp_path / "b-transcript.md").write_text("existing")
|
||||
|
||||
mock_load_model = MagicMock()
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
mock_load_model.assert_not_called()
|
||||
|
||||
|
||||
def test_cli_batch_force_overwrites(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
(tmp_path / "a-transcript.md").write_text("existing")
|
||||
b = tmp_path / "b.mp3"
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b), "--force"])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Пропуск" not in out.output
|
||||
assert "2 обработано" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_per_file_error(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
call_count = 0
|
||||
|
||||
def transcribe_side_effect(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("oops")
|
||||
return tfr
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "1 обработано" in out.output
|
||||
assert "1 ошибок" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_invalid_in_prescan(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b = tmp_path / "b.mp3"
|
||||
# b doesn't exist
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
def validate_side_effect(p):
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"Файл не найден: {p}")
|
||||
return p
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=validate_side_effect),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "1 обработано" in out.output
|
||||
assert "1 ошибок" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_output_incompatible(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
with patch("local_transcriber.cli.load_config", return_value={}):
|
||||
out = runner.invoke(app, [str(a), str(b), "--output", "out.md"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "--output несовместим" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_empty_glob(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("local_transcriber.cli.load_config", return_value={}):
|
||||
out = runner.invoke(app, ["*.mp3"])
|
||||
|
||||
assert out.exit_code == 1
|
||||
assert "Файлы не найдены" in out.output
|
||||
|
||||
|
||||
def test_cli_config_applied(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
result = _make_result()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={"model": "tiny"}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/tiny") as mock_ensure,
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
mock_ensure.assert_called_once_with("tiny", on_status=mock_ensure.call_args[1]["on_status"])
|
||||
|
||||
|
||||
def test_cli_cli_overrides_config(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
model = _make_model()
|
||||
result = _make_result()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={"model": "tiny"}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=audio),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/small") as mock_ensure,
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--model", "small"])
|
||||
|
||||
mock_ensure.assert_called_once_with("small", on_status=mock_ensure.call_args[1]["on_status"])
|
||||
|
||||
|
||||
def test_cli_batch_fallback_warning(tmp_path):
|
||||
"""Batch mode shows fallback warning when load_model falls back to CPU."""
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result = _make_result(device_used="cpu")
|
||||
model = _make_model()
|
||||
tfr = TranscribeFileResult(result=result, model=model, actual_device="cpu")
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert "fallback" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_empty_speech_warning(tmp_path):
|
||||
"""Batch mode warns when a file has no detected speech."""
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result_empty = _make_result(segments=[])
|
||||
result_ok = _make_result()
|
||||
model = _make_model()
|
||||
tfr_empty = _make_tfr(result=result_empty, model=model)
|
||||
tfr_ok = _make_tfr(result=result_ok, model=model)
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model, "cpu")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok]),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Речь не обнаружена" in out.output
|
||||
assert "2 обработано" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_midstream_fallback_warning(tmp_path):
|
||||
"""Batch mode shows warning when _transcribe_file falls back mid-stream."""
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
model_gpu = _make_model()
|
||||
model_cpu = _make_model()
|
||||
result = _make_result(device_used="cpu")
|
||||
# First file triggers mid-stream fallback
|
||||
tfr_fallback = TranscribeFileResult(result=result, model=model_cpu, actual_device="cpu")
|
||||
tfr_ok = TranscribeFileResult(result=result, model=model_cpu, actual_device="cpu")
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", return_value=(model_gpu, "cuda")),
|
||||
patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert "fallback" in out.output
|
||||
assert "2 обработано" in out.output
|
||||
|
||||
|
||||
def test_cli_batch_model_loaded_once(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
a.write_bytes(b"fake")
|
||||
b.write_bytes(b"fake")
|
||||
|
||||
result = _make_result()
|
||||
model = _make_model()
|
||||
tfr = _make_tfr(result=result, model=model)
|
||||
mock_load_model = MagicMock(return_value=(model, "cpu"))
|
||||
|
||||
with (
|
||||
patch("local_transcriber.cli.load_config", return_value={}),
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"),
|
||||
patch("local_transcriber.cli.load_model", mock_load_model),
|
||||
patch("local_transcriber.cli._transcribe_file", return_value=tfr),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(a), str(b)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
mock_load_model.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from local_transcriber.config import (
|
||||
find_config_file,
|
||||
load_config,
|
||||
resolve_defaults,
|
||||
)
|
||||
|
||||
|
||||
def test_find_config_file_cwd(tmp_path, monkeypatch):
|
||||
config = tmp_path / ".transcriber.toml"
|
||||
config.write_text('model = "small"\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert find_config_file() == config
|
||||
|
||||
|
||||
def test_find_config_file_user_home(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .transcriber.toml in CWD
|
||||
global_config = tmp_path / ".config" / "transcriber" / "config.toml"
|
||||
global_config.parent.mkdir(parents=True)
|
||||
global_config.write_text('language = "ru"\n')
|
||||
with patch("local_transcriber.config.Path.home", return_value=tmp_path):
|
||||
result = find_config_file()
|
||||
assert result == global_config
|
||||
|
||||
|
||||
def test_find_config_file_none(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("local_transcriber.config.Path.home", return_value=tmp_path):
|
||||
assert find_config_file() is None
|
||||
|
||||
|
||||
def test_load_config_valid(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('model = "small"\nlanguage = "ru"\n')
|
||||
result = load_config(config)
|
||||
assert result == {"model": "small", "language": "ru"}
|
||||
|
||||
|
||||
def test_load_config_malformed(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("this is not valid toml [[[")
|
||||
with pytest.raises(ValueError, match="Ошибка чтения конфига"):
|
||||
load_config(config)
|
||||
|
||||
|
||||
def test_load_config_unknown_keys_warned(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('modle = "small"\nmodel = "tiny"\n')
|
||||
with pytest.warns(UserWarning, match="modle"):
|
||||
result = load_config(config)
|
||||
assert result == {"model": "tiny"}
|
||||
|
||||
|
||||
def test_load_config_non_string_value(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text("device = 123\n")
|
||||
with pytest.raises(ValueError, match="должно быть строкой"):
|
||||
load_config(config)
|
||||
|
||||
|
||||
def test_load_config_invalid_device(tmp_path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('device = "tpu"\n')
|
||||
with pytest.raises(ValueError, match="Недопустимое значение device"):
|
||||
load_config(config)
|
||||
|
||||
|
||||
def test_resolve_defaults_cli_wins():
|
||||
config = {"model": "tiny", "language": "en"}
|
||||
cli = {"model": "small", "language": None, "device": None, "compute_type": None}
|
||||
result = resolve_defaults(cli, config)
|
||||
assert result["model"] == "small"
|
||||
assert result["language"] == "en"
|
||||
|
||||
|
||||
def test_resolve_defaults_config_wins():
|
||||
config = {"model": "tiny"}
|
||||
cli = {"model": None, "language": None, "device": None, "compute_type": None}
|
||||
result = resolve_defaults(cli, config)
|
||||
assert result["model"] == "tiny"
|
||||
|
||||
|
||||
def test_resolve_defaults_hardcoded_fallback():
|
||||
result = resolve_defaults(
|
||||
{"model": None, "language": None, "device": None, "compute_type": None}, {}
|
||||
)
|
||||
assert result == {
|
||||
"model": "large-v3",
|
||||
"language": "auto",
|
||||
"device": "auto",
|
||||
"compute_type": "int8",
|
||||
}
|
||||
@@ -5,7 +5,14 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||
|
||||
from local_transcriber.transcriber import Segment, TranscribeResult, ensure_model_available, transcribe
|
||||
from local_transcriber.transcriber import (
|
||||
Segment,
|
||||
TranscribeResult,
|
||||
_transcribe_file,
|
||||
ensure_model_available,
|
||||
load_model,
|
||||
transcribe,
|
||||
)
|
||||
|
||||
|
||||
def _make_raw_segments(count: int) -> list:
|
||||
@@ -414,3 +421,53 @@ def test_transcribe_strict_cuda_error_during_transcription(mock_model_cls):
|
||||
device="cuda",
|
||||
strict_device=True,
|
||||
)
|
||||
|
||||
|
||||
# === load_model tests ===
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.WhisperModel")
|
||||
def test_load_model_cuda_fallback(mock_model_cls):
|
||||
cpu_instance = MagicMock()
|
||||
|
||||
def model_side_effect(model_name, device, compute_type):
|
||||
if device == "cuda":
|
||||
raise RuntimeError("CUDA out of memory")
|
||||
return cpu_instance
|
||||
|
||||
mock_model_cls.side_effect = model_side_effect
|
||||
|
||||
with pytest.warns(UserWarning, match="Переключение на CPU"):
|
||||
model, actual_device = load_model("tiny", "cuda", "int8")
|
||||
|
||||
assert actual_device == "cpu"
|
||||
assert model is cpu_instance
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.WhisperModel")
|
||||
def test_load_model_strict_raises(mock_model_cls):
|
||||
mock_model_cls.side_effect = RuntimeError("CUDA out of memory")
|
||||
|
||||
with pytest.raises(RuntimeError, match="CUDA out of memory"):
|
||||
load_model("tiny", "cuda", "int8", strict_device=True)
|
||||
|
||||
|
||||
@patch("local_transcriber.transcriber.WhisperModel")
|
||||
def test__transcribe_file_basic(mock_model_cls):
|
||||
raw_segments = _make_raw_segments(2)
|
||||
info = _make_info()
|
||||
|
||||
instance = MagicMock()
|
||||
instance.transcribe.return_value = (iter(raw_segments), info)
|
||||
|
||||
tfr = _transcribe_file(
|
||||
model=instance,
|
||||
actual_device="cpu",
|
||||
file_path=Path("test.mp3"),
|
||||
model_name="tiny",
|
||||
compute_type="int8",
|
||||
)
|
||||
|
||||
assert len(tfr.result.segments) == 2
|
||||
assert tfr.actual_device == "cpu"
|
||||
assert tfr.model is instance
|
||||
|
||||
@@ -7,7 +7,9 @@ import pytest
|
||||
from local_transcriber.utils import (
|
||||
build_output_path,
|
||||
detect_device,
|
||||
expand_globs,
|
||||
get_gpu_name,
|
||||
has_existing_transcript,
|
||||
validate_input_file,
|
||||
)
|
||||
|
||||
@@ -82,3 +84,45 @@ def test_get_gpu_name_success():
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = get_gpu_name()
|
||||
assert result == "NVIDIA GeForce RTX 3060"
|
||||
|
||||
|
||||
def test_expand_globs_no_patterns(tmp_path):
|
||||
a = tmp_path / "a.mp3"
|
||||
b = tmp_path / "b.mp3"
|
||||
result = expand_globs([a, b])
|
||||
assert result == [a, b]
|
||||
|
||||
|
||||
def test_expand_globs_with_star(tmp_path):
|
||||
(tmp_path / "x.mp3").write_bytes(b"fake")
|
||||
(tmp_path / "y.mp3").write_bytes(b"fake")
|
||||
(tmp_path / "z.txt").write_bytes(b"fake")
|
||||
result = expand_globs([Path(str(tmp_path / "*.mp3"))])
|
||||
assert len(result) == 2
|
||||
assert all(p.suffix == ".mp3" for p in result)
|
||||
|
||||
|
||||
def test_expand_globs_no_match(tmp_path):
|
||||
result = expand_globs([Path(str(tmp_path / "*.wav"))])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_expand_globs_deduplicates(tmp_path):
|
||||
f = tmp_path / "a.mp3"
|
||||
f.write_bytes(b"fake")
|
||||
result = expand_globs([f, Path(str(tmp_path / "*.mp3"))])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_has_existing_transcript_true(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
transcript = tmp_path / "meeting-transcript.md"
|
||||
transcript.write_text("content")
|
||||
assert has_existing_transcript(audio) is True
|
||||
|
||||
|
||||
def test_has_existing_transcript_false(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
assert has_existing_transcript(audio) is False
|
||||
|
||||
Reference in New Issue
Block a user