transcribe_helpers: cover 0% -> 100% before splitting

transcribe_helpers.py is 289 lines against the 250-line cap and was
completely untested. Tests land first, in their own commit, so the split
that follows is verified against tests that predate the seam.

Left at its current path rather than moved under python_pkg/: transcribe.sh
invokes it by path inside a dedicated ML venv, and transcribe_fw.py imports
two siblings by bare name, so the closure is five files (1236 lines), not
three. Tests go in linux_configuration/tests/ instead, whose conftest
already puts standalone script directories on sys.path - the same
arrangement the usage_report work used, and still collected by CI.

The ML dependencies are never installed in CI and the module reaches them
only through _try_import, so patching that one seam covers every probe in
both directions. An AST scan at every nesting level confirmed there is no
other path to them: the only direct numpy imports sit under TYPE_CHECKING.

41 tests, 100% line and branch. The last gap was the dispatch guard at
line 284, unreachable through the CLI because argparse's choices list and
the dispatch dict carry the same eight commands; covering it by admitting a
ninth choice also documents why the guard is there.

Split across four test files because a single module hit 291 lines - over
the cap this work exists to clear.

Verified: python-version prints 3.14, check-faster-whisper exits 7 with no
ML deps present, generate-wav writes a 96044-byte WAV; 243 tests pass in the
directory, up from 202 by exactly the 41 added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 13:29:07 +02:00
parent 377a352d48
commit 7587cd9604
7 changed files with 691 additions and 0 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Cover transcribe_helpers.py before splitting it",
"objective": "transcribe_helpers.py is 289 lines against a 250-line cap and was completely untested. This commit raises it to 100% line and branch coverage first, so the following split can be verified against tests that predate the seam. The module is left at its current path rather than moved under python_pkg/: transcribe.sh invokes it by path inside a dedicated ML venv, and transcribe_fw.py imports two siblings by bare name, so the closure is five files, not three. Tests instead go in linux_configuration/tests/, whose conftest already puts standalone script directories on sys.path - the same arrangement the usage_report work used. No production code changes here.",
"acceptance_criteria": [
"transcribe_helpers.py reaches 100% line and branch coverage, from 0%",
"transcribe_helpers.py is byte-identical to HEAD - this commit adds tests only",
"The tests run in CI without the ML dependencies present, reaching them only through the _try_import seam",
"No suppression of any kind: no noqa, no type: ignore, no lowered threshold",
"Every new test file is under the 250-line cap, so the over-cap total does not rise"
],
"out_of_scope": [
"The split of transcribe_helpers.py itself, which is the next commit",
"transcribe_fw.py and _transcribe_diarize.py, covered separately",
"Moving any of these files under python_pkg/, which would break transcribe.sh's by-path invocation",
"Removing the unreachable dispatch guard at transcribe_helpers.py:284 - a behaviour change that does not belong in a coverage commit"
],
"verifier": "pytest linux_configuration/tests/test_transcribe_*.py --cov=transcribe_helpers --cov-branch --cov-report=term-missing for the coverage claim and the test count; python3 transcribe_helpers.py {python-version,check-faster-whisper,generate-wav} for the real-run claim; pre-commit run --files for lint; bash ~/utils/scripts/check_file_length.sh --all to confirm no new over-cap file."
}

View File

@ -0,0 +1,66 @@
{
"intent": "Cover transcribe_helpers.py from 0% to 100% line and branch, so the following split of the 289-line file is verified against tests written before the seam existed.",
"scope": [
"New: linux_configuration/tests/test_transcribe_{probes,model_ops,wav,helpers_cli}.py",
"linux_configuration/tests/conftest.py - added the tools/ dir to sys.path and two shared doubles as fixtures",
"Non-goal: any change to transcribe_helpers.py, which is byte-identical to HEAD",
"Non-goal: moving these files under python_pkg/"
],
"changes": [
"Added the tools/ directory to the existing _SCRIPT_DIRS list in conftest.py, the same mechanism already used for the usage_report bin/ modules.",
"Added 41 tests covering the dependency probes, the sine-WAV generator, model preparation, the CUDA probe, and all eight CLI subcommands with their exit codes.",
"Patched only at the _try_import seam, so no ML package is needed: the module reaches faster_whisper, torch, speechbrain, soundfile, ctranslate2 and huggingface_hub exclusively through it. Verified by an AST scan at every nesting level - the only direct numpy imports in the package sit under TYPE_CHECKING and never execute.",
"Shared doubles live in conftest.py as fixtures rather than an importable helper module: name-tests-test requires every .py under tests/ to be named test_*.py, and conftest is not importable by name.",
"Split the tests across four files rather than one, because a single module reached 291 lines - over the cap this work exists to clear."
],
"verification": [
{
"command": "git diff --stat -- .../tools/transcribe_helpers.py",
"result": "pass",
"evidence": "No change: this commit adds tests only."
},
{
"command": "pytest linux_configuration/tests/test_transcribe_*.py --cov=transcribe_helpers --cov-branch --cov-report=term-missing",
"result": "pass",
"evidence": "41 passed. transcribe_helpers.py: 118 statements, 0 missing, 26 branches, 0 partial, 100%. The last gap was the dispatch guard at line 284, unreachable through the CLI because argparse's choices list and the dispatch dict carry the same eight commands; it is covered by admitting a ninth choice, which also documents why the guard exists."
},
{
"command": "pytest linux_configuration/tests/test_transcribe_*.py (count across the test-file split)",
"result": "pass",
"evidence": "41 passed both before and after splitting the 291-line module into four, which is the discriminating check for a test-file split: a file outside the runner's glob would be silently uncollected."
},
{
"command": "python3 transcribe_helpers.py python-version && python3 transcribe_helpers.py check-faster-whisper",
"result": "pass",
"evidence": "Prints 3.14, then exits 7 with the offline-mode message - 7 being the code transcribe.sh branches on. Confirms the real entry point still works on a machine with no ML dependencies."
},
{
"command": "python3 transcribe_helpers.py generate-wav --file <scratch>/tone.wav",
"result": "pass",
"evidence": "Wrote a 96044-byte WAV, matching 16000 Hz x 3 s x 16-bit mono plus header."
},
{
"command": "pytest linux_configuration/tests/ -q",
"result": "pass",
"evidence": "243 passed, up from 202 by exactly the 41 tests added; no existing test was altered."
},
{
"command": "pre-commit run --files <changed files>",
"result": "pass",
"evidence": "All hooks pass. Fixed rather than suppressed: three FBT001 boolean-positional warnings became keyword-only parameters, an S108 hardcoded /tmp path became tmp_path, a mypy func-returns-value error from an append-inside-or expression became a named function, and three noqa: N802 comments were removed by exposing the third-party CamelCase name through __getattr__ instead of defining it."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "35 files over cap, unchanged by this commit. New test files are 223/122/108/58 lines, all under 250."
}
],
"risks": [
"The tests stub every ML dependency, so they prove the control flow and the exit-code contract, not that faster_whisper itself behaves as expected. That is deliberate: the packages are absent in CI, and a real model download is not a unit test.",
"conftest.py now grows shared doubles alongside its sys.path bootstrap. Acceptable because name-tests-test forbids a helper module under tests/, and the alternative is duplicating the double in two files."
],
"rollback": [
"git revert the commit; the four test files and the conftest additions disappear together.",
"After rollback, confirm pytest linux_configuration/tests/ reports 202 passed and that transcribe_helpers.py is unchanged."
]
}

View File

@ -10,6 +10,12 @@ from __future__ import annotations
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from collections.abc import Callable
# Repo root is two levels up from this file (linux_configuration/tests/conftest.py).
_REPO_ROOT = Path(__file__).resolve().parents[2]
@ -29,8 +35,60 @@ _SCRIPT_DIRS = (
/ "scripts"
/ "single_use"
/ "utils", # fast_count
_REPO_ROOT
/ "linux_configuration"
/ "scripts"
/ "single_use"
/ "misc"
/ "testsAndMisc-bash"
/ "tools", # transcribe_fw and its helpers
)
for _script_dir in _SCRIPT_DIRS:
if str(_script_dir) not in sys.path:
sys.path.insert(0, str(_script_dir))
@pytest.fixture
def fake_whisper() -> type[FakeWhisper]:
"""The faster_whisper double, as a fixture (conftest is not importable)."""
return FakeWhisper
@pytest.fixture
def importer() -> Callable[[dict[str, object]], object]:
"""The ``_try_import`` double factory, as a fixture."""
return fake_importer
class FakeWhisper:
"""Stand-in for faster_whisper, recording how a model was constructed.
``WhisperModel`` is upstream's CamelCase name, so it is exposed through
``__getattr__`` rather than defined as a method: defining it would mean
naming a non-PEP8 identifier here just to satisfy a third-party API.
"""
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[dict[str, object]] = []
self._error = error
def _construct(self, name: str, **kwargs: object) -> object:
if self._error is not None:
raise self._error
self.calls.append({"name": name, **kwargs})
return object()
def __getattr__(self, attr: str) -> object:
if attr == "WhisperModel":
return self._construct
raise AttributeError(attr)
def fake_importer(available: dict[str, object]) -> object:
"""Return a ``_try_import`` double resolving only the named modules."""
def _try(name: str) -> object | None:
return available.get(name)
return _try

View File

@ -0,0 +1,223 @@
"""Tests for the transcribe_helpers command-line dispatch.
transcribe.sh calls this module as a subcommand runner and branches on the
exit status, so the codes are the contract: 7 means "faster_whisper missing",
2 means "required argument missing", 1 means "the operation failed".
"""
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
import pytest
import transcribe_helpers as helpers
if TYPE_CHECKING:
from pathlib import Path
_EXIT_MISSING_ARG = 2
_EXIT_NO_FASTER_WHISPER = 7
def _run(monkeypatch: pytest.MonkeyPatch, *argv: str) -> None:
"""Invoke main() with the given command line."""
monkeypatch.setattr(sys, "argv", ["transcribe_helpers.py", *argv])
helpers.main()
# --------------------------------------------------------------------------- #
# commands that report through the exit status
# --------------------------------------------------------------------------- #
def test_python_version_prints_and_exits_cleanly(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""transcribe.sh parses this to decide which wheels to install."""
with caplog.at_level(logging.INFO):
_run(monkeypatch, "python-version")
assert helpers.get_python_version() in caplog.text
def test_check_faster_whisper_exits_7_when_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exit 7 is the offline-mode signal transcribe.sh branches on."""
monkeypatch.setattr(helpers, "check_faster_whisper", lambda: False)
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "check-faster-whisper")
assert excinfo.value.code == _EXIT_NO_FASTER_WHISPER
def test_check_faster_whisper_is_silent_when_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A satisfied dependency exits 0, so the shell continues."""
monkeypatch.setattr(helpers, "check_faster_whisper", lambda: True)
_run(monkeypatch, "check-faster-whisper")
def test_check_diarization_never_fails_the_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Missing diarization deps only warn: speaker labels are optional."""
monkeypatch.setattr(helpers, "check_diarization_deps", lambda: False)
_run(monkeypatch, "check-diarization")
@pytest.mark.parametrize(("available", "raises"), [(True, False), (False, True)])
def test_check_ctranslate2_exits_1_when_absent(
monkeypatch: pytest.MonkeyPatch, *, available: bool, raises: bool
) -> None:
"""ctranslate2 is required, so its absence is a hard failure."""
monkeypatch.setattr(helpers, "check_ctranslate2", lambda: available)
if raises:
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "check-ctranslate2")
assert excinfo.value.code == 1
else:
_run(monkeypatch, "check-ctranslate2")
def test_deps_installed_reports_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""The confirmation command exits 0."""
_run(monkeypatch, "deps-installed")
# --------------------------------------------------------------------------- #
# commands taking arguments
# --------------------------------------------------------------------------- #
def test_generate_wav_writes_the_requested_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The --file argument is passed through to the generator."""
out = tmp_path / "tone.wav"
_run(monkeypatch, "generate-wav", "--file", str(out))
assert out.exists()
def test_generate_wav_requires_a_file(monkeypatch: pytest.MonkeyPatch) -> None:
"""Omitting --file is a usage error, exit 2."""
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "generate-wav")
assert excinfo.value.code == _EXIT_MISSING_ARG
def test_generate_wav_exits_1_when_generation_fails(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A generator failure surfaces as exit 1."""
monkeypatch.setattr(helpers, "generate_sine_wav", lambda _f: False)
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "generate-wav", "--file", str(tmp_path / "x.wav"))
assert excinfo.value.code == 1
def test_prepare_model_passes_both_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--model and --model-dir reach prepare_model unchanged."""
seen: list[tuple[str, str]] = []
def _record(model: str, model_dir: str) -> bool:
seen.append((model, model_dir))
return True
monkeypatch.setattr(helpers, "prepare_model", _record)
_run(monkeypatch, "prepare-model", "--model", "small", "--model-dir", "/m")
assert seen == [("small", "/m")]
@pytest.mark.parametrize(
"argv",
[
("prepare-model",),
("prepare-model", "--model", "small"),
("prepare-model", "--model-dir", "/m"),
],
)
def test_prepare_model_requires_both_arguments(
monkeypatch: pytest.MonkeyPatch, argv: tuple[str, ...]
) -> None:
"""Either argument missing is a usage error, exit 2."""
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, *argv)
assert excinfo.value.code == _EXIT_MISSING_ARG
def test_prepare_model_exits_1_on_download_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A failed download surfaces as exit 1."""
monkeypatch.setattr(helpers, "prepare_model", lambda _m, _d: False)
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "prepare-model", "--model", "small", "--model-dir", "/m")
assert excinfo.value.code == 1
@pytest.mark.parametrize(("works", "raises"), [(True, False), (False, True)])
def test_test_cuda_reports_through_the_exit_status(
monkeypatch: pytest.MonkeyPatch, *, works: bool, raises: bool
) -> None:
"""transcribe.sh falls back to CPU when this exits non-zero."""
monkeypatch.setattr(helpers, "test_cuda", lambda: works)
if raises:
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "test-cuda")
assert excinfo.value.code == 1
else:
_run(monkeypatch, "test-cuda")
def test_an_unknown_command_is_rejected_by_argparse(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The choices list means a typo cannot silently do nothing."""
with pytest.raises(SystemExit) as excinfo:
_run(monkeypatch, "not-a-command")
assert excinfo.value.code == _EXIT_MISSING_ARG
def test_a_command_with_no_handler_is_a_no_op(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The dispatch guard tolerates a choice with no handler behind it.
Unreachable through the CLI today, because argparse's ``choices`` list and
the dispatch dict carry the same eight commands. It is exercised here by
accepting a ninth choice, so that adding a command to one list but not the
other degrades to doing nothing rather than raising.
"""
real_parse_args = helpers.argparse.ArgumentParser.parse_args
def _parse_with_extra_choice(
self: helpers.argparse.ArgumentParser, *args: object, **kwargs: object
) -> helpers.argparse.Namespace:
for action in self._actions:
if action.dest == "command" and action.choices is not None:
action.choices = [*action.choices, "unhandled"]
return real_parse_args(self, *args, **kwargs)
monkeypatch.setattr(
helpers.argparse.ArgumentParser, "parse_args", _parse_with_extra_choice
)
_run(monkeypatch, "unhandled")

View File

@ -0,0 +1,143 @@
"""Tests for transcribe.sh's model download and CUDA probe.
Both reach faster_whisper through ``_try_import``, so the package itself is
never needed; the double records how the model would have been constructed.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import transcribe_helpers as helpers
if TYPE_CHECKING:
from collections.abc import Callable
from conftest import FakeWhisper
import pytest
# --------------------------------------------------------------------------- #
# prepare_model / test_cuda
# --------------------------------------------------------------------------- #
def test_prepare_model_downloads_with_cpu_int8(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The model is fetched on CPU in int8, into the requested directory."""
fw = fake_whisper()
monkeypatch.setattr(helpers, "_try_import", importer({"faster_whisper": fw}))
assert helpers.prepare_model("small", "/models") is True
assert fw.calls == [
{
"name": "small",
"device": "cpu",
"compute_type": "int8",
"download_root": "/models",
}
]
def test_prepare_model_enables_progress_bars_when_hub_is_present(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""huggingface_hub progress bars are turned back on for the download."""
fw = fake_whisper()
hub_logging = type("L", (), {"set_verbosity_info": lambda self: None})()
hub = type("H", (), {"constants": type("C", (), {})()})()
monkeypatch.setattr(
helpers,
"_try_import",
importer(
{
"faster_whisper": fw,
"huggingface_hub.logging": hub_logging,
"huggingface_hub": hub,
}
),
)
monkeypatch.delenv("HF_HUB_DISABLE_PROGRESS_BARS", raising=False)
assert helpers.prepare_model("small", "/models") is True
assert hub.constants.HF_HUB_DISABLE_PROGRESS_BARS is False
def test_prepare_model_fails_without_faster_whisper(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without the package there is nothing to download."""
monkeypatch.setattr(helpers, "_try_import", importer({}))
with caplog.at_level(logging.ERROR):
result = helpers.prepare_model("small", "/models")
assert result is False
assert "not installed" in caplog.text
def test_prepare_model_reports_a_download_failure(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A network or disk error is logged and reported, not raised."""
failing = fake_whisper(error=OSError("no space left"))
monkeypatch.setattr(helpers, "_try_import", importer({"faster_whisper": failing}))
with caplog.at_level(logging.ERROR):
result = helpers.prepare_model("small", "/models")
assert result is False
assert "Failed to prepare model" in caplog.text
def test_test_cuda_initialises_a_tiny_model_on_the_gpu(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CUDA probe loads the tiny model in float16 on the GPU."""
fw = fake_whisper()
monkeypatch.setattr(helpers, "_try_import", importer({"faster_whisper": fw}))
assert helpers.test_cuda() is True
assert fw.calls == [{"name": "tiny", "device": "cuda", "compute_type": "float16"}]
def test_test_cuda_fails_without_faster_whisper(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No package means no CUDA test."""
monkeypatch.setattr(helpers, "_try_import", importer({}))
assert helpers.test_cuda() is False
def test_test_cuda_reports_an_unusable_gpu(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A machine without a working CUDA stack reports False, not a crash."""
no_cuda = fake_whisper(error=RuntimeError("CUDA driver not found"))
monkeypatch.setattr(helpers, "_try_import", importer({"faster_whisper": no_cuda}))
with caplog.at_level(logging.ERROR):
result = helpers.test_cuda()
assert result is False
assert "CUDA test failed" in caplog.text

View File

@ -0,0 +1,125 @@
"""Tests for transcribe.sh's dependency probes.
The ML dependencies (faster_whisper, torch, speechbrain, soundfile,
ctranslate2) are never installed in CI, and the module reaches them only
through ``_try_import``. Patching that one seam is what lets every probe be
tested in both directions without the real packages.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
import transcribe_helpers as helpers
if TYPE_CHECKING:
from collections.abc import Callable
from conftest import FakeWhisper
# --------------------------------------------------------------------------- #
# The optional-dependency import seam
# --------------------------------------------------------------------------- #
def test_try_import_returns_the_module_when_present() -> None:
"""A real, importable module comes back as a module object."""
assert helpers._try_import("json") is not None
def test_try_import_returns_none_for_a_missing_module() -> None:
"""A missing optional dependency is None, not an ImportError."""
assert helpers._try_import("no_such_module_anywhere_12345") is None
# --------------------------------------------------------------------------- #
# dependency probes
# --------------------------------------------------------------------------- #
def test_get_python_version_reports_major_minor() -> None:
"""The version string is major.minor, which is what transcribe.sh parses."""
version = helpers.get_python_version()
major, _, minor = version.partition(".")
assert major.isdigit()
assert minor.isdigit()
@pytest.mark.parametrize("present", [True, False])
def test_check_faster_whisper_reports_availability(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
*,
present: bool,
) -> None:
"""The probe is True only when faster_whisper imports."""
available = {"faster_whisper": fake_whisper()} if present else {}
monkeypatch.setattr(helpers, "_try_import", importer(available))
assert helpers.check_faster_whisper() is present
@pytest.mark.parametrize("present", [True, False])
def test_check_ctranslate2_reports_availability(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
*,
present: bool,
) -> None:
"""The probe is True only when ctranslate2 imports."""
available = {"ctranslate2": object()} if present else {}
monkeypatch.setattr(helpers, "_try_import", importer(available))
assert helpers.check_ctranslate2() is present
def test_check_diarization_deps_needs_all_three(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Diarization needs soundfile, speechbrain and torch together."""
monkeypatch.setattr(
helpers,
"_try_import",
importer({"soundfile": object(), "speechbrain": object(), "torch": object()}),
)
assert helpers.check_diarization_deps() is True
@pytest.mark.parametrize("missing", ["soundfile", "speechbrain", "torch"])
def test_check_diarization_deps_warns_when_one_is_missing(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
missing: str,
) -> None:
"""Any one absent dependency downgrades to a warning, not a failure."""
available: dict[str, object] = {
name: object()
for name in ("soundfile", "speechbrain", "torch")
if name != missing
}
monkeypatch.setattr(helpers, "_try_import", importer(available))
with caplog.at_level(logging.WARNING):
result = helpers.check_diarization_deps()
assert result is False
assert "speaker labels will be skipped" in caplog.text
def test_print_deps_installed_names_the_interpreter(
fake_whisper: type[FakeWhisper],
importer: Callable[[dict[str, object]], object],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The confirmation line carries the running Python version."""
with caplog.at_level(logging.INFO):
helpers.print_deps_installed()
assert "dependencies installed" in caplog.text

View File

@ -0,0 +1,58 @@
"""Tests for the stdlib-only sine WAV generator.
transcribe.sh uses it to produce a self-test tone without pulling in numpy or
any audio library, so this exercises the real file it writes.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import wave
import transcribe_helpers as helpers
if TYPE_CHECKING:
from pathlib import Path
import pytest
# --------------------------------------------------------------------------- #
# generate_sine_wav
# --------------------------------------------------------------------------- #
def test_generate_sine_wav_writes_a_readable_mono_16k_wav(tmp_path: Path) -> None:
"""The generated file is a real WAV with the documented parameters."""
out = tmp_path / "tone.wav"
assert helpers.generate_sine_wav(str(out)) is True
with wave.open(str(out)) as wf:
assert wf.getnchannels() == 1
assert wf.getsampwidth() == 2
assert wf.getframerate() == 16000
assert wf.getnframes() == 16000 * 3
def test_generate_sine_wav_honours_its_parameters(tmp_path: Path) -> None:
"""Duration and sample rate change the frame count accordingly."""
out = tmp_path / "tone.wav"
helpers.generate_sine_wav(str(out), duration=1, sample_rate=8000)
with wave.open(str(out)) as wf:
assert wf.getframerate() == 8000
assert wf.getnframes() == 8000
def test_generate_sine_wav_reports_failure_on_an_unwritable_path(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An OSError is logged and reported as False rather than raised."""
unwritable = tmp_path / "missing-dir" / "tone.wav"
with caplog.at_level(logging.ERROR):
result = helpers.generate_sine_wav(str(unwritable))
assert result is False
assert "Failed to generate WAV" in caplog.text