f1621ace9f
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m26s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m44s
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 / Validate - Python (mypy + alembic) (push) Successful in 5m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Validate - Style (push) Successful in 7m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Unit Tests (push) Successful in 10m42s
CI/CD Pipeline / Validate - Security (push) Successful in 12m25s
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>
152 lines
6.7 KiB
Python
152 lines
6.7 KiB
Python
"""#1970 素材原子化切片逻辑单元测试(纯函数,不依赖 DB)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from packages.domain.asset_atom_clip import AssetAtomClip
|
|
from packages.domain.atom_clip_service import (
|
|
MAX_CLIP_SECONDS,
|
|
MIN_CLIP_SECONDS,
|
|
compute_atom_clips,
|
|
compute_fallback_clips,
|
|
)
|
|
|
|
|
|
class TestComputeAtomClips:
|
|
def test_short_asset_under_6s_single_clip(self):
|
|
"""<6 秒素材整条作为一个片段,不切。"""
|
|
for dur in (0.1, 3.0, 5.99):
|
|
clips = compute_atom_clips("a1", dur, rng=random.Random(1))
|
|
assert len(clips) == 1
|
|
assert clips[0].start_time == 0.0
|
|
assert abs(clips[0].end_time - dur) < 0.01
|
|
assert clips[0].clip_index == 0
|
|
|
|
def test_exactly_6s_single_clip(self):
|
|
clips = compute_atom_clips("a1", 6.0, rng=random.Random(1))
|
|
assert len(clips) == 1
|
|
assert clips[0].start_time == 0.0
|
|
|
|
def test_zero_and_negative_duration_returns_empty(self):
|
|
assert compute_atom_clips("a1", 0) == []
|
|
assert compute_atom_clips("a1", -1.0) == []
|
|
|
|
@pytest.mark.parametrize("seed", range(30))
|
|
def test_clips_in_3_to_6_range(self, seed):
|
|
"""除末段外,每段时长在 3~6 秒;末段 >=3 秒。"""
|
|
clips = compute_atom_clips("a1", 60.0, rng=random.Random(seed))
|
|
assert len(clips) >= 2
|
|
for clip in clips[:-1]:
|
|
assert MIN_CLIP_SECONDS - 0.06 <= clip.duration <= MAX_CLIP_SECONDS + 0.06
|
|
# 末段 >=3(不足 3 应已合并)
|
|
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
|
|
|
@pytest.mark.parametrize("dur", [6.01, 7.0, 9.0, 12.3, 30.0, 45.3, 100.0])
|
|
def test_full_coverage_no_gaps_no_overlap(self, dur):
|
|
clips = compute_atom_clips("a1", dur, rng=random.Random(int(dur * 100) % 10000))
|
|
assert abs(clips[0].start_time) < 0.001
|
|
assert abs(clips[-1].end_time - dur) < 0.01
|
|
for prev, nxt in zip(clips, clips[1:], strict=False):
|
|
assert abs(prev.end_time - nxt.start_time) < 0.001
|
|
|
|
def test_clip_index_sequential(self):
|
|
clips = compute_atom_clips("a1", 40.0, rng=random.Random(5))
|
|
assert [c.clip_index for c in clips] == list(range(len(clips)))
|
|
|
|
def test_tail_shorter_than_3s_merges_into_previous(self):
|
|
"""末段不足 3 秒必须合并到前一段。"""
|
|
# 多跑种子,保证任何随机结果都不存在 <3s 的末段
|
|
for seed in range(100):
|
|
clips = compute_atom_clips("a1", 7.5, rng=random.Random(seed))
|
|
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
|
assert abs(clips[-1].end_time - 7.5) < 0.01
|
|
|
|
def test_tail_between_3_and_6_stands_alone(self):
|
|
"""末段 >=3 秒独立成段。"""
|
|
found_standalone = False
|
|
for seed in range(100):
|
|
clips = compute_atom_clips("a1", 9.5, rng=random.Random(seed))
|
|
if len(clips) == 2:
|
|
found_standalone = True
|
|
assert clips[-1].duration >= MIN_CLIP_SECONDS - 0.06
|
|
assert found_standalone, "9.5s 至少在某些种子下应切为两段"
|
|
|
|
def test_scene_change_snap_within_window(self):
|
|
"""切点 0.5s 窗口内有切换点时,切点对齐到切换处。"""
|
|
aligned = 0
|
|
for seed in range(500):
|
|
clips = compute_atom_clips("a1", 20.0, scene_change_points=[4.52], rng=random.Random(seed))
|
|
if any(c.scene_change_at == 4.52 for c in clips):
|
|
aligned += 1
|
|
hit = next(c for c in clips if c.scene_change_at == 4.52)
|
|
# 命中片段的右边界即切换点
|
|
assert abs(hit.end_time - 4.52) < 0.001
|
|
assert aligned > 0
|
|
|
|
def test_scene_change_outside_window_not_force_aligned(self):
|
|
"""窗口外的切换点不应强行对齐。"""
|
|
clips = compute_atom_clips("a1", 30.0, scene_change_points=[15.0], rng=random.Random(1))
|
|
for c in clips:
|
|
if c.scene_change_at is not None:
|
|
assert abs(c.end_time - c.scene_change_at) < 0.001
|
|
|
|
def test_scene_snap_never_creates_sub_3s_clip(self):
|
|
"""对齐不能导致片段短于 3 秒。"""
|
|
for seed in range(100):
|
|
clips = compute_atom_clips("a1", 40.0, scene_change_points=[3.2, 6.3, 9.4], rng=random.Random(seed))
|
|
for c in clips:
|
|
assert c.duration >= MIN_CLIP_SECONDS - 0.06
|
|
|
|
def test_scene_points_out_of_duration_ignored(self):
|
|
clips = compute_atom_clips("a1", 20.0, scene_change_points=[-1.0, 25.0, 4.0], rng=random.Random(3))
|
|
assert all(c.scene_change_at != -1.0 and c.scene_change_at != 25.0 for c in clips)
|
|
|
|
def test_tags_inherited(self):
|
|
clips = compute_atom_clips("a1", 30.0, tags=["t1", "t2"], rng=random.Random(2))
|
|
assert all(c.tags == ["t1", "t2"] for c in clips)
|
|
|
|
def test_random_not_fixed_rhythm(self):
|
|
"""随机切片:不同种子产出的切点集合应不同(避免固定节奏)。"""
|
|
cuts1 = [c.end_time for c in compute_atom_clips("a1", 60.0, rng=random.Random(1))]
|
|
cuts2 = [c.end_time for c in compute_atom_clips("a1", 60.0, rng=random.Random(2))]
|
|
assert cuts1 != cuts2
|
|
|
|
def test_seed_reproducible(self):
|
|
"""相同种子结果可复现。"""
|
|
a = [(c.start_time, c.end_time) for c in compute_atom_clips("a1", 60.0, rng=random.Random(42))]
|
|
b = [(c.start_time, c.end_time) for c in compute_atom_clips("a1", 60.0, rng=random.Random(42))]
|
|
assert a == b
|
|
|
|
|
|
class TestComputeFallbackClips:
|
|
def test_fallback_marked_and_uniform(self):
|
|
clips = compute_fallback_clips("a1", 20.0, clip_seconds=4.5)
|
|
assert clips
|
|
assert all(c.is_fallback for c in clips)
|
|
for prev, nxt in zip(clips, clips[1:], strict=False):
|
|
assert abs(prev.end_time - nxt.start_time) < 0.001
|
|
assert abs(clips[-1].end_time - 20.0) < 0.01
|
|
|
|
def test_fallback_tail_merge(self):
|
|
"""11.5s = 4.5+4.5+2.5 → 末段 2.5<3 合并 → 4.5+7.0。"""
|
|
clips = compute_fallback_clips("a1", 11.5, clip_seconds=4.5)
|
|
assert len(clips) == 2
|
|
assert abs(clips[-1].duration - 7.0) < 0.01
|
|
|
|
def test_fallback_short_asset(self):
|
|
clips = compute_fallback_clips("a1", 2.0)
|
|
assert len(clips) == 1
|
|
assert clips[0].is_fallback
|
|
|
|
def test_fallback_invalid_duration(self):
|
|
assert compute_fallback_clips("a1", 0) == []
|
|
assert compute_fallback_clips("a1", -5) == []
|
|
|
|
def test_fallback_clip_has_no_persisted_id(self):
|
|
clips = compute_fallback_clips("a1", 10.0)
|
|
# 兜底片段仍有运行时 id(dataclass 生成),但 is_fallback 是判别标记
|
|
assert all(isinstance(c, AssetAtomClip) for c in clips)
|