Files
xiaoxia-saas/tests/unit/test_video_validation.py
xiaoxia 0ef4e1d633
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Unit Tests (push) Successful in 9m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m39s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
feat: 渲染失败检测+任务超时机制 (#1219)
- 新增 video_validation 模块(moov atom 检测 + ffprobe 验证 + 退出码映射)
- render_adapter 渲染后自动校验输出再上传 OSS
- 三个渲染任务加 10 分钟 soft_time_limit
- Worker 启动时清理 GenerationTask + Job 两张表的孤儿任务
- 21 个新单元测试,全量 13713 测试通过
2026-08-02 15:34:22 +08:00

334 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""测试 video_validation 模块 — 渲染输出校验."""
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from video_processing.video_validation import (
FFMPEG_EXIT_CODES,
VideoValidationResult,
_check_moov_atom,
get_exit_code_message,
validate_video_output,
)
# ── get_exit_code_message 测试 ────────────────────────────────────────────────
class TestGetExitCodeMessage:
"""FFmpeg 退出码映射测试."""
def test_known_exit_codes(self):
"""已知退出码返回有意义的描述."""
assert "成功" in get_exit_code_message(0)
assert "通用错误" in get_exit_code_message(1)
assert "OOM" in get_exit_code_message(137)
assert "段错误" in get_exit_code_message(139)
assert "滤镜错误" in get_exit_code_message(183)
assert "素材异常" in get_exit_code_message(234)
def test_signal_termination(self):
"""信号终止(exit > 128 且不在映射表中)返回信号编号."""
msg = get_exit_code_message(130) # SIGINT = 130 - 128 = 2
assert "信号 2" in msg
def test_unknown_exit_code(self):
"""未知退出码返回通用错误."""
msg = get_exit_code_message(42)
assert "未知错误" in msg
assert "42" in msg
def test_all_mapped_codes_have_description(self):
"""所有映射的退出码都有名称和描述."""
for code, (name, desc) in FFMPEG_EXIT_CODES.items():
assert name, f"exit_code {code} 缺少名称"
assert desc, f"exit_code {code} 缺少描述"
# ── _check_moov_atom 测试 ─────────────────────────────────────────────────────
class TestCheckMoovAtom:
"""moov atom 检测测试."""
def test_file_with_moov_in_head(self):
"""文件头部包含 moov 标记 → Truefaststart 模式)."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
# 写入一些假数据,中间包含 moov 标记
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 1000)
f.flush()
path = Path(f.name)
try:
assert _check_moov_atom(path) is True
finally:
path.unlink()
def test_file_with_moov_in_tail(self):
"""文件尾部包含 moov 标记 → True(普通 MP4 模式)."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
# 写一个较大的文件,moov 在尾部
f.write(b"\x00" * 100_000)
f.write(b"moov")
f.write(b"\x00" * 100)
f.flush()
path = Path(f.name)
try:
assert _check_moov_atom(path) is True
finally:
path.unlink()
def test_file_without_moov(self):
"""文件不含 moov 标记 → False(截断/损坏的 MP4."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
# 写入纯随机数据,不含 moov
f.write(b"\x00\x01\x02\x03" * 1000)
f.flush()
path = Path(f.name)
try:
assert _check_moov_atom(path) is False
finally:
path.unlink()
def test_empty_file(self):
"""空文件 → False."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
path = Path(f.name)
try:
assert _check_moov_atom(path) is False
finally:
path.unlink()
def test_tiny_file(self):
"""极小文件(< 8 bytes)→ False."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00\x01")
f.flush()
path = Path(f.name)
try:
assert _check_moov_atom(path) is False
finally:
path.unlink()
def test_nonexistent_file(self):
"""不存在的文件 → False."""
assert _check_moov_atom(Path("/nonexistent/file.mp4")) is False
# ── validate_video_output 测试 ────────────────────────────────────────────────
class TestValidateVideoOutput:
"""渲染输出完整性校验测试."""
def test_file_not_exists(self):
"""文件不存在 → valid=False."""
result = validate_video_output("/nonexistent/video.mp4")
assert result.valid is False
assert result.file_exists is False
assert "不存在" in result.error_message
def test_empty_file(self):
"""空文件 → valid=False."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "大小" in result.error_message or "过小" in result.error_message
finally:
path.unlink()
def test_tiny_file(self):
"""极小文件(< 1KB)→ valid=False."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 500)
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "过小" in result.error_message
finally:
path.unlink()
def test_file_without_moov(self):
"""文件有大小但无 moov atom → valid=False."""
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00\x01\x02\x03" * 2000) # 8KB, 无 moov
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "moov" in result.error_message.lower()
finally:
path.unlink()
@patch("video_processing.video_validation.subprocess.run")
def test_valid_video(self, mock_run):
"""完整的有效视频 → valid=True."""
# mock ffprobe 返回
mock_result = MagicMock()
mock_result.stdout = json.dumps(
{
"streams": [
{
"width": 1080,
"height": 1920,
"duration": "10.5",
"codec_name": "h264",
}
],
"format": {"duration": "10.5"},
}
)
mock_result.returncode = 0
mock_run.return_value = mock_result
# 创建含 moov 的文件
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 5000)
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is True
assert result.file_exists is True
assert result.moov_atom_found is True
assert result.has_video_stream is True
assert result.width == 1080
assert result.height == 1920
assert result.duration == 10.5
assert result.error_message == ""
finally:
path.unlink()
@patch("video_processing.video_validation.subprocess.run")
def test_no_video_stream(self, mock_run):
"""文件有 moov 但无视频流 → valid=False."""
mock_result = MagicMock()
mock_result.stdout = json.dumps({"streams": [], "format": {"duration": "0"}})
mock_result.returncode = 0
mock_run.return_value = mock_result
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 5000)
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "无视频流" in result.error_message
finally:
path.unlink()
@patch("video_processing.video_validation.subprocess.run")
def test_ffprobe_timeout(self, mock_run):
"""ffprobe 超时 → valid=False."""
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ffprobe", timeout=15)
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 5000)
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "超时" in result.error_message
finally:
path.unlink()
@patch("video_processing.video_validation.subprocess.run")
def test_ffprobe_error(self, mock_run):
"""ffprobe 执行失败 → valid=False."""
mock_run.side_effect = subprocess.CalledProcessError(returncode=1, cmd="ffprobe", stderr="Invalid data found")
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 5000)
path = Path(f.name)
try:
result = validate_video_output(path)
assert result.valid is False
assert "ffprobe" in result.error_message.lower()
finally:
path.unlink()
@patch("video_processing.video_validation.subprocess.run")
def test_short_video_warning(self, mock_run):
"""极短视频(< min_duration)→ 仍 valid=True 但有警告."""
mock_result = MagicMock()
mock_result.stdout = json.dumps(
{
"streams": [{"width": 100, "height": 100, "duration": "0.05", "codec_name": "h264"}],
"format": {"duration": "0.05"},
}
)
mock_result.returncode = 0
mock_run.return_value = mock_result
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
f.write(b"\x00" * 100)
f.write(b"moov")
f.write(b"\x00" * 5000)
path = Path(f.name)
try:
# 默认 min_duration=0.1,视频只有 0.05s → 应该仍然 valid(仅警告)
result = validate_video_output(path)
assert result.valid is True
finally:
path.unlink()
# ── VideoValidationResult 数据结构测试 ────────────────────────────────────────
class TestVideoValidationResult:
"""VideoValidationResult 数据结构测试."""
def test_default_invalid(self):
"""默认构造结果为无效."""
result = VideoValidationResult(valid=False)
assert result.valid is False
assert result.is_valid is False
assert result.file_size == 0
assert result.error_message == ""
def test_valid_result(self):
"""有效结果属性正确."""
result = VideoValidationResult(
valid=True,
file_exists=True,
file_size=1024000,
moov_atom_found=True,
has_video_stream=True,
duration=15.3,
width=1920,
height=1080,
)
assert result.is_valid is True
assert result.duration == 15.3
assert result.width == 1920