4b3feb2d88
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 12s
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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Integration 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 / 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 / PR Build Web Image (pull_request) Successful in 37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
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 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (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 / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18s
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
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m34s
CI/CD Pipeline / Validate - Style (push) Successful in 2m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Validate - Security (push) Successful in 6m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m55s
CI/CD Pipeline / Unit Tests (push) Successful in 6m53s
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 / Canary Release to 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>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""#1754 渲染期画面扰动——让批量视频本身更不同。
|
||
|
||
测试覆盖:
|
||
1. generate_visual_perturbation 生成合法参数
|
||
2. _build_filter_complex 中视觉扰动滤镜正确应用
|
||
3. _render_pass_through 中视觉扰动滤镜正确应用
|
||
4. ensure_variant_plans 为每个变体生成并存储扰动参数
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# ── 1. generate_visual_perturbation 单元测试 ──────────────────────────────────
|
||
|
||
|
||
class TestGenerateVisualPerturbation:
|
||
"""测试 generate_visual_perturbation 函数。"""
|
||
|
||
def test_returns_dict_with_expected_keys(self):
|
||
"""返回 dict 包含 hflip/zoom_ratio/speed_factor/brightness_shift。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
result = generate_visual_perturbation(random.Random(42))
|
||
assert set(result.keys()) == {"hflip", "zoom_ratio", "speed_factor", "brightness_shift"}
|
||
|
||
def test_hflip_is_bool(self):
|
||
"""hflip 是 bool 类型。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
result = generate_visual_perturbation(random.Random(42))
|
||
assert isinstance(result["hflip"], bool)
|
||
|
||
def test_zoom_ratio_range(self):
|
||
"""zoom_ratio 在 1.0~1.08 范围内。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
for seed in range(100):
|
||
result = generate_visual_perturbation(random.Random(seed))
|
||
assert 1.0 <= result["zoom_ratio"] <= 1.08, f"seed={seed}: {result['zoom_ratio']}"
|
||
|
||
def test_speed_factor_range(self):
|
||
"""speed_factor 在 0.95~1.05 范围内。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
for seed in range(100):
|
||
result = generate_visual_perturbation(random.Random(seed))
|
||
assert 0.95 <= result["speed_factor"] <= 1.05, f"seed={seed}: {result['speed_factor']}"
|
||
|
||
def test_brightness_shift_values(self):
|
||
"""brightness_shift 只能取特定值。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
allowed = {-10, -5, 0, 5, 10}
|
||
for seed in range(100):
|
||
result = generate_visual_perturbation(random.Random(seed))
|
||
assert result["brightness_shift"] in allowed, f"seed={seed}: {result['brightness_shift']}"
|
||
|
||
def test_different_seeds_produce_different_results(self):
|
||
"""不同种子产生不同扰动参数。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
results = [generate_visual_perturbation(random.Random(seed)) for seed in range(20)]
|
||
# 至少有一些不同的 zoom_ratio
|
||
zooms = {r["zoom_ratio"] for r in results}
|
||
assert len(zooms) > 1, "所有种子产生了相同的 zoom_ratio"
|
||
|
||
def test_hflip_probability_approximately_30_percent(self):
|
||
"""hflip 概率约 30%(大样本验证)。"""
|
||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||
|
||
n = 1000
|
||
flips = sum(1 for seed in range(n) if generate_visual_perturbation(random.Random(seed))["hflip"])
|
||
ratio = flips / n
|
||
assert 0.20 <= ratio <= 0.40, f"hflip 比例 {ratio:.2%} 偏离 30%"
|
||
|
||
|
||
# ── 2. 渲染侧滤镜应用测试 ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestVisualPerturbationFilters:
|
||
"""测试 _get_visual_perturbation 和滤镜应用方法。"""
|
||
|
||
def _make_render_service(self, perturbation: dict | None = None):
|
||
"""构造一个带 mock plan 的 UnifiedRenderService 实例。"""
|
||
# 避免实际初始化
|
||
from video_processing.unified_render_service import UnifiedRenderService
|
||
|
||
svc = object.__new__(UnifiedRenderService)
|
||
svc.output_width = 1920
|
||
svc.output_height = 1080
|
||
svc.plan = MagicMock()
|
||
svc.plan.config = {"visual_perturbation": perturbation} if perturbation else {}
|
||
return svc
|
||
|
||
def test_get_visual_perturbation_empty_when_no_config(self):
|
||
"""plan.config 无 visual_perturbation 时返回空 dict。"""
|
||
svc = self._make_render_service(None)
|
||
assert svc._get_visual_perturbation() == {}
|
||
|
||
def test_get_visual_perturbation_parses_values(self):
|
||
"""正确解析 perturbation dict 中的各字段。"""
|
||
perturbation = {
|
||
"hflip": True,
|
||
"zoom_ratio": 1.05,
|
||
"speed_factor": 0.97,
|
||
"brightness_shift": 5,
|
||
}
|
||
svc = self._make_render_service(perturbation)
|
||
result = svc._get_visual_perturbation()
|
||
assert result["hflip"] is True
|
||
assert result["zoom_ratio"] == 1.05
|
||
assert result["speed_factor"] == 0.97
|
||
assert result["brightness_shift"] == 5
|
||
|
||
def test_get_visual_perturbation_clamps_zoom_ratio(self):
|
||
"""zoom_ratio 被限制在 1.0~1.2 范围内。"""
|
||
svc = self._make_render_service({"zoom_ratio": 2.0})
|
||
assert svc._get_visual_perturbation()["zoom_ratio"] == 1.2
|
||
|
||
svc2 = self._make_render_service({"zoom_ratio": 0.5})
|
||
assert svc2._get_visual_perturbation()["zoom_ratio"] == 1.0
|
||
|
||
def test_get_visual_perturbation_clamps_brightness(self):
|
||
"""brightness_shift 被限制在 -30~30 范围内。"""
|
||
svc = self._make_render_service({"brightness_shift": 50})
|
||
assert svc._get_visual_perturbation()["brightness_shift"] == 30
|
||
|
||
svc2 = self._make_render_service({"brightness_shift": -50})
|
||
assert svc2._get_visual_perturbation()["brightness_shift"] == -30
|
||
|
||
def test_pre_scale_hflip(self):
|
||
"""hflip=True 时 filters 列表追加 hflip。"""
|
||
svc = self._make_render_service({"hflip": True})
|
||
filters: list[str] = []
|
||
svc._apply_visual_perturbation_pre_scale(filters, svc._get_visual_perturbation())
|
||
assert "hflip" in filters
|
||
|
||
def test_pre_scale_no_hflip(self):
|
||
"""hflip=False 时 filters 列表不变。"""
|
||
svc = self._make_render_service({"hflip": False})
|
||
filters: list[str] = ["existing_filter"]
|
||
svc._apply_visual_perturbation_pre_scale(filters, svc._get_visual_perturbation())
|
||
assert filters == ["existing_filter"]
|
||
|
||
def test_post_scale_zoom_and_brightness(self):
|
||
"""zoom_ratio>1 时追加 scale+crop,brightness!=0 时追加 eq。"""
|
||
svc = self._make_render_service({"zoom_ratio": 1.05, "brightness_shift": 10})
|
||
perturbation = svc._get_visual_perturbation()
|
||
filters: list[str] = []
|
||
svc._apply_visual_perturbation_post_scale(filters, perturbation)
|
||
# zoom: scale=2016:1134 (1920*1.05=2016, 1080*1.05=1134)
|
||
assert "scale=2016:1134" in filters
|
||
assert "crop=1920:1080" in filters
|
||
# brightness: 10/100 = 0.1
|
||
assert "eq=brightness=0.100" in filters
|
||
|
||
def test_post_scale_no_perturbation_when_defaults(self):
|
||
"""zoom_ratio=1.0 且 brightness_shift=0 时不追加滤镜。"""
|
||
svc = self._make_render_service({"zoom_ratio": 1.0, "brightness_shift": 0})
|
||
perturbation = svc._get_visual_perturbation()
|
||
filters: list[str] = []
|
||
svc._apply_visual_perturbation_post_scale(filters, perturbation)
|
||
assert filters == []
|
||
|
||
|
||
# ── 3. ensure_variant_plans 扰动注入测试 ──────────────────────────────────────
|
||
|
||
|
||
class TestEnsureVariantPlansPerturbation:
|
||
"""测试 ensure_variant_plans 为每个变体注入视觉扰动。"""
|
||
|
||
def test_variant_plans_get_perturbation_in_config(self):
|
||
"""每个变体 plan 的 config 中包含 visual_perturbation。"""
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
db = MagicMock()
|
||
svc = EditPlanService(db)
|
||
|
||
# Mock 所有依赖方法
|
||
mock_plan0 = MagicMock()
|
||
mock_plan0.id = "plan-0"
|
||
mock_variant = MagicMock()
|
||
mock_variant.id = "plan-1"
|
||
|
||
svc.clone_plan_for_variant = MagicMock(return_value=mock_plan0)
|
||
svc.reselect_plan_for_variant = MagicMock(return_value=mock_variant)
|
||
svc.apply_voice_duration_to_plan = MagicMock()
|
||
svc.mark_clips_ready = MagicMock()
|
||
svc.update_plan_config = MagicMock()
|
||
|
||
rng = random.Random(42)
|
||
plan_ids = svc.ensure_variant_plans(
|
||
"source-plan-id",
|
||
count=3,
|
||
candidate_asset_ids=["asset-1", "asset-2", "asset-3"],
|
||
rng=rng,
|
||
)
|
||
|
||
assert len(plan_ids) == 3
|
||
|
||
# update_plan_config 应该被调用 3 次(每个变体一次)
|
||
assert svc.update_plan_config.call_count == 3
|
||
|
||
# 检查每次调用都传入了 visual_perturbation
|
||
for call in svc.update_plan_config.call_args_list:
|
||
args, kwargs = call
|
||
plan_id = args[0]
|
||
config_updates = args[1]
|
||
assert "visual_perturbation" in config_updates
|
||
perturbation = config_updates["visual_perturbation"]
|
||
assert "hflip" in perturbation
|
||
assert "zoom_ratio" in perturbation
|
||
assert "speed_factor" in perturbation
|
||
assert "brightness_shift" in perturbation
|
||
|
||
def test_variant_0_no_hflip(self):
|
||
"""变体 0(预览 plan)不做 hflip。"""
|
||
from app.services.edit_plan_service import EditPlanService
|
||
|
||
db = MagicMock()
|
||
svc = EditPlanService(db)
|
||
|
||
mock_plan0 = MagicMock()
|
||
mock_plan0.id = "plan-0"
|
||
mock_variant = MagicMock()
|
||
mock_variant.id = "plan-1"
|
||
|
||
svc.clone_plan_for_variant = MagicMock(return_value=mock_plan0)
|
||
svc.reselect_plan_for_variant = MagicMock(return_value=mock_variant)
|
||
svc.apply_voice_duration_to_plan = MagicMock()
|
||
svc.mark_clips_ready = MagicMock()
|
||
svc.update_plan_config = MagicMock()
|
||
|
||
rng = random.Random(42)
|
||
svc.ensure_variant_plans(
|
||
"source-plan-id",
|
||
count=2,
|
||
candidate_asset_ids=["asset-1", "asset-2"],
|
||
rng=rng,
|
||
)
|
||
|
||
# 第一次调用(变体 0)的 hflip 应该是 False
|
||
first_call_args = svc.update_plan_config.call_args_list[0]
|
||
perturbation_0 = first_call_args[0][1]["visual_perturbation"]
|
||
assert perturbation_0["hflip"] is False
|