"""ffmpeg 基础设施模块单元测试。""" import json import subprocess from unittest.mock import MagicMock, patch import pytest from yuxi.knowledge.parser.ffmpeg import ( FFMPEG_MAX_AUDIO_DURATION_SECS, FFMPEG_MAX_BUFFER_BYTES, FFMPEG_MAX_CONCURRENT, FFMPEG_TIMEOUT_MS, FFPROBE_TIMEOUT_MS, FFmpegError, MAX_AUDIO_BYTES, MAX_VIDEO_BYTES, convert_to_wav, extract_audio_from_video, extract_keyframes, probe_audio_duration, run_ffmpeg, run_ffprobe, ) class TestFFmpegError: def test_error_attributes(self): err = FFmpegError("test error", returncode=1) assert str(err) == "test error" assert err.returncode == 1 assert err.message == "test error" def test_default_returncode(self): err = FFmpegError("test") assert err.returncode == -1 class TestRunFFmpeg: @patch("yuxi.knowledge.parser.ffmpeg.subprocess.run") def test_success(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stdout=b"output", stderr=b"") result = run_ffmpeg(["-version"]) assert result == "output" mock_run.assert_called_once() @patch("yuxi.knowledge.parser.ffmpeg.subprocess.run") def test_timeout(self, mock_run): mock_run.side_effect = subprocess.TimeoutExpired(cmd="ffmpeg", timeout=45) with pytest.raises(FFmpegError, match="超时"): run_ffmpeg(["-i", "input.mp4"]) @patch("yuxi.knowledge.parser.ffmpeg.subprocess.run") def test_nonzero_returncode(self, mock_run): mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"error msg") with pytest.raises(FFmpegError, match="非零退出码"): run_ffmpeg(["-i", "bad.mp4"]) class TestRunFFprobe: @patch("yuxi.knowledge.parser.ffmpeg.subprocess.run") def test_success(self, mock_run): mock_run.return_value = MagicMock(returncode=0, stdout=b'{"format":{}}', stderr=b"") result = run_ffprobe(["-show_format", "test.mp3"]) assert result == '{"format":{}}' class TestProbeAudioDuration: @patch("yuxi.knowledge.parser.ffmpeg.run_ffprobe") def test_success(self, mock_ffprobe): mock_ffprobe.return_value = json.dumps({"format": {"duration": "120.5"}}) duration = probe_audio_duration("test.mp3") assert duration == 120.5 @patch("yuxi.knowledge.parser.ffmpeg.run_ffprobe") def test_failure_returns_none(self, mock_ffprobe): mock_ffprobe.side_effect = FFmpegError("failed") duration = probe_audio_duration("bad.mp3") assert duration is None class TestConvertToWav: @patch("yuxi.knowledge.parser.ffmpeg.run_ffmpeg") def test_calls_ffmpeg_with_correct_args(self, mock_ffmpeg): convert_to_wav("input.mp3", "output.wav") mock_ffmpeg.assert_called_once() args = mock_ffmpeg.call_args[0][0] assert "-i" in args assert "input.mp3" in args assert "-ar" in args assert "16000" in args assert "-ac" in args assert "1" in args class TestExtractAudioFromVideo: @patch("yuxi.knowledge.parser.ffmpeg.run_ffmpeg") def test_calls_ffmpeg_with_correct_args(self, mock_ffmpeg): extract_audio_from_video("video.mp4", "audio.wav") mock_ffmpeg.assert_called_once() args = mock_ffmpeg.call_args[0][0] assert "-i" in args assert "video.mp4" in args assert "-vn" in args class TestExtractKeyframes: @patch("yuxi.knowledge.parser.ffmpeg._detect_scene_change_pts", return_value=[]) def test_no_frames(self, mock_detect, tmp_path): frames = extract_keyframes("video.mp4", str(tmp_path)) assert frames == [] @patch("yuxi.knowledge.parser.ffmpeg.run_ffmpeg") @patch("yuxi.knowledge.parser.ffmpeg._detect_scene_change_pts", return_value=[5.0, 12.5]) def test_with_frames(self, mock_detect, mock_ffmpeg, tmp_path): frames = extract_keyframes("video.mp4", str(tmp_path)) assert len(frames) == 2 assert frames[0][1] == 5.0 assert frames[1][1] == 12.5 class TestConstants: def test_constants_values(self): assert FFMPEG_MAX_BUFFER_BYTES == 10 * 1024 * 1024 assert FFPROBE_TIMEOUT_MS == 10_000 assert FFMPEG_TIMEOUT_MS == 45_000 assert FFMPEG_MAX_AUDIO_DURATION_SECS == 1200 assert MAX_AUDIO_BYTES == 16 * 1024 * 1024 assert MAX_VIDEO_BYTES == 16 * 1024 * 1024 assert FFMPEG_MAX_CONCURRENT == 4