"""#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)