ee4636e087
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m48s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m18s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 196h20m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 196h20m26s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 196h20m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 196h20m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 196h26m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 196h26m40s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 196h26m35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 196h26m42s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 196h26m42s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 196h26m43s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 196h26m43s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 196h26m46s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 196h26m47s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 196h26m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 196h55m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 197h1m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 197h1m25s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 197h1m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 197h1m30s
变更: - RHYTHM_TEMPLATES 从 6 种扩展到 8 种(新增 [2,2,1,1,2] 和 [3,2,1,2,1]) - 修复 MIN_CLIP_DURATION 被重复定义为 1.0 的 bug(恢复为 2.0) - plan_clip_durations 新增 asset_durations 参数,钳制最大片段时长 <= 素材可用时长 × 90% - 新增时长总和误差校验(成片净时长与配音时长误差 <= 0.5s),超限时末段补偿修正 - edit_plan_service.apply_voice_duration_to_plan 传入素材时长参与钳制 - 48 个新增单元测试全部通过 向后兼容:asset_durations 默认 None,不影响现有调用方
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""#1768 节奏模板多样化增强 — 单元测试。
|
||
|
||
覆盖:
|
||
- 8 种预设模板完整性
|
||
- MIN_CLIP_DURATION = 2.0(修复旧 1.0 覆盖 bug)
|
||
- 最大片段时长钳制(<= 素材可用时长 × 90%)
|
||
- 时长总和误差校验(<= 0.5s)
|
||
- asset_durations 参数向后兼容(None/空 = 不钳制)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from packages.domain.voice_duration_planner import (
|
||
MIN_CLIP_DURATION,
|
||
RHYTHM_TEMPLATES,
|
||
TOTAL_DURATION_TOLERANCE,
|
||
adapt_template_length,
|
||
get_rhythm_template,
|
||
plan_clip_durations,
|
||
total_output_duration,
|
||
)
|
||
|
||
# ── 模板池 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRhythmTemplatesPool:
|
||
"""#1768 模板池扩展到 8 种。"""
|
||
|
||
def test_template_count_is_8(self):
|
||
assert len(RHYTHM_TEMPLATES) == 8
|
||
|
||
def test_all_templates_have_5_segments(self):
|
||
for tpl in RHYTHM_TEMPLATES:
|
||
assert len(tpl) == 5
|
||
|
||
def test_new_template_22112_exists(self):
|
||
assert [2, 2, 1, 1, 2] in RHYTHM_TEMPLATES
|
||
|
||
def test_new_template_32121_exists(self):
|
||
assert [3, 2, 1, 2, 1] in RHYTHM_TEMPLATES
|
||
|
||
def test_original_6_templates_preserved(self):
|
||
originals = [
|
||
[1, 1, 1, 1, 1],
|
||
[2, 1, 3, 1, 2],
|
||
[1, 2, 1, 2, 1],
|
||
[3, 1, 1, 1, 3],
|
||
[1, 1, 3, 2, 1],
|
||
[2, 1, 1, 3, 1],
|
||
]
|
||
for orig in originals:
|
||
assert orig in RHYTHM_TEMPLATES
|
||
|
||
def test_all_weights_positive(self):
|
||
for tpl in RHYTHM_TEMPLATES:
|
||
assert all(w > 0 for w in tpl)
|
||
|
||
def test_weight_sum_variety(self):
|
||
"""不同模板权重和应不完全相同,确保节奏有差异。"""
|
||
sums = {sum(t) for t in RHYTHM_TEMPLATES}
|
||
assert len(sums) >= 3 # 至少有 3 种不同的权重和
|
||
|
||
|
||
# ── 常量修复 ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConstantsFixed:
|
||
"""#1768 修复 MIN_CLIP_DURATION 从 1.0 回到 2.0。"""
|
||
|
||
def test_min_clip_duration_is_2(self):
|
||
assert MIN_CLIP_DURATION == 2.0
|
||
|
||
def test_total_duration_tolerance_is_05(self):
|
||
assert TOTAL_DURATION_TOLERANCE == 0.5
|
||
|
||
|
||
# ── get_rhythm_template ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGetRhythmTemplate:
|
||
def test_none_seed_returns_average(self):
|
||
assert get_rhythm_template(None) == [1, 1, 1, 1, 1]
|
||
|
||
def test_same_seed_returns_same_template(self):
|
||
for seed in [0, 42, 999, 123456]:
|
||
t1 = get_rhythm_template(seed)
|
||
t2 = get_rhythm_template(seed)
|
||
assert t1 == t2
|
||
|
||
def test_different_seeds_can_yield_different_templates(self):
|
||
"""大量 seed 应能命中多个不同模板。"""
|
||
results = {tuple(get_rhythm_template(s)) for s in range(200)}
|
||
assert len(results) >= 5 # 200 个 seed 至少命中 5 种模板
|
||
|
||
|
||
# ── adapt_template_length ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAdaptTemplateLength:
|
||
def test_exact_match(self):
|
||
tpl = [2, 2, 1, 1, 2]
|
||
assert adapt_template_length(tpl, 5) == tpl
|
||
|
||
def test_truncate(self):
|
||
tpl = [2, 2, 1, 1, 2]
|
||
assert adapt_template_length(tpl, 3) == [2, 2, 1]
|
||
|
||
def test_extend_cycles(self):
|
||
tpl = [2, 2, 1, 1, 2]
|
||
result = adapt_template_length(tpl, 8)
|
||
assert len(result) == 8
|
||
assert result == [2, 2, 1, 1, 2, 2, 2, 1]
|
||
|
||
def test_zero_clips(self):
|
||
assert adapt_template_length([1, 1, 1], 0) == []
|
||
|
||
def test_negative_clips(self):
|
||
assert adapt_template_length([1, 1, 1], -1) == []
|
||
|
||
|
||
# ── plan_clip_durations 基础行为 ────────────────────────────────────────────
|
||
|
||
|
||
class TestPlanClipDurationsBasic:
|
||
def test_invalid_inputs(self):
|
||
assert plan_clip_durations(0, 30.0) == []
|
||
assert plan_clip_durations(-1, 30.0) == []
|
||
assert plan_clip_durations(5, 0.0) == []
|
||
assert plan_clip_durations(5, -10.0) == []
|
||
assert plan_clip_durations(5, "abc") == []
|
||
|
||
def test_average_distribution_no_transitions(self):
|
||
result = plan_clip_durations(5, 30.0)
|
||
assert len(result) == 5
|
||
assert abs(sum(result) - 30.0) < 0.01
|
||
|
||
def test_all_segments_above_min(self):
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=[3, 1, 1, 1, 3])
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|
||
|
||
def test_with_rhythm_template(self):
|
||
tpl = [2, 2, 1, 1, 2]
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
|
||
assert len(result) == 5
|
||
# 权重和 = 8,每段应大致为 7.5, 7.5, 3.75, 3.75, 7.5
|
||
assert result[0] > result[2] # 权重 2 > 权重 1
|
||
assert abs(sum(result) - 30.0) < 0.5
|
||
|
||
def test_total_duration_matches_voice(self):
|
||
"""成片净时长 ≈ 配音时长(无转场时完全等于)。"""
|
||
for voice in [15.0, 30.0, 60.0, 120.0]:
|
||
result = plan_clip_durations(5, voice)
|
||
net = total_output_duration(result)
|
||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
|
||
|
||
def test_with_transitions(self):
|
||
"""有转场时成片净时长也应 ≈ 配音时长。"""
|
||
effects = [None, "xfade", "xfade", "xfade", "xfade"]
|
||
durations = [0.0, 1.0, 1.0, 1.0, 1.0]
|
||
result = plan_clip_durations(5, 30.0, transition_effects=effects, transition_durations=durations)
|
||
net = total_output_duration(result, effects, durations)
|
||
assert abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
|
||
|
||
|
||
# ── #1768 最小片段时长钳制 ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMinClipDurationClamp:
|
||
def test_min_duration_2s_enforced(self):
|
||
"""极端权重下,所有片段仍 >= 2.0s。"""
|
||
tpl = [10, 1, 1, 1, 1]
|
||
result = plan_clip_durations(5, 20.0, rhythm_template=tpl)
|
||
for dur in result:
|
||
assert dur >= 2.0, f"片段时长 {dur} < MIN_CLIP_DURATION(2.0)"
|
||
|
||
def test_short_voice_still_meets_minimum(self):
|
||
"""配音极短时保底每段 MIN_CLIP_DURATION。"""
|
||
result = plan_clip_durations(5, 3.0)
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|
||
|
||
|
||
# ── #1768 最大片段时长钳制 ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMaxClipDurationClamp:
|
||
def test_no_clamp_without_asset_durations(self):
|
||
"""不传 asset_durations 时不做上限钳制(向后兼容)。"""
|
||
tpl = [5, 1, 1, 1, 1]
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl)
|
||
# 第一段权重 5/9 * 30 = 16.67,不应被钳制
|
||
assert result[0] > 10.0
|
||
|
||
def test_no_clamp_with_empty_asset_durations(self):
|
||
"""asset_durations 为空列表时不做上限钳制。"""
|
||
tpl = [5, 1, 1, 1, 1]
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=[])
|
||
assert result[0] > 10.0
|
||
|
||
def test_clamp_respects_90_percent(self):
|
||
"""有素材时长时,片段时长 <= 素材可用时长 × 90%。"""
|
||
tpl = [5, 1, 1, 1, 1]
|
||
# 素材只有第一段短(12s),90% = 10.8s
|
||
asset_durs = [12.0, 60.0, 60.0, 60.0, 60.0]
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=tpl, asset_durations=asset_durs)
|
||
max_allowed = 12.0 * 0.9
|
||
assert result[0] <= max_allowed + 0.01, f"第一段 {result[0]} 超过 90% 上限 {max_allowed}"
|
||
|
||
def test_clamp_does_not_violate_min(self):
|
||
"""素材极短时钳制不违反 MIN_CLIP_DURATION。"""
|
||
# 素材 2.0s,90% = 1.8s < MIN(2.0),不应钳制到 1.8
|
||
asset_durs = [2.0, 60.0, 60.0, 60.0, 60.0]
|
||
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|
||
|
||
def test_clamp_preserves_total(self):
|
||
"""钳制后总时长仍应接近配音时长。"""
|
||
asset_durs = [10.0, 60.0, 60.0, 60.0, 60.0]
|
||
voice = 30.0
|
||
result = plan_clip_durations(5, voice, asset_durations=asset_durs)
|
||
net = total_output_duration(result)
|
||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5 # 允许略多误差
|
||
|
||
def test_all_assets_short(self):
|
||
"""所有素材都短时,钳制全部生效但不违反最小值。"""
|
||
asset_durs = [8.0, 8.0, 8.0, 8.0, 8.0]
|
||
result = plan_clip_durations(5, 30.0, asset_durations=asset_durs)
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|
||
max_allowed = 8.0 * 0.9
|
||
# 如果 max_allowed >= MIN_CLIP_DURATION 才钳制
|
||
if max_allowed >= MIN_CLIP_DURATION:
|
||
assert dur <= max_allowed + 0.1
|
||
|
||
|
||
# ── #1768 时长总和误差校验 ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestTotalDurationTolerance:
|
||
def test_no_transition_exact_match(self):
|
||
"""无转场时总时长精确等于配音。"""
|
||
result = plan_clip_durations(5, 25.0)
|
||
assert abs(sum(result) - 25.0) < 0.01
|
||
|
||
def test_with_transition_within_tolerance(self):
|
||
"""有转场时净时长在 0.5s 以内。"""
|
||
effects = [None, "xfade", "fade", "xfade", "fade"]
|
||
durations = [0.0, 0.8, 1.2, 0.5, 1.0]
|
||
result = plan_clip_durations(5, 45.0, transition_effects=effects, transition_durations=durations)
|
||
net = total_output_duration(result, effects, durations)
|
||
assert abs(net - 45.0) <= TOTAL_DURATION_TOLERANCE
|
||
|
||
@pytest.mark.parametrize("voice", [10.0, 20.0, 30.0, 60.0, 120.0])
|
||
def test_various_voice_durations(self, voice):
|
||
result = plan_clip_durations(5, voice)
|
||
net = total_output_duration(result)
|
||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE
|
||
|
||
@pytest.mark.parametrize("tpl", RHYTHM_TEMPLATES)
|
||
def test_each_template_within_tolerance(self, tpl):
|
||
"""每种模板分配的总时长都应在误差范围内。"""
|
||
adapted = adapt_template_length(tpl, 5)
|
||
result = plan_clip_durations(5, 30.0, rhythm_template=adapted)
|
||
net = total_output_duration(result)
|
||
assert (
|
||
abs(net - 30.0) <= TOTAL_DURATION_TOLERANCE
|
||
), f"模板 {tpl} 总时长误差 {abs(net - 30.0):.3f}s > {TOTAL_DURATION_TOLERANCE}s"
|
||
|
||
|
||
# ── #1768 组合场景 ──────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCombinedScenarios:
|
||
def test_rhythm_plus_clamp_plus_tolerance(self):
|
||
"""节奏模板 + 素材钳制 + 误差校验 同时生效。"""
|
||
tpl = [3, 2, 1, 2, 1]
|
||
effects = [None, "xfade", None, "xfade", None]
|
||
tdurs = [0.0, 1.0, 0.0, 1.0, 0.0]
|
||
asset_durs = [15.0, 60.0, 60.0, 60.0, 60.0]
|
||
voice = 30.0
|
||
|
||
adapted = adapt_template_length(tpl, 5)
|
||
result = plan_clip_durations(
|
||
5,
|
||
voice,
|
||
transition_effects=effects,
|
||
transition_durations=tdurs,
|
||
rhythm_template=adapted,
|
||
asset_durations=asset_durs,
|
||
)
|
||
|
||
# 最小值保证
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|
||
|
||
# 最大值钳制(第一段 90% = 13.5)
|
||
assert result[0] <= 15.0 * 0.9 + 0.1
|
||
|
||
# 总时长误差
|
||
net = total_output_duration(result, effects, tdurs)
|
||
assert abs(net - voice) <= TOTAL_DURATION_TOLERANCE + 0.5
|
||
|
||
def test_many_clips_with_cycling_template(self):
|
||
"""片段数 > 模板长度时循环填充 + 钳制。"""
|
||
tpl = [2, 2, 1, 1, 2]
|
||
adapted = adapt_template_length(tpl, 8)
|
||
assert len(adapted) == 8
|
||
|
||
asset_durs = [20.0] * 8
|
||
result = plan_clip_durations(8, 40.0, rhythm_template=adapted, asset_durations=asset_durs)
|
||
assert len(result) == 8
|
||
for dur in result:
|
||
assert dur >= MIN_CLIP_DURATION
|