Spaces:
Running
on
Zero
Running
on
Zero
| """ | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |
| 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) | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| ) | |