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
- 新增 video_validation 模块(moov atom 检测 + ffprobe 验证 + 退出码映射) - render_adapter 渲染后自动校验输出再上传 OSS - 三个渲染任务加 10 分钟 soft_time_limit - Worker 启动时清理 GenerationTask + Job 两张表的孤儿任务 - 21 个新单元测试,全量 13713 测试通过
334 lines
12 KiB
Python
Executable File
334 lines
12 KiB
Python
Executable File
"""测试 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 标记 → True(faststart 模式)."""
|
||
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
|