voice-tools / tests /contract /test_cli_contracts.py
jcudit's picture
jcudit HF Staff
feat: complete audio speaker separation feature with 3 workflows
cb39c05
"""
Contract tests for CLI commands
Tests that CLI interface contracts match specifications from contracts/cli-commands.md
"""
import json
import subprocess
from pathlib import Path
import pytest
class TestSeparateCLIContract:
"""Contract tests for 'voice-tools separate' command."""
def test_separate_command_exists(self):
"""Test that separate command is registered."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
assert result.returncode == 0
assert "separate" in result.stdout
def test_separate_help_text(self):
"""Test that separate command has help documentation."""
result = subprocess.run(
["voice-tools", "separate", "--help"], capture_output=True, text=True
)
assert result.returncode == 0
help_text = result.stdout.lower()
# Should document key parameters
assert "input" in help_text or "file" in help_text
assert "output" in help_text
assert "speaker" in help_text
def test_separate_required_argument(self):
"""Test that INPUT_FILE argument is required."""
result = subprocess.run(["voice-tools", "separate"], capture_output=True, text=True)
# Should fail without input file
assert result.returncode != 0
assert "required" in result.stderr.lower() or "missing" in result.stderr.lower()
def test_separate_optional_arguments(self):
"""Test that optional arguments are accepted."""
# Test --help to verify argument parsing without actual execution
result = subprocess.run(
["voice-tools", "separate", "--help"], capture_output=True, text=True
)
help_text = result.stdout
# Verify optional arguments from contract
assert "--output-dir" in help_text or "-o" in help_text
assert "--min-speakers" in help_text
assert "--max-speakers" in help_text
assert "--output-format" in help_text or "format" in help_text.lower()
def test_separate_output_dir_default(self, tmp_path):
"""Test that default output directory is used when not specified."""
# Create minimal test (will fail due to missing audio, but tests argument parsing)
input_file = tmp_path / "test.m4a"
input_file.touch()
result = subprocess.run(
["voice-tools", "separate", str(input_file)],
capture_output=True,
text=True,
cwd=str(tmp_path),
)
# Command should attempt to use default directory
# (May fail on invalid audio, but should not fail on missing --output-dir)
assert "--output-dir" not in result.stderr or "required" not in result.stderr.lower()
def test_separate_exit_codes(self):
"""Test that appropriate exit codes are returned.
Contract specifies:
- 0: Success
- 1: Invalid arguments or file not found
- 2: Unsupported audio format
- 3: Processing error
- 4: Output write error
"""
# Test exit code 1: File not found
result = subprocess.run(
["voice-tools", "separate", "nonexistent.m4a"], capture_output=True, text=True
)
assert result.returncode == 1
assert "not found" in result.stderr.lower() or "error" in result.stderr.lower()
def test_separate_error_messages(self):
"""Test that error messages are informative."""
# Test with nonexistent file
result = subprocess.run(
["voice-tools", "separate", "nonexistent.m4a"], capture_output=True, text=True
)
assert result.returncode != 0
# Error message should be clear
error_msg = result.stderr.lower()
assert "error" in error_msg or "failed" in error_msg
assert "nonexistent.m4a" in error_msg or "not found" in error_msg
def test_separate_min_max_speakers_validation(self):
"""Test that min/max speakers are validated."""
# Test with min > max (should fail validation)
result = subprocess.run(
[
"voice-tools",
"separate",
"test.m4a",
"--min-speakers",
"5",
"--max-speakers",
"2",
],
capture_output=True,
text=True,
)
# Should fail with validation error
# (May fail on missing file first, but validates if file exists)
assert result.returncode != 0
@pytest.mark.integration
def test_separate_output_structure(self, tmp_path):
"""Test that output follows specified structure.
Contract specifies:
- {output-dir}/speaker_00.m4a
- {output-dir}/speaker_01.m4a
- {output-dir}/separation_report.json
"""
# This test requires actual audio processing
# Skip if no test audio available
test_audio = Path("audio_fixtures/multi_speaker/two_speakers.m4a")
if not test_audio.exists():
pytest.skip("Test audio not available")
output_dir = tmp_path / "output"
result = subprocess.run(
["voice-tools", "separate", str(test_audio), "--output-dir", str(output_dir)],
capture_output=True,
text=True,
)
if result.returncode == 0:
# Verify output structure
assert output_dir.exists()
# Should have separation_report.json
report_file = output_dir / "separation_report.json"
assert report_file.exists()
# Load and validate report structure
with open(report_file) as f:
report = json.load(f)
assert "input_file" in report
assert "speakers_detected" in report
assert "processing_time_seconds" in report
assert "output_files" in report
@pytest.mark.integration
def test_separate_progress_indicators(self, tmp_path):
"""Test that progress is displayed during processing."""
test_audio = Path("audio_fixtures/multi_speaker/two_speakers.m4a")
if not test_audio.exists():
pytest.skip("Test audio not available")
output_dir = tmp_path / "output"
result = subprocess.run(
["voice-tools", "separate", str(test_audio), "--output-dir", str(output_dir)],
capture_output=True,
text=True,
)
# Should show progress in stdout
if result.returncode == 0:
output = result.stdout.lower()
# Check for progress indicators
assert "loading" in output or "processing" in output or "separating" in output
class TestCLICommonBehavior:
"""Test common CLI behavior across all commands."""
def test_version_flag(self):
"""Test that --version flag works."""
result = subprocess.run(["voice-tools", "--version"], capture_output=True, text=True)
assert result.returncode == 0
# Should show version number
assert any(char.isdigit() for char in result.stdout)
def test_help_flag(self):
"""Test that --help flag works."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
assert result.returncode == 0
assert "usage" in result.stdout.lower()
def test_command_list(self):
"""Test that all commands are listed in help."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
assert result.returncode == 0
# Should list available commands
assert "separate" in result.stdout.lower()
# May also show other commands if they exist
def test_verbose_flag(self):
"""Test that -v/--verbose flag is accepted."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
# Check if verbose flag is documented
# (May not be implemented yet, so don't assert)
has_verbose = "-v" in result.stdout or "--verbose" in result.stdout
def test_quiet_flag(self):
"""Test that -q/--quiet flag is accepted."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
# Check if quiet flag is documented
has_quiet = "-q" in result.stdout or "--quiet" in result.stdout
class TestExtractSpeakerCLIContract:
"""Contract tests for 'voice-tools extract-speaker' command (US2)."""
def test_extract_speaker_command_exists(self):
"""Test that extract-speaker command is registered."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
assert result.returncode == 0
assert "extract-speaker" in result.stdout or "extract_speaker" in result.stdout
def test_extract_speaker_help_text(self):
"""Test that extract-speaker command has help documentation."""
result = subprocess.run(
["voice-tools", "extract-speaker", "--help"], capture_output=True, text=True
)
assert result.returncode == 0
help_text = result.stdout.lower()
# Should document key parameters
assert "reference" in help_text
assert "target" in help_text
assert "threshold" in help_text
assert "output" in help_text
def test_extract_speaker_required_arguments(self):
"""Test that REFERENCE_CLIP and TARGET_FILE arguments are required."""
result = subprocess.run(
["voice-tools", "extract-speaker"], capture_output=True, text=True
)
# Should fail without required arguments
assert result.returncode != 0
assert "required" in result.stderr.lower() or "missing" in result.stderr.lower()
def test_extract_speaker_optional_arguments(self):
"""Test that optional arguments are accepted."""
result = subprocess.run(
["voice-tools", "extract-speaker", "--help"], capture_output=True, text=True
)
help_text = result.stdout
# Verify optional arguments from contract
assert "--output" in help_text or "-o" in help_text
assert "--threshold" in help_text
assert "--min-confidence" in help_text
assert "--concatenate" in help_text or "--no-concatenate" in help_text
def test_extract_speaker_threshold_validation(self):
"""Test that threshold is validated (must be between 0.0 and 1.0)."""
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
"ref.m4a",
"target.m4a",
"--threshold",
"1.5", # Invalid: > 1.0
],
capture_output=True,
text=True,
)
# Should fail with validation error
assert result.returncode != 0
def test_extract_speaker_exit_codes(self):
"""Test that appropriate exit codes are returned.
Contract specifies:
- 0: Success (at least 1 segment found)
- 1: Invalid arguments or file not found
- 2: Unsupported audio format
- 3: Processing error or no matches found
- 4: Reference clip too short or poor quality
- 5: Output write error
"""
# Test exit code 1: File not found
result = subprocess.run(
["voice-tools", "extract-speaker", "nonexistent.m4a", "target.m4a"],
capture_output=True,
text=True,
)
assert result.returncode in [1, 2, 3] # Various error codes possible
assert "not found" in result.stderr.lower() or "error" in result.stderr.lower()
def test_extract_speaker_error_messages(self):
"""Test that error messages are informative."""
# Test with nonexistent reference file
result = subprocess.run(
["voice-tools", "extract-speaker", "nonexistent.m4a", "target.m4a"],
capture_output=True,
text=True,
)
assert result.returncode != 0
# Error message should be clear
error_msg = result.stderr.lower()
assert "error" in error_msg or "failed" in error_msg
assert "nonexistent.m4a" in error_msg or "not found" in error_msg
@pytest.mark.integration
def test_extract_speaker_output_structure_concatenated(self, tmp_path):
"""Test that output follows specified structure for concatenated mode.
Contract specifies:
- extracted_speaker.m4a
- extraction_report.json
"""
test_reference = Path("audio_fixtures/speaker_extraction/reference_speaker_a.m4a")
test_target = Path("audio_fixtures/speaker_extraction/multi_speaker_conversation.m4a")
if not test_reference.exists() or not test_target.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "extracted.m4a"
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
str(test_reference),
str(test_target),
"--output",
str(output_file),
],
capture_output=True,
text=True,
)
if result.returncode == 0:
# Verify output file exists
assert output_file.exists()
# Verify report exists
report_file = output_file.parent / "extraction_report.json"
if report_file.exists():
with open(report_file) as f:
report = json.load(f)
# Validate report structure
assert "reference_clip" in report
assert "target_file" in report
assert "threshold" in report
assert "segments_found" in report
assert "segments_included" in report
assert "total_duration_seconds" in report
assert "average_confidence" in report
@pytest.mark.integration
def test_extract_speaker_output_structure_separate(self, tmp_path):
"""Test that output follows specified structure for separate segments mode.
Contract specifies:
- {output_prefix}/segment_001.m4a
- {output_prefix}/segment_002.m4a
- {output_prefix}/extraction_report.json
"""
test_reference = Path("audio_fixtures/speaker_extraction/reference_speaker_a.m4a")
test_target = Path("audio_fixtures/speaker_extraction/multi_speaker_conversation.m4a")
if not test_reference.exists() or not test_target.exists():
pytest.skip("Test audio not available")
output_dir = tmp_path / "segments"
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
str(test_reference),
str(test_target),
"--no-concatenate",
"--output",
str(output_dir),
],
capture_output=True,
text=True,
)
if result.returncode == 0:
# Verify output directory exists
assert output_dir.exists()
# Verify segment files exist
segment_files = list(output_dir.glob("segment_*.m4a"))
assert len(segment_files) > 0
# Verify report exists
report_file = output_dir / "extraction_report.json"
if report_file.exists():
with open(report_file) as f:
report = json.load(f)
assert report["segments_included"] == len(segment_files)
@pytest.mark.integration
def test_extract_speaker_no_matches_warning(self, tmp_path):
"""Test that appropriate warning is shown when no matches found."""
test_reference = Path("audio_fixtures/speaker_extraction/reference_speaker_a.m4a")
test_target = Path("audio_fixtures/speaker_extraction/different_speaker_only.m4a")
if not test_reference.exists() or not test_target.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "no_matches.m4a"
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
str(test_reference),
str(test_target),
"--output",
str(output_file),
],
capture_output=True,
text=True,
)
# Should return exit code 3 for no matches
if result.returncode == 3:
error_msg = result.stderr.lower() + result.stdout.lower()
assert "no" in error_msg and ("match" in error_msg or "segment" in error_msg)
@pytest.mark.integration
def test_extract_speaker_reference_too_short(self, tmp_path):
"""Test that error is shown when reference clip is too short."""
test_reference = Path("audio_fixtures/speaker_extraction/reference_too_short.m4a")
test_target = Path("audio_fixtures/speaker_extraction/multi_speaker_conversation.m4a")
if not test_reference.exists() or not test_target.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "extracted.m4a"
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
str(test_reference),
str(test_target),
"--output",
str(output_file),
],
capture_output=True,
text=True,
)
# Should return exit code 4 for reference clip issues
if result.returncode == 4:
error_msg = result.stderr.lower()
assert "short" in error_msg or "minimum" in error_msg
@pytest.mark.integration
def test_extract_speaker_progress_indicators(self, tmp_path):
"""Test that progress is displayed during processing."""
test_reference = Path("audio_fixtures/speaker_extraction/reference_speaker_a.m4a")
test_target = Path("audio_fixtures/speaker_extraction/multi_speaker_conversation.m4a")
if not test_reference.exists() or not test_target.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "extracted.m4a"
result = subprocess.run(
[
"voice-tools",
"extract-speaker",
str(test_reference),
str(test_target),
"--output",
str(output_file),
],
capture_output=True,
text=True,
)
# Should show progress in stdout
if result.returncode == 0:
output = result.stdout.lower()
# Check for progress indicators
assert (
"loading" in output
or "processing" in output
or "extracting" in output
or "matching" in output
)
def test_extract_speaker_concatenate_options(self):
"""Test that concatenation options are accepted."""
result = subprocess.run(
["voice-tools", "extract-speaker", "--help"], capture_output=True, text=True
)
help_text = result.stdout.lower()
# Should document concatenation options
assert "--silence" in help_text or "silence" in help_text
assert "--crossfade" in help_text or "crossfade" in help_text
class TestDenoiseCLIContract:
"""Contract tests for 'voice-tools denoise' command (US3)."""
def test_denoise_command_exists(self):
"""Test that denoise command is registered."""
result = subprocess.run(["voice-tools", "--help"], capture_output=True, text=True)
assert result.returncode == 0
assert "denoise" in result.stdout
def test_denoise_help_text(self):
"""Test that denoise command has help documentation."""
result = subprocess.run(
["voice-tools", "denoise", "--help"], capture_output=True, text=True
)
assert result.returncode == 0
help_text = result.stdout.lower()
# Should document key parameters
assert "input" in help_text or "file" in help_text
assert "output" in help_text
assert "threshold" in help_text or "silence" in help_text
def test_denoise_required_argument(self):
"""Test that INPUT_FILE argument is required."""
result = subprocess.run(["voice-tools", "denoise"], capture_output=True, text=True)
# Should fail without input file
assert result.returncode != 0
assert "required" in result.stderr.lower() or "missing" in result.stderr.lower()
def test_denoise_optional_arguments(self):
"""Test that optional arguments are accepted."""
result = subprocess.run(
["voice-tools", "denoise", "--help"], capture_output=True, text=True
)
help_text = result.stdout
# Verify optional arguments from contract
assert "--output" in help_text or "-o" in help_text
assert "--vad-threshold" in help_text or "vad" in help_text.lower()
assert "--silence-threshold" in help_text or "silence" in help_text.lower()
assert "--crossfade" in help_text or "crossfade" in help_text.lower()
def test_denoise_threshold_validation(self):
"""Test that VAD threshold is validated (must be between 0.0 and 1.0)."""
result = subprocess.run(
[
"voice-tools",
"denoise",
"test.m4a",
"--vad-threshold",
"1.5", # Invalid: > 1.0
],
capture_output=True,
text=True,
)
# Should fail with validation error
assert result.returncode != 0
def test_denoise_exit_codes(self):
"""Test that appropriate exit codes are returned.
Contract specifies:
- 0: Success (audio processed)
- 1: Invalid arguments or file not found
- 2: Unsupported audio format
- 3: Processing error
- 4: Output write error
"""
# Test exit code 1: File not found
result = subprocess.run(
["voice-tools", "denoise", "nonexistent.m4a"], capture_output=True, text=True
)
assert result.returncode == 1
assert "not found" in result.stderr.lower() or "error" in result.stderr.lower()
def test_denoise_error_messages(self):
"""Test that error messages are informative."""
# Test with nonexistent file
result = subprocess.run(
["voice-tools", "denoise", "nonexistent.m4a"], capture_output=True, text=True
)
assert result.returncode != 0
# Error message should be clear
error_msg = result.stderr.lower()
assert "error" in error_msg or "failed" in error_msg
assert "nonexistent.m4a" in error_msg or "not found" in error_msg
@pytest.mark.integration
def test_denoise_output_structure(self, tmp_path):
"""Test that output follows specified structure.
Contract specifies:
- denoised audio file at specified output path
- denoising_report.json with metrics
"""
test_audio = Path("audio_fixtures/noisy/background_noise.m4a")
if not test_audio.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "denoised.m4a"
result = subprocess.run(
["voice-tools", "denoise", str(test_audio), "--output", str(output_file)],
capture_output=True,
text=True,
)
if result.returncode == 0:
# Verify output file exists
assert output_file.exists()
# Verify report exists
report_file = output_file.parent / "denoising_report.json"
if report_file.exists():
with open(report_file) as f:
report = json.load(f)
# Validate report structure
assert "input_file" in report
assert "segments_kept" in report
assert "segments_removed" in report
assert "original_duration" in report
assert "output_duration" in report
assert "compression_ratio" in report
@pytest.mark.integration
def test_denoise_progress_indicators(self, tmp_path):
"""Test that progress is displayed during processing."""
test_audio = Path("audio_fixtures/noisy/background_noise.m4a")
if not test_audio.exists():
pytest.skip("Test audio not available")
output_file = tmp_path / "denoised.m4a"
result = subprocess.run(
["voice-tools", "denoise", str(test_audio), "--output", str(output_file)],
capture_output=True,
text=True,
)
# Should show progress in stdout
if result.returncode == 0:
output = result.stdout.lower()
# Check for progress indicators
assert (
"loading" in output
or "processing" in output
or "denoising" in output
or "detecting" in output
)