""" 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 )