import shutil import subprocess import wave from collections.abc import Callable from pathlib import Path import pytest from src.atmos.mediainfo_check import ( AssetFetchError, MediainfoError, MediainfoOutputError, check_mediainfo, run_mediainfo, ) from src.atmos.models import AtmosValidationBuilder, AtmosValidationFindingCode from src.atmos.validator import validate from tests.conftest import path_to_url _FIXTURES_DIR = Path(__file__).parent.parent.parent / "fixtures" _DOLBY_RENDERER_FIXTURE = _FIXTURES_DIR / "dolby_renderer_silent_2s.wav" # Runs real mediainfo on WAVs generated via Python's stdlib wave module (format # tag 0x0001 = WAVE_FORMAT_PCM, matching the Dolby Atmos Master ADM Profile # spec). mp3 fixtures use ffmpeg because wave only emits WAV. pytestmark = pytest.mark.skipif( shutil.which("mediainfo") is None, reason="mediainfo not installed", ) def _write_test_wav( path: Path, *, sample_rate_hz: int = 48000, bit_depth: int = 24, num_samples: int = 6000, ) -> None: with wave.open(str(path), "wb") as writer: writer.setnchannels(1) writer.setsampwidth(bit_depth // 8) writer.setframerate(sample_rate_hz) writer.writeframes(b"\x00" * num_samples * (bit_depth // 8)) class TestRealMediainfoParse: def test_clean_48khz_24bit_wav(self, tmp_path: Path) -> None: wav_path = tmp_path / "clean.wav" _write_test_wav(wav_path, num_samples=6000) output = run_mediainfo(path_to_url(wav_path)) assert output.container == "Wave" assert output.codec == "PCM" assert output.codec_id == "1" assert output.sample_rate_hz == 48000 assert output.bits_per_sample == 24 assert output.is_truncated is False # 6000 samples / 48000 Hz = 0.125 s = 125 ms assert output.duration_ms == 125 class TestRealCheckMediainfo: def test_valid_48khz_24bit_passes_format_checks(self, tmp_path: Path) -> None: wav_path = tmp_path / "valid.wav" _write_test_wav(wav_path, num_samples=4800) # 0.1 s atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(wav_path), atmos_validation_builder) # Expected: container/codec/sample-rate/bit-depth/LPCM all pass. ADM profile is # absent in a stdlib-wave-generated file, so NOT_DOLBY_ATMOS will fire — tested below. error_keys = atmos_validation_builder.errors.keys() assert AtmosValidationFindingCode.NOT_WAVE.metadata_key not in error_keys assert AtmosValidationFindingCode.NOT_PCM.metadata_key not in error_keys assert AtmosValidationFindingCode.NOT_LPCM.metadata_key not in error_keys assert AtmosValidationFindingCode.WRONG_BIT_DEPTH.metadata_key not in error_keys assert AtmosValidationFindingCode.WRONG_SAMPLE_RATE.metadata_key not in error_keys assert AtmosValidationFindingCode.ATMOS_FILE_TRUNCATED.metadata_key not in error_keys @pytest.mark.parametrize( ("wav_kwargs", "expected_code"), [ pytest.param( {"sample_rate_hz": 44100, "num_samples": 4410}, AtmosValidationFindingCode.WRONG_SAMPLE_RATE, id="wrong_sample_rate", ), pytest.param( {"bit_depth": 16, "num_samples": 4800}, AtmosValidationFindingCode.WRONG_BIT_DEPTH, id="wrong_bit_depth", ), ], ) def test_wrong_wav_parameter_triggers_finding( self, tmp_path: Path, wav_kwargs: dict[str, object], expected_code: AtmosValidationFindingCode ) -> None: wav_path = tmp_path / "wrong.wav" _write_test_wav(wav_path, **wav_kwargs) # type: ignore[arg-type] atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(wav_path), atmos_validation_builder) assert expected_code.metadata_key in atmos_validation_builder.errors def test_truncated_wav_detected(self, tmp_path: Path) -> None: wav_path = tmp_path / "truncated.wav" _write_test_wav(wav_path, num_samples=6000) data = wav_path.read_bytes() wav_path.write_bytes(data[: len(data) // 2]) atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(wav_path), atmos_validation_builder) assert AtmosValidationFindingCode.ATMOS_FILE_TRUNCATED.metadata_key in atmos_validation_builder.errors def test_no_adm_profile_triggers_dolby_atmos_finding(self, tmp_path: Path) -> None: wav_path = tmp_path / "no_adm.wav" _write_test_wav(wav_path, num_samples=4800) atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(wav_path), atmos_validation_builder) assert AtmosValidationFindingCode.NOT_DOLBY_ATMOS.metadata_key in atmos_validation_builder.errors def _write_png(path: Path) -> None: subprocess.run( [ "ffmpeg", "-f", "lavfi", "-i", "color=c=red:size=32x32:duration=0.04", "-frames:v", "1", str(path), "-y", ], capture_output=True, check=True, ) def _write_random_bytes(path: Path) -> None: path.write_bytes(b"\x00\x01\x02\x03\xff\xfe\xfd\xfc" * 128) def _write_empty_file(path: Path) -> None: path.touch() def _write_nothing(path: Path) -> None: pass class TestRealGracefulDegradation: def test_mp3_emits_findings_not_crash(self, tmp_path: Path) -> None: mp3_path = tmp_path / "input.mp3" subprocess.run( [ "ffmpeg", "-f", "lavfi", "-i", "sine=frequency=1000:duration=0.1", "-c:a", "libmp3lame", str(mp3_path), "-y", ], capture_output=True, check=True, ) atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(mp3_path), atmos_validation_builder) # MP3 parses successfully but fails the container/codec checks. assert AtmosValidationFindingCode.NOT_WAVE.metadata_key in atmos_validation_builder.errors assert AtmosValidationFindingCode.NOT_PCM.metadata_key in atmos_validation_builder.errors @pytest.mark.parametrize( ("file_creator", "filename"), [ pytest.param(_write_png, "input.png", id="png_image"), pytest.param(_write_random_bytes, "garbage.bin", id="random_bytes"), pytest.param(_write_empty_file, "empty.bin", id="empty_file"), pytest.param(_write_nothing, "does-not-exist.wav", id="nonexistent_file"), ], ) def test_non_media_input_raises( self, tmp_path: Path, file_creator: Callable[[Path], None], filename: str, ) -> None: path = tmp_path / filename file_creator(path) with pytest.raises((AssetFetchError, MediainfoError, MediainfoOutputError)): check_mediainfo(path_to_url(path), AtmosValidationBuilder()) class TestRealDolbyRendererFile: def test_parses_expected_fields(self) -> None: output = run_mediainfo(path_to_url(_DOLBY_RENDERER_FIXTURE)) assert output.container == "Wave" assert output.codec == "PCM" assert output.codec_id == "1" assert output.channels == 10 assert output.sample_rate_hz == 48000 assert output.bits_per_sample == 24 assert output.is_truncated is False assert output.adm_profile == "Dolby Atmos Master" assert output.duration_ms == 2000 def test_passes_all_format_checks(self) -> None: atmos_validation_builder = AtmosValidationBuilder() check_mediainfo(path_to_url(_DOLBY_RENDERER_FIXTURE), atmos_validation_builder) assert atmos_validation_builder.errors == {} class TestRealDurationMatch: def test_matching_durations_no_finding(self, tmp_path: Path) -> None: atmos_path = tmp_path / "atmos.wav" stereo_path = tmp_path / "stereo.wav" _write_test_wav(atmos_path, num_samples=24000) # 0.5 s _write_test_wav(stereo_path, num_samples=24000) # 0.5 s atmos_validation_builder = validate(path_to_url(atmos_path), path_to_url(stereo_path)) assert AtmosValidationFindingCode.DURATION_MISMATCH.metadata_key not in atmos_validation_builder.errors def test_durations_over_threshold_emits_finding(self, tmp_path: Path) -> None: # Atmos 2600 ms vs stereo 500 ms — 2100 ms diff, over the 2000 ms threshold. atmos_path = tmp_path / "atmos.wav" stereo_path = tmp_path / "stereo.wav" _write_test_wav(atmos_path, num_samples=124800) # 2.6 s _write_test_wav(stereo_path, num_samples=24000) # 0.5 s atmos_validation_builder = validate(path_to_url(atmos_path), path_to_url(stereo_path)) assert AtmosValidationFindingCode.DURATION_MISMATCH.metadata_key in atmos_validation_builder.errors def test_stereo_file_missing_raises(self, tmp_path: Path) -> None: atmos_path = tmp_path / "atmos.wav" _write_test_wav(atmos_path, num_samples=24000) missing_stereo = tmp_path / "does-not-exist.wav" with pytest.raises((AssetFetchError, MediainfoError, MediainfoOutputError)): validate(path_to_url(atmos_path), path_to_url(missing_stereo))