From 7587cd960408208036d46fddef5cfa319057fc62 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 13:29:07 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../transcribe-helpers-tests-2026-08-17.json | 18 ++ .../transcribe-helpers-tests-2026-08-17.json | 66 ++++++ linux_configuration/tests/conftest.py | 58 +++++ .../tests/test_transcribe_helpers_cli.py | 223 ++++++++++++++++++ .../tests/test_transcribe_model_ops.py | 143 +++++++++++ .../tests/test_transcribe_probes.py | 125 ++++++++++ .../tests/test_transcribe_wav.py | 58 +++++ 7 files changed, 691 insertions(+) create mode 100644 docs/superpowers/contracts/transcribe-helpers-tests-2026-08-17.json create mode 100644 docs/superpowers/evidence/transcribe-helpers-tests-2026-08-17.json create mode 100644 linux_configuration/tests/test_transcribe_helpers_cli.py create mode 100644 linux_configuration/tests/test_transcribe_model_ops.py create mode 100644 linux_configuration/tests/test_transcribe_probes.py create mode 100644 linux_configuration/tests/test_transcribe_wav.py diff --git a/docs/superpowers/contracts/transcribe-helpers-tests-2026-08-17.json b/docs/superpowers/contracts/transcribe-helpers-tests-2026-08-17.json new file mode 100644 index 00000000..f0f4f9fe --- /dev/null +++ b/docs/superpowers/contracts/transcribe-helpers-tests-2026-08-17.json @@ -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." +} diff --git a/docs/superpowers/evidence/transcribe-helpers-tests-2026-08-17.json b/docs/superpowers/evidence/transcribe-helpers-tests-2026-08-17.json new file mode 100644 index 00000000..5f359c8b --- /dev/null +++ b/docs/superpowers/evidence/transcribe-helpers-tests-2026-08-17.json @@ -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 /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 ", + "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." + ] +} diff --git a/linux_configuration/tests/conftest.py b/linux_configuration/tests/conftest.py index 5671d73b..695325de 100644 --- a/linux_configuration/tests/conftest.py +++ b/linux_configuration/tests/conftest.py @@ -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 diff --git a/linux_configuration/tests/test_transcribe_helpers_cli.py b/linux_configuration/tests/test_transcribe_helpers_cli.py new file mode 100644 index 00000000..ac7e3f7b --- /dev/null +++ b/linux_configuration/tests/test_transcribe_helpers_cli.py @@ -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") diff --git a/linux_configuration/tests/test_transcribe_model_ops.py b/linux_configuration/tests/test_transcribe_model_ops.py new file mode 100644 index 00000000..155ade12 --- /dev/null +++ b/linux_configuration/tests/test_transcribe_model_ops.py @@ -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 diff --git a/linux_configuration/tests/test_transcribe_probes.py b/linux_configuration/tests/test_transcribe_probes.py new file mode 100644 index 00000000..123c486f --- /dev/null +++ b/linux_configuration/tests/test_transcribe_probes.py @@ -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 diff --git a/linux_configuration/tests/test_transcribe_wav.py b/linux_configuration/tests/test_transcribe_wav.py new file mode 100644 index 00000000..b4faa8b7 --- /dev/null +++ b/linux_configuration/tests/test_transcribe_wav.py @@ -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