f9f3e6bfb9
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m12s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 58s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m31s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m17s
CI/CD Pipeline / Integration Tests (push) Successful in 3m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 6m33s
AI Code Review / AI Code Review (pull_request) Successful in 6m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m14s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Validate - Security (push) Successful in 9m51s
CI/CD Pipeline / Unit Tests (push) Successful in 10m42s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
412 lines
15 KiB
Python
Executable File
412 lines
15 KiB
Python
Executable File
"""缩略图生成器单元测试 - 纯逻辑函数."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from video_processing.thumbnail_generator import _format_seek_time
|
||
|
||
|
||
class TestFormatSeekTime:
|
||
"""_format_seek_time 时间格式化测试."""
|
||
|
||
def test_zero_seconds(self):
|
||
"""0秒."""
|
||
result = _format_seek_time(0)
|
||
assert result == "00:00:00.00"
|
||
|
||
def test_less_than_one_second(self):
|
||
"""小于1秒."""
|
||
result = _format_seek_time(0.5)
|
||
assert result == "00:00:00.50"
|
||
|
||
def test_few_seconds(self):
|
||
"""几秒."""
|
||
result = _format_seek_time(5.5)
|
||
assert result == "00:00:05.50"
|
||
|
||
def test_one_minute(self):
|
||
"""1分钟."""
|
||
result = _format_seek_time(60.0)
|
||
assert result == "00:01:00.00"
|
||
|
||
def test_minutes_and_seconds(self):
|
||
"""分+秒."""
|
||
result = _format_seek_time(125.5)
|
||
assert result == "00:02:05.50"
|
||
|
||
def test_one_hour(self):
|
||
"""1小时."""
|
||
result = _format_seek_time(3600.0)
|
||
assert result == "01:00:00.00"
|
||
|
||
def test_hours_minutes_seconds(self):
|
||
"""时+分+秒."""
|
||
result = _format_seek_time(3725.25)
|
||
assert result == "01:02:05.25"
|
||
|
||
def test_long_duration(self):
|
||
"""长视频(2小时以上)."""
|
||
result = _format_seek_time(7384.12)
|
||
assert result == "02:03:04.12"
|
||
|
||
def test_precision_two_decimal(self):
|
||
"""两位小数精度."""
|
||
result = _format_seek_time(3.14159)
|
||
assert result == "00:00:03.14"
|
||
|
||
def test_always_two_digit_hours(self):
|
||
"""小时始终两位数字."""
|
||
result = _format_seek_time(3600 * 9)
|
||
assert result.startswith("09:")
|
||
|
||
def test_always_two_digit_minutes(self):
|
||
"""分钟始终两位数字."""
|
||
result = _format_seek_time(300) # 5分钟
|
||
parts = result.split(":")
|
||
assert parts[1] == "05"
|
||
|
||
def test_float_input(self):
|
||
"""浮点数输入."""
|
||
result = _format_seek_time(10.0)
|
||
assert isinstance(result, str)
|
||
assert result == "00:00:10.00"
|
||
|
||
def test_int_input(self):
|
||
"""整数输入."""
|
||
result = _format_seek_time(30)
|
||
assert result == "00:00:30.00"
|
||
|
||
def test_format_structure(self):
|
||
"""格式结构正确:HH:MM:SS.xx."""
|
||
result = _format_seek_time(3661.5)
|
||
# 格式: HH:MM:SS.xx
|
||
parts = result.split(":")
|
||
assert len(parts) == 3
|
||
assert "." in parts[2]
|
||
sec_parts = parts[2].split(".")
|
||
assert len(sec_parts) == 2
|
||
assert len(sec_parts[1]) == 2 # 两位小数
|
||
|
||
|
||
# ── MediaKit 智能抽帧集成测试 ──────────────────────────────────────────
|
||
|
||
|
||
class TestExtractFramesViaMediakit:
|
||
"""_extract_frames_via_mediakit 函数测试."""
|
||
|
||
def test_mediakit_not_configured_returns_none(self, tmp_path, monkeypatch):
|
||
"""MediaKit 未配置时返回 None."""
|
||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||
|
||
# Mock get_mediakit_client 返回不可用客户端
|
||
class FakeClient:
|
||
is_available = False
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.mediakit_client.get_mediakit_client",
|
||
lambda: FakeClient(),
|
||
)
|
||
|
||
video_file = tmp_path / "test.mp4"
|
||
video_file.write_bytes(b"fake video")
|
||
|
||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
|
||
assert result is None
|
||
|
||
def test_mediakit_success_returns_frames(self, tmp_path, monkeypatch):
|
||
"""MediaKit 成功时返回帧列表."""
|
||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||
|
||
class FakeClient:
|
||
is_available = True
|
||
|
||
def extract_frames(self, video_url, strategy, max_frames):
|
||
return [
|
||
{"image_url": "https://example.com/frame1.jpg", "timestamp": 1.5},
|
||
{"image_url": "https://example.com/frame2.jpg", "timestamp": 3.2},
|
||
]
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.mediakit_client.get_mediakit_client",
|
||
lambda: FakeClient(),
|
||
)
|
||
# Mock upload_to_oss
|
||
monkeypatch.setattr(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
lambda path, key: f"https://oss.example.com/{key}",
|
||
)
|
||
# Mock httpx.get for downloading frame
|
||
import httpx
|
||
|
||
class FakeResponse:
|
||
status_code = 200
|
||
content = b"fake image data"
|
||
|
||
def raise_for_status(self):
|
||
pass
|
||
|
||
monkeypatch.setattr("httpx.get", lambda url, **kw: FakeResponse())
|
||
|
||
video_file = tmp_path / "test.mp4"
|
||
video_file.write_bytes(b"fake video")
|
||
|
||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 2)
|
||
assert result is not None
|
||
assert len(result) == 2
|
||
assert result[0]["timestamp"] == 1.5
|
||
assert result[1]["timestamp"] == 3.2
|
||
|
||
def test_mediakit_failure_returns_none(self, tmp_path, monkeypatch):
|
||
"""MediaKit 调用失败时返回 None."""
|
||
from video_processing.thumbnail_generator import _extract_frames_via_mediakit
|
||
|
||
class FakeClient:
|
||
is_available = True
|
||
|
||
def extract_frames(self, video_url, strategy, max_frames):
|
||
return None
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.mediakit_client.get_mediakit_client",
|
||
lambda: FakeClient(),
|
||
)
|
||
monkeypatch.setattr(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
lambda path, key: f"https://oss.example.com/{key}",
|
||
)
|
||
|
||
video_file = tmp_path / "test.mp4"
|
||
video_file.write_bytes(b"fake video")
|
||
|
||
result = _extract_frames_via_mediakit(str(video_file), "plan1", 3)
|
||
assert result is None
|
||
|
||
|
||
class TestExtractAndUploadCoverFramesFallback:
|
||
"""extract_and_upload_cover_frames 降级逻辑测试."""
|
||
|
||
def test_fallback_to_ffmpeg_when_mediakit_fails(self, tmp_path, monkeypatch):
|
||
"""MediaKit 失败时降级到 ffmpeg 抽帧."""
|
||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||
|
||
# Mock MediaKit 返回 None(未配置或失败)
|
||
class FakeClient:
|
||
is_available = False
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.mediakit_client.get_mediakit_client",
|
||
lambda: FakeClient(),
|
||
)
|
||
|
||
# Mock 单次 ffmpeg 抽帧直接返回 dummy 帧,避免真调用 ffmpeg
|
||
def _fake_single_pass(video_path, seek_points, out_dir, prefix="frame", **kw):
|
||
results = []
|
||
for i, st in enumerate(seek_points):
|
||
fp = Path(out_dir) / f"{prefix}_{i + 1:02d}.jpg"
|
||
fp.write_bytes(b"\xff\xd8\xff\xe0") # 最小 jpeg 头
|
||
results.append((st, str(fp)))
|
||
return results
|
||
|
||
monkeypatch.setattr(
|
||
"video_processing.thumbnail_generator._extract_frames_single_pass",
|
||
_fake_single_pass,
|
||
)
|
||
# blackdetect 直接返回空
|
||
monkeypatch.setattr(
|
||
"video_processing.thumbnail_generator._detect_black_intervals",
|
||
lambda *a, **kw: [],
|
||
)
|
||
# Mock upload
|
||
monkeypatch.setattr(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
lambda path, key: f"https://oss.example.com/{key}",
|
||
)
|
||
# Mock probe_duration
|
||
monkeypatch.setattr(
|
||
"video_processing.ffmpeg_utils.probe_duration",
|
||
lambda path: 60.0,
|
||
)
|
||
|
||
video_file = tmp_path / "test.mp4"
|
||
video_file.write_bytes(b"fake video")
|
||
|
||
result = extract_and_upload_cover_frames(str(video_file), "plan1", num_frames=2)
|
||
assert len(result) == 2
|
||
assert all("url" in item for item in result)
|
||
assert all("position" in item for item in result)
|
||
|
||
|
||
class TestSeekPointBlackAvoidance:
|
||
"""_adjust_seek_points_avoid_black 纯逻辑测试."""
|
||
|
||
def test_no_black_intervals_returns_unchanged(self):
|
||
from video_processing.thumbnail_generator import _adjust_seek_points_avoid_black
|
||
|
||
pts = [2.0, 5.0, 8.0]
|
||
out = _adjust_seek_points_avoid_black(pts, [], duration=10.0)
|
||
assert out == [2.0, 5.0, 8.0]
|
||
|
||
def test_point_in_black_shifts_forward(self):
|
||
from video_processing.thumbnail_generator import _adjust_seek_points_avoid_black
|
||
|
||
# 黑屏 [4, 6],点在 5.0,向前偏移到 4-0.25=3.75
|
||
pts = [5.0]
|
||
out = _adjust_seek_points_avoid_black(pts, [(4.0, 6.0)], duration=10.0)
|
||
assert out[0] == pytest.approx(3.75, abs=0.01)
|
||
|
||
def test_point_at_start_shifts_backward(self):
|
||
from video_processing.thumbnail_generator import _adjust_seek_points_avoid_black
|
||
|
||
# 黑屏 [0, 3],点在 1.0,向前偏移 -0.25 会 <0 → 向后偏移到 3+0.25=3.25
|
||
pts = [1.0]
|
||
out = _adjust_seek_points_avoid_black(pts, [(0.0, 3.0)], duration=10.0)
|
||
assert out[0] == pytest.approx(3.25, abs=0.01)
|
||
|
||
def test_all_black_keeps_point(self):
|
||
from video_processing.thumbnail_generator import _adjust_seek_points_avoid_black
|
||
|
||
# 全黑,偏移都无效,保留原点
|
||
pts = [5.0]
|
||
out = _adjust_seek_points_avoid_black(pts, [(0.0, 10.0)], duration=10.0)
|
||
assert out[0] == pytest.approx(5.0, abs=0.01)
|
||
|
||
def test_multiple_points_decouple(self):
|
||
from video_processing.thumbnail_generator import _adjust_seek_points_avoid_black
|
||
|
||
pts = [2.0, 5.0, 8.0]
|
||
black = [(4.5, 5.5)] # 只有中点在黑屏
|
||
out = _adjust_seek_points_avoid_black(pts, black, duration=10.0)
|
||
assert out[0] == 2.0
|
||
assert out[2] == 8.0
|
||
# 中点必须不在黑屏内
|
||
assert not (4.5 <= out[1] <= 5.5)
|
||
|
||
|
||
class TestScorerRunsInsideTempDir:
|
||
"""P1 修复:scorer 必须在 TemporaryDirectory 块内调用(帧文件还在时)。"""
|
||
|
||
def _setup_mocks(self, monkeypatch, tmp_path, *, scorer_should_read=True):
|
||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||
|
||
class FakeClient:
|
||
is_available = False
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.mediakit_client.get_mediakit_client",
|
||
lambda: FakeClient(),
|
||
)
|
||
|
||
self._frames_on_disk_when_called = []
|
||
|
||
def _fake_single_pass(video_path, seek_points, out_dir, prefix="frame", **kw):
|
||
results = []
|
||
for i, st in enumerate(seek_points):
|
||
fp = Path(out_dir) / f"{prefix}_{i + 1:02d}.jpg"
|
||
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"X" * 200)
|
||
results.append((st, str(fp)))
|
||
return results
|
||
|
||
monkeypatch.setattr(
|
||
"video_processing.thumbnail_generator._extract_frames_single_pass",
|
||
_fake_single_pass,
|
||
)
|
||
monkeypatch.setattr(
|
||
"video_processing.thumbnail_generator._detect_black_intervals",
|
||
lambda *a, **kw: [],
|
||
)
|
||
monkeypatch.setattr(
|
||
"video_processing.ffmpeg_utils.probe_duration",
|
||
lambda path: 60.0,
|
||
)
|
||
monkeypatch.setattr(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
lambda path, key: f"https://oss.example.com/{key}",
|
||
)
|
||
# 标题叠加 no-op
|
||
monkeypatch.setattr(
|
||
"video_processing.thumbnail_generator.apply_title_overlay",
|
||
lambda *a, **kw: None,
|
||
)
|
||
|
||
# 记录 scorer 被调用时各 image_path 是否存在
|
||
def _fake_scorer(candidates):
|
||
for c in candidates:
|
||
self._frames_on_disk_when_called.append(Path(c["image_path"]).exists())
|
||
# 给个假评分:倒序排,验证顺序被应用
|
||
scored = list(candidates)
|
||
for i, c in enumerate(scored):
|
||
c["score"] = float(len(scored) - i)
|
||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||
return scored
|
||
|
||
monkeypatch.setattr(
|
||
"packages.shared.cover_frame_scorer.score_frames",
|
||
_fake_scorer,
|
||
)
|
||
return extract_and_upload_cover_frames
|
||
|
||
def test_scorer_reads_files_while_they_exist(self, tmp_path, monkeypatch):
|
||
"""核心 P1:评分时帧文件必须还在磁盘上(在 TemporaryDirectory 内调用)。"""
|
||
extract = self._setup_mocks(monkeypatch, tmp_path)
|
||
video_file = tmp_path / "t.mp4"
|
||
video_file.write_bytes(b"fake")
|
||
result = extract(str(video_file), "plan1", num_frames=3)
|
||
# scorer 看到的 3 个文件都必须存在
|
||
assert len(self._frames_on_disk_when_called) == 3
|
||
assert all(self._frames_on_disk_when_called), f"scorer 调用时有文件已被删除: {self._frames_on_disk_when_called}"
|
||
# 结果按评分降序排列(is_best 在第一个)
|
||
assert len(result) == 3
|
||
assert result[0].get("is_best") is True
|
||
# 结果中不应该再暴露 image_path
|
||
assert all("image_path" not in c for c in result)
|
||
|
||
def test_scorer_failure_falls_back_gracefully(self, tmp_path, monkeypatch):
|
||
"""评分抛异常时不应中断上传,仍返回所有候选帧。"""
|
||
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
|
||
|
||
class FakeClient:
|
||
is_available = False
|
||
|
||
monkeypatch.setattr("packages.shared.mediakit_client.get_mediakit_client", lambda: FakeClient())
|
||
|
||
def _fake_single_pass(video_path, seek_points, out_dir, prefix="frame", **kw):
|
||
results = []
|
||
for i, st in enumerate(seek_points):
|
||
fp = Path(out_dir) / f"{prefix}_{i + 1:02d}.jpg"
|
||
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"X" * 100)
|
||
results.append((st, str(fp)))
|
||
return results
|
||
|
||
monkeypatch.setattr("video_processing.thumbnail_generator._extract_frames_single_pass", _fake_single_pass)
|
||
monkeypatch.setattr("video_processing.thumbnail_generator._detect_black_intervals", lambda *a, **kw: [])
|
||
monkeypatch.setattr("video_processing.ffmpeg_utils.probe_duration", lambda p: 60.0)
|
||
monkeypatch.setattr(
|
||
"video_processing.oss_helpers.upload_to_oss",
|
||
lambda path, key: f"https://oss/{key}",
|
||
)
|
||
monkeypatch.setattr("video_processing.thumbnail_generator.apply_title_overlay", lambda *a, **kw: None)
|
||
|
||
def _boom(candidates):
|
||
raise RuntimeError("cv2 crashed")
|
||
|
||
monkeypatch.setattr("packages.shared.cover_frame_scorer.score_frames", _boom)
|
||
|
||
video_file = tmp_path / "t.mp4"
|
||
video_file.write_bytes(b"fake")
|
||
# 不应抛出
|
||
result = extract_and_upload_cover_frames(str(video_file), "plan1", num_frames=3)
|
||
assert len(result) == 3
|
||
assert all("url" in c for c in result)
|
||
|
||
def test_best_frame_is_first_after_scoring(self, tmp_path, monkeypatch):
|
||
"""评分后 best 帧(score 最高)在 candidates[0],is_best=True。"""
|
||
extract = self._setup_mocks(monkeypatch, tmp_path)
|
||
video_file = tmp_path / "t.mp4"
|
||
video_file.write_bytes(b"fake")
|
||
result = extract(str(video_file), "plan1", num_frames=5)
|
||
assert result[0]["is_best"] is True
|
||
scores = [c.get("score", 0.0) for c in result]
|
||
assert scores == sorted(scores, reverse=True)
|