aboutsummaryrefslogtreecommitdiff
path: root/lib/project/tests/test_cli.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/tests/test_cli.py')
-rw-r--r--lib/project/tests/test_cli.py287
1 files changed, 287 insertions, 0 deletions
diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py
new file mode 100644
index 0000000..a5437b7
--- /dev/null
+++ b/lib/project/tests/test_cli.py
@@ -0,0 +1,287 @@
+import json
+from pathlib import Path
+import sys
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+from voiceforge import cli, pipeline
+
+
+@pytest.fixture
+def invoke(monkeypatch):
+ def run(*args):
+ monkeypatch.setattr(sys, "argv", ["voiceforge", *map(str, args)])
+ return cli.main()
+
+ return run
+
+
+def test_preview_real_audio_preserves_original_and_reports_provenance(
+ wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch
+):
+ time = np.arange(48000 * 7) / 48000
+ source = wav(0.15 * np.sin(2 * np.pi * 440 * time))
+ original = source.read_bytes()
+ output_dir = tmp_path / "previews"
+ modes = []
+ real_run = pipeline.ffmpeg_run
+
+ def record_render(*args, **kwargs):
+ log = real_run(*args, **kwargs)
+ if args[2] == "Mastering (pass 2)":
+ modes.append(json.loads(log[log.rfind("{"):log.rfind("}") + 1])["normalization_type"])
+ return log
+
+ monkeypatch.setattr(pipeline, "ffmpeg_run", record_render)
+ invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "5",
+ "--profiles", "natural", "radio", "--no-normalize", "--no-limiter",
+ "--output-dir", output_dir)
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Preview:" in captured.err
+ assert source.read_bytes() == original
+ assert len(modes) == 2
+ assert {path.name for path in output_dir.iterdir()} == {
+ f"input.{profile}.preview{suffix}"
+ for profile in ("natural", "radio") for suffix in (".wav", ".report.json")
+ }
+ for profile, mode in zip(("natural", "radio"), modes):
+ target = output_dir / f"input.{profile}.preview.wav"
+ report = json.loads(target.with_suffix(".report.json").read_text())
+ info = sf.info(target)
+ assert info.duration == pytest.approx(5, abs=0.02)
+ assert info.channels == 1
+ assert info.subtype == "PCM_24"
+ assert report["output"] == str(target)
+ assert report["provenance"] == {
+ "original": str(source.resolve()), "preview_start_seconds": 1,
+ "requested_duration_seconds": 5, "normalization_forced": True,
+ }
+ assert not Path(report["source"]).exists()
+ assert report["settings"]["profile"] == profile
+ assert report["settings"]["denoiser"] == "none"
+ assert report["settings"]["normalize"] is True
+ assert report["settings"]["limiter"] is True
+ assert report["settings"]["compressor_ratio"] == {"natural": 2.0, "radio": 3.5}[profile]
+ assert report["normalization_mode"] == mode
+ assert report["master_loudness"]["input_i"] == pytest.approx(-19, abs=0.5)
+ assert report["master_loudness"]["input_tp"] <= -1.4
+
+
+def test_config_stdout_json_and_set_precedence(tmp_path, invoke, capsys):
+ config = tmp_path / "settings.toml"
+ config.write_text('profile = "cleanup-only"\ntarget_lufs = -24.0\n')
+ invoke("config", "--json", "--config", config, "--profile", "narrator",
+ "--target-lufs", "-21", "--set", "target_lufs=-18",
+ "--set", "profile=radio", "--no-denoise")
+ captured = capsys.readouterr()
+ settings = json.loads(captured.out)
+ assert captured.err == ""
+ assert settings["profile"] == "radio"
+ assert settings["compressor_ratio"] == 3.5
+ assert settings["compressor_attack_ms"] == 8
+ assert settings["normalize"] is True
+ assert settings["target_lufs"] == -18
+ assert settings["denoiser"] == "none"
+
+
+def test_analyze_stdout_is_json(wav, ffmpeg, invoke, capsys):
+ source = wav()
+ original = source.read_bytes()
+ invoke("analyze", source, "--no-denoise")
+ stats = json.loads(capsys.readouterr().out)
+ assert stats["duration_seconds"] == pytest.approx(3)
+ assert stats["channels"] == 1
+ assert stats["frames"] == 144000
+ assert np.isfinite(stats["loudness"]["input_i"])
+ assert source.read_bytes() == original
+ assert list(source.parent.iterdir()) == [source]
+
+
+@pytest.mark.parametrize("start", ["nan", "inf", "-inf"])
+def test_preview_rejects_nonfinite_start_before_tool_setup(
+ tmp_path, invoke, capsys, monkeypatch, start
+):
+ monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid start reached FFmpeg"))
+ output_dir = tmp_path / "previews"
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", tmp_path / "missing.wav", f"--start={start}",
+ "--output-dir", output_dir)
+ assert error.value.code == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Preview start" in captured.err
+ assert not output_dir.exists()
+
+
+def test_unknown_setting_is_a_cli_error(invoke, capsys):
+ with pytest.raises(SystemExit) as error:
+ invoke("config", "--json", "--set", "unknown_setting=1")
+ assert error.value.code == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "Unknown setting(s): unknown_setting" in captured.err
+
+
+def test_missing_input_fails_before_tool_setup(tmp_path, invoke, capsys, monkeypatch):
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Missing input reached FFmpeg"))
+ target = tmp_path / "master.wav"
+ with pytest.raises(SystemExit) as error:
+ invoke("process", tmp_path / "missing.wav", "--no-denoise", "-o", target)
+ assert error.value.code == 1
+ assert "existing WAV file" in capsys.readouterr().err
+ assert not target.exists()
+
+
+def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ target = tmp_path / "master.wav"
+ target.write_bytes(b"previous master")
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Existing output reached FFmpeg"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, "--no-denoise", "-o", target)
+ assert error.value.code == 1
+ assert "Output exists" in capsys.readouterr().err
+ assert target.read_bytes() == b"previous master"
+ assert source.read_bytes() == original
+ assert not target.with_suffix(".report.json").exists()
+
+
+def test_overwrite_cannot_replace_input(wav, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Input collision reached processing"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, "-o", source, "--overwrite")
+ assert error.value.code == 1
+ assert "overwrite an input recording" in capsys.readouterr().err
+ assert source.read_bytes() == original
+
+
+def test_duplicate_batch_names_fail_before_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ (tmp_path / "other").mkdir()
+ sources = [wav(), wav(name="other/input.wav")]
+ originals = [source.read_bytes() for source in sources]
+ output_dir = tmp_path / "masters"
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Duplicate names reached processing"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", *sources, "--output-dir", output_dir, "--overwrite")
+ assert error.value.code == 1
+ assert "duplicate output names" in capsys.readouterr().err
+ assert not output_dir.exists()
+ assert [source.read_bytes() for source in sources] == originals
+
+
+def test_default_outputs_are_written_beside_each_input(wav, tmp_path, ffmpeg, invoke, capsys):
+ (tmp_path / "a").mkdir()
+ (tmp_path / "b").mkdir()
+ sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
+ invoke("process", *sources, "--no-denoise")
+ for source in sources:
+ assert {path.name for path in source.parent.iterdir()} == {
+ "take.wav", "take.natural.wav", "take.natural.report.json"}
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err.count("Written:") == 2
+
+
+def test_output_and_output_dir_cannot_be_combined(wav, tmp_path, invoke, capsys):
+ with pytest.raises(SystemExit) as error:
+ invoke("process", wav(), "-o", tmp_path / "x.wav", "--output-dir", tmp_path / "outs")
+ assert error.value.code == 1
+ assert "--output-dir cannot be combined" in capsys.readouterr().err
+
+
+def test_missing_later_input_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ source = wav()
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", source, tmp_path / "missing.wav", "--no-denoise")
+ assert error.value.code == 1
+ assert "existing WAV file" in capsys.readouterr().err
+ assert not (tmp_path / "input.natural.wav").exists()
+
+
+def test_existing_output_in_batch_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch):
+ (tmp_path / "a").mkdir()
+ (tmp_path / "b").mkdir()
+ sources = [wav(name="a/take.wav"), wav(name="b/take.wav")]
+ (tmp_path / "b" / "take.natural.wav").write_bytes(b"previous result")
+ monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch"))
+ with pytest.raises(SystemExit) as error:
+ invoke("process", *sources, "--no-denoise")
+ assert error.value.code == 1
+ assert "Output exists" in capsys.readouterr().err
+ assert not (tmp_path / "a" / "take.natural.wav").exists()
+
+
+def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, capsys, monkeypatch):
+ source = wav()
+ monkeypatch.setattr(cli, "ensure_ffmpeg", lambda: pytest.fail("Invalid preview plan reached FFmpeg"))
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--profiles", "natural", "natural")
+ assert error.value.code == 1
+ assert "unique" in capsys.readouterr().err
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--profiles", "natural",
+ "--set", "profile=radio")
+ assert error.value.code == 1
+ assert "remove the profile override" in capsys.readouterr().err
+ assert list(source.parent.iterdir()) == [source]
+
+
+def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys):
+ source = wav()
+ original = source.read_bytes()
+ invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "2",
+ "--profiles", "natural")
+ assert {path.name for path in tmp_path.iterdir()} == {
+ "input.wav", "input.natural.preview.wav", "input.natural.preview.report.json"}
+ report = json.loads((tmp_path / "input.natural.preview.report.json").read_text())
+ assert report["settings"]["profile"] == "natural"
+ assert source.read_bytes() == original
+
+
+def test_preview_cancellation_cleans_excerpt(wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ output_dir = tmp_path / "previews"
+ excerpts = []
+
+ def cancel(excerpt, *args, **kwargs):
+ assert excerpt.is_file()
+ excerpts.append(excerpt)
+ raise KeyboardInterrupt
+
+ monkeypatch.setattr(cli, "process", cancel)
+ with pytest.raises(SystemExit) as error:
+ invoke("preview", source, "--no-denoise", "--output-dir", output_dir)
+ assert error.value.code == 130
+ assert "Cancelled" in capsys.readouterr().err
+ assert len(excerpts) == 1
+ assert not excerpts[0].exists()
+ assert list(output_dir.iterdir()) == []
+ assert source.read_bytes() == original
+
+
+@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"])
+def test_nonfile_output_with_overwrite_fails_before_tool_setup(
+ wav, tmp_path, bypass, progress, monkeypatch, suffix
+):
+ source = wav()
+ original = source.read_bytes()
+ target = tmp_path / "master.wav"
+ nonfile = target.with_suffix(suffix)
+ nonfile.mkdir()
+ bypass.mp3 = True
+ monkeypatch.setattr(pipeline, "ensure_ffmpeg", lambda: pytest.fail("Non-file output reached FFmpeg"))
+ with pytest.raises(ValueError, match="Output is not a regular file"):
+ pipeline.process(source, target, bypass, progress, overwrite=True)
+ assert nonfile.is_dir()
+ assert list(nonfile.iterdir()) == []
+ assert set(tmp_path.iterdir()) == {source, nonfile}
+ assert source.read_bytes() == original