Files
xiaoxia-saas/tests/unit/test_p02_p03_fixes.py
T
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

376 lines
15 KiB
Python
Raw 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.
"""P0-2 / P0-3 修复验证测试.
P0-2: OSSStorageService.get_download_url 预签名 URL 逻辑验证
P0-3: FFmpeg xfade exit 234 — build_xfade_filter_chain 安全钳制 + effective_duration trim
"""
from __future__ import annotations
import logging
from unittest.mock import MagicMock, patch
from video_processing.ffmpeg_utils import build_xfade_filter_chain
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
class TestBuildXfadeFilterChainSafetyClamp:
"""验证 xfade 滤镜链的安全钳制逻辑,防止 exit 234。"""
def test_empty_clips(self):
"""空片段列表返回空字符串和 0 时长。"""
f, dur = build_xfade_filter_chain([], [], [])
assert f == ""
assert dur == 0.0
def test_single_clip(self):
"""单片段用 copy,不做 xfade。"""
f, dur = build_xfade_filter_chain([3.0], ["v0"], ["cut"])
assert "copy" in f
assert dur == 3.0
def test_two_clips_normal(self):
"""两个正常时长片段 — offset + td ≤ first_input_duration。"""
f, dur = build_xfade_filter_chain(
clip_durations=[5.0, 5.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=0.5,
)
assert "xfade" in f
assert "duration=0.500" in f
# 总时长 = 5 + 5 - 0.5 = 9.5
assert abs(dur - 9.5) < 0.01
def test_short_clip_safety_clamp(self):
"""片段短于 transition_duration 时,td 被钳制,不会导致 exit 234。
这是 P0-3 的核心场景:视频只有 1std=0.5soffset 计算后
offset + td 可能超过 first_input_duration。
"""
# 两个 1s 片段,td=0.5s
# 原始 offset = max(0, 1.0 - 0.5*1) = 0.5
# first_input_dur = 1.0, available = 1.0 - 0.5 = 0.5
# safe_td = min(0.5, 0.5) = 0.5 — 刚好 OK
f, dur = build_xfade_filter_chain(
clip_durations=[1.0, 1.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=0.5,
)
assert "xfade" in f
# offset + td 必须 ≤ first_input_duration
# offset=0.5, td=0.5 → 0.5+0.5=1.0 ≤ 1.0 ✓
assert dur > 0
def test_very_short_clip_clamped(self):
"""片段极短(0.3s),td 被钳制到 available 以内。"""
# clip1=0.3s, clip2=5.0s, td=0.5s
# offset = max(0, 0.3 - 0.5) = 0.0
# first_input_dur = 0.3
# available = 0.3 - 0.0 = 0.3
# safe_td = min(0.5, 0.3) = 0.3
f, dur = build_xfade_filter_chain(
clip_durations=[0.3, 5.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=0.5,
)
assert "duration=0.300" in f # td 被钳制到 0.3
def test_multi_clip_chain_clamp(self):
"""多片段链式 xfade,每步都钳制。"""
# 3 个 0.5s 片段,td=0.5s
# Step 1: offset=0, first_input_dur=0.5, available=0.5, safe_td=0.5
# total_transition=0.5
# Step 2: cumulative=1.0, first_input_dur=1.0-0.5=0.5
# offset=max(0, 1.0-0.5*2)=0.0, available=0.5, safe_td=0.5
f, dur = build_xfade_filter_chain(
clip_durations=[0.5, 0.5, 0.5],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "fade"],
transition_duration=0.5,
)
assert "xfade" in f
assert f.count("xfade") == 2
assert dur > 0
def test_middle_clip_shorter_than_td(self):
"""P1 修复验证:中间片段短于 td 时,td 被钳制到该片段时长。
[5.0, 0.3, 5.0] + td=0.5s:
- Step 1: second input = 0.3s, safe_td 必须 ≤ 0.3
- Step 2: second input = 5.0s, safe_td 可以 = 0.5
"""
import re
f, dur = build_xfade_filter_chain(
clip_durations=[5.0, 0.3, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "fade"],
transition_duration=0.5,
)
assert f.count("xfade") == 2
# 解析每个 xfade 的 duration
durations_found = []
for part in f.split(";"):
if "xfade=" not in part:
continue
m = re.search(r"duration=([\d.]+)", part)
assert m, f"无法解析: {part}"
durations_found.append(float(m.group(1)))
# 第一个 xfade: td 必须 ≤ 0.3 (第二个输入 clip_durations[1]=0.3)
assert durations_found[0] <= 0.3 + 0.001, f"第一个 xfade td={durations_found[0]} 超过 clip_durations[1]=0.3"
# 第二个 xfade: td 可以 = 0.5 (clip_durations[2]=5.0)
assert durations_found[1] <= 0.5 + 0.001
assert dur > 0
def test_offset_plus_td_never_exceeds_input(self):
"""压力测试:多种时长组合,offset + td 永远不超过 first_input_duration。"""
test_cases = [
([0.1, 5.0], 0.5),
([0.5, 0.5], 0.5),
([1.0, 1.0, 1.0], 0.5),
([0.2, 0.3, 0.4], 0.5),
([10.0, 0.1], 0.5),
([3.0, 3.0, 3.0, 3.0], 0.5),
([5.0, 0.3, 5.0], 0.5), # P1: 中间片段短于 td
([5.0, 0.1, 0.1, 5.0], 0.5), # P1: 多个中间片段都短于 td
]
for durations, td in test_cases:
labels = [f"v{i}" for i in range(len(durations))]
transitions = ["cut"] + ["fade"] * (len(durations) - 1)
f, dur = build_xfade_filter_chain(
clip_durations=durations,
clip_video_labels=labels,
transitions=transitions,
transition_duration=td,
)
assert dur >= 0, f" durations={durations} td={td} → dur={dur}"
# 解析 filter 验证 offset + td 的合理性
import re
# 按 xfade 步骤索引追踪第二个输入
xfade_idx = 0
for part in f.split(";"):
if "xfade=" not in part:
continue
# 格式: [prev][next]xfade=transition=X:duration=D:offset=O[out]
m = re.search(
r"xfade=transition=(\w+):duration=([\d.]+):offset=([\d.]+)",
part,
)
assert m, f"无法解析 xfade 参数: {part}"
offset_val = float(m.group(3))
dur_val = float(m.group(2))
assert offset_val >= 0
assert dur_val >= 0.001 # 至少 1ms
# P1 修复验证: td 不能超过第二个输入片段时长
second_input_idx = xfade_idx + 1
assert (
dur_val <= durations[second_input_idx] + 0.001
), f"td={dur_val} > clip_durations[{second_input_idx}]={durations[second_input_idx]}"
xfade_idx += 1
# ── P0-3: effective_duration trim 逻辑验证 ────────────────────────────────────
class TestEffectiveDurationTrim:
"""验证 _build_filter_complex 中 effective_duration trim 逻辑。"""
def _make_service(self, clips, asset_paths=None):
from pathlib import Path
from video_processing.unified_render_service import UnifiedRenderService
plan = MagicMock()
plan.id = "test_plan"
work_dir = Path("/tmp/test_render")
if asset_paths is None:
asset_paths = {}
for c in clips:
asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}")
return UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_paths,
work_dir=work_dir,
)
def _make_clip(self, clip_id, duration=0.0, actual_duration=5.0, clip_type="main", order=0):
from pathlib import Path
from video_processing.unified_render_service import ResolvedClip
return ResolvedClip(
clip_id=clip_id,
asset_id=f"asset_{clip_id}.mp4",
local_path=Path(f"/tmp/asset_{clip_id}.mp4"),
clip_type=clip_type,
order=order,
duration=duration,
actual_duration=actual_duration,
transition_effect="fade",
config={},
)
def test_trim_applied_when_duration_less_than_actual(self):
"""clip.duration < actual_duration → trim=duration=clip.duration。"""
clip = self._make_clip("c1", duration=3.0, actual_duration=10.0)
svc = self._make_service([clip])
layers = svc._group_clips_into_layers([clip])
fc, _ = svc._build_filter_complex(layers)
assert "trim=duration=3.0" in fc
def test_trim_uses_actual_when_no_duration_set(self):
"""clip.duration=0 → 使用 actual_duration 做 trim。"""
clip = self._make_clip("c1", duration=0.0, actual_duration=7.5)
svc = self._make_service([clip])
layers = svc._group_clips_into_layers([clip])
fc, _ = svc._build_filter_complex(layers)
assert "trim=duration=7.5" in fc
def test_trim_uses_min_of_duration_and_actual(self):
"""clip.duration > actual_duration → trim 到 actual_duration。"""
clip = self._make_clip("c1", duration=10.0, actual_duration=2.0)
svc = self._make_service([clip])
layers = svc._group_clips_into_layers([clip])
fc, _ = svc._build_filter_complex(layers)
assert "trim=duration=2.0" in fc
def test_no_trim_when_both_zero(self):
"""duration=0 且 actual_duration=0 → 不做 trim。"""
clip = self._make_clip("c1", duration=0.0, actual_duration=0.0)
svc = self._make_service([clip])
layers = svc._group_clips_into_layers([clip])
fc, _ = svc._build_filter_complex(layers)
assert "trim=" not in fc
def test_xfade_uses_effective_durations(self):
"""多片段 xfade 使用 trim 后的有效时长。"""
clip1 = self._make_clip("c1", duration=3.0, actual_duration=10.0, order=0)
clip2 = self._make_clip("c2", duration=4.0, actual_duration=10.0, order=1)
svc = self._make_service([clip1, clip2])
layers = svc._group_clips_into_layers([clip1, clip2])
fc, _ = svc._build_filter_complex(layers)
assert "xfade=" in fc
# 两个 clip 的 trim 应该分别用 3.0 和 4.0
assert "trim=duration=3.0" in fc
assert "trim=duration=4.0" in fc
# ── P0-2: get_download_url 预签名 URL 逻辑验证 ────────────────────────────────
class TestGetDownloadUrl:
"""验证 OSSStorageService.get_download_url 逻辑。"""
def test_returns_signed_url_when_bucket_configured(self):
"""bucket 已配置 → 返回签名 URL。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = MagicMock()
svc.bucket.sign_url.return_value = "https://signed-url.oss.com/file.mp4?signature=xxx"
svc.public_url = "https://bucket.oss.com"
result = svc.get_download_url("uploads/video.mp4")
svc.bucket.sign_url.assert_called_once_with("GET", "uploads/video.mp4", 3600)
assert "signed-url" in result
def test_returns_raw_url_when_bucket_none(self):
"""bucket 未配置 → 返回原始公网 URL,并记录 warning。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = None
svc.public_url = "https://bucket.oss.com"
svc.local_url_prefix = "/generated-files"
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
result = svc.get_download_url("uploads/video.mp4")
assert result == "https://bucket.oss.com/uploads/video.mp4"
def test_local_generated_url_returned_as_is(self):
"""本地生成文件 URL → 原样返回,不走 OSS。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = None
svc.local_url_prefix = "/generated-files"
result = svc.get_download_url("/generated-files/abc123.mp4")
assert result == "/generated-files/abc123.mp4"
def test_normalize_strips_full_url(self):
"""完整 URL → 提取路径部分作为 storage_key。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
key = svc._normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/video.mp4")
assert key == "uploads/video.mp4"
def test_normalize_preserves_plain_key(self):
"""纯路径 → 保持不变。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
key = svc._normalize_storage_key("uploads/video.mp4")
assert key == "uploads/video.mp4"
def test_sign_url_failure_falls_back(self):
"""sign_url 异常 → 回退到原始 URL,不崩溃。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = MagicMock()
svc.bucket.sign_url.side_effect = Exception("OSS error")
svc.public_url = "https://bucket.oss.com"
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
result = svc.get_download_url("uploads/video.mp4")
assert result == "https://bucket.oss.com/uploads/video.mp4"
def test_diagnostic_logging_on_bucket_none(self, caplog):
"""bucket 未配置时记录 warning 日志。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = None
svc.public_url = "https://bucket.oss.com"
svc.local_url_prefix = "/generated-files"
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
with caplog.at_level(logging.WARNING):
svc.get_download_url("https://bucket.oss.com/uploads/video.mp4")
assert any("OSS bucket not configured" in r.message for r in caplog.records)
def test_diagnostic_logging_on_sign_success(self, caplog):
"""签名成功时记录 info 日志。"""
from app.core.storage import OSSStorageService
with patch.object(OSSStorageService, "__init__", lambda self: None):
svc = OSSStorageService()
svc.bucket = MagicMock()
svc.bucket.sign_url.return_value = "https://signed.oss.com/file.mp4?sig=xxx"
svc.public_url = "https://bucket.oss.com"
with caplog.at_level(logging.INFO):
svc.get_download_url("uploads/video.mp4")
assert any("signed URL generated" in r.message for r in caplog.records)