Files
xiaoxia-saas/tests/unit/test_1970_atom_clip_service.py
T
xiaoxia 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
feat(#1970): 素材原子化切片 P1 - 数据层/切片逻辑/原子片段级选片 (#1974)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 03:57:07 +08:00

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)