"""Tests for scene-change smart frame selection + random shuffle of segment processing.""" from __future__ import annotations import random from unittest.mock import MagicMock, patch import pytest from apps.api.app.api.routes.templates_editor.clips import ( _build_scene_segments, _pick_start_in_scene_segment, ) from packages.shared.mediakit_client import MediaKitClient # ── Part 1: Random shuffle tests ────────────────────────────────────────── class TestRandomShuffle: """验证 segments 处理顺序随机打乱逻辑.""" def test_same_segments_produce_different_asset_orders(self): """同一批 segments 多次处理,asset 分配顺序有变化. 模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序 存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。 """ segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)] asset_ids = ["A", "B", "C", "D"] observed_orders: list[tuple] = set() for _ in range(50): shuffled_indices = list(range(len(segments))) random.shuffle(shuffled_indices) order_tuple = tuple(shuffled_indices) observed_orders.add(order_tuple) # 50 次打乱,4! = 24 种排列,应出现多种不同排列 assert len(observed_orders) > 1, "打乱应该产生多种不同顺序" def test_clips_data_order_always_sorted(self): """clips_data 按 order 排序后始终有序. 模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。 """ segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)] for _ in range(20): shuffled_indices = list(range(len(segments))) random.shuffle(shuffled_indices) # 模拟构建 clips_data(用 _seg_order 作为 order) clips_data = [] for idx in shuffled_indices: seg_order, _, _ = segments[idx] clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"}) # 按 order 排序 clips_data.sort(key=lambda c: c["order"]) # 验证 order 始终有序 orders = [c["order"] for c in clips_data] assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}" # ── Part 2: detect_scene_changes tests ──────────────────────────────────── class TestDetectSceneChanges: """验证 MediaKitClient.detect_scene_changes 方法.""" def _make_client(self) -> MediaKitClient: """创建一个可用的 MediaKitClient(mock 配置).""" with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings: mock_settings.return_value.mediakit_api_key = "test-key" mock_settings.return_value.mediakit_base_url = "http://test" mock_settings.return_value.mediakit_timeout = 30 client = MediaKitClient() return client def test_scene_change_success(self): """SceneChange 策略成功返回时间戳列表.""" client = self._make_client() mock_frames = [ {"image_url": "url1", "timestamp": 0.0}, {"image_url": "url2", "timestamp": 3.2}, {"image_url": "url3", "timestamp": 7.8}, {"image_url": "url4", "timestamp": 12.5}, ] with patch.object(client, "extract_frames", return_value=mock_frames): result = client.detect_scene_changes("https://example.com/video.mp4") assert result is not None assert result[0] == 0.0 # 始终以 0.0 开头 assert 3.2 in result assert 7.8 in result assert 12.5 in result assert result == sorted(result) # 应已排序 def test_scene_change_fallback_to_time_interval(self): """SceneChange 失败降级到 TimeInterval 策略.""" client = self._make_client() # 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果 fallback_frames = [ {"image_url": "url1", "timestamp": 0.0}, {"image_url": "url2", "timestamp": 5.0}, {"image_url": "url3", "timestamp": 10.0}, ] call_count = 0 def side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: # 第一次 SceneChange 失败 return None else: # 第二次 TimeInterval 成功 assert kwargs.get("strategy") == "TimeInterval" return fallback_frames with patch.object(client, "extract_frames", side_effect=side_effect): result = client.detect_scene_changes("https://example.com/video.mp4") assert result is not None assert result[0] == 0.0 assert 5.0 in result assert 10.0 in result def test_mediakit_not_available_returns_none(self): """MediaKit 不可用时返回 None.""" with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings: mock_settings.return_value.mediakit_api_key = "" # 未配置 mock_settings.return_value.mediakit_base_url = "http://test" mock_settings.return_value.mediakit_timeout = 30 client = MediaKitClient() result = client.detect_scene_changes("https://example.com/video.mp4") assert result is None def test_both_strategies_fail_returns_none(self): """SceneChange 和 TimeInterval 都失败时返回 None.""" client = self._make_client() with patch.object(client, "extract_frames", return_value=None): result = client.detect_scene_changes("https://example.com/video.mp4") assert result is None def test_prepends_zero_if_not_present(self): """若帧列表中不包含 0.0,自动在开头添加.""" client = self._make_client() # 帧列表中没有 timestamp=0.0 mock_frames = [ {"image_url": "url1", "timestamp": 2.0}, {"image_url": "url2", "timestamp": 5.5}, ] with patch.object(client, "extract_frames", return_value=mock_frames): result = client.detect_scene_changes("https://example.com/video.mp4") assert result is not None assert result[0] == 0.0 assert 2.0 in result assert 5.5 in result # ── Part 2.2: Scene segment building and assignment ─────────────────────── class TestSceneSegments: """验证镜头段构建和分配逻辑.""" def test_build_scene_segments(self): """从场景切换点正确构建镜头段.""" scene_changes = [0.0, 3.2, 7.8, 12.5] asset_duration = 15.0 segments = _build_scene_segments(scene_changes, asset_duration) assert len(segments) == 4 assert segments[0] == (0.0, 3.2) assert segments[1] == (3.2, 7.8) assert segments[2] == (7.8, 12.5) assert segments[3] == (12.5, 15.0) def test_build_scene_segments_filters_short(self): """过滤掉过短的镜头段(< 0.5秒).""" scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0] asset_duration = 12.0 segments = _build_scene_segments(scene_changes, asset_duration) # (0.0, 0.1) 长度 0.1 < 0.5 → 过滤 # (0.1, 5.0) → 保留 # (5.0, 5.3) 长度 0.3 < 0.5 → 过滤 # (5.3, 10.0) → 保留 # (10.0, 12.0) → 保留 assert len(segments) == 3 assert segments[0] == (0.1, 5.0) assert segments[1] == (5.3, 10.0) assert segments[2] == (10.0, 12.0) def test_pick_start_in_segment(self): """在镜头段内随机选取起始时间.""" seg_start = 3.0 seg_end = 8.0 clip_duration = 2.0 starts = set() for _ in range(100): start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration) assert start is not None assert seg_start <= start <= seg_end - clip_duration starts.add(round(start, 2)) # 应该有多个不同的起始时间 assert len(starts) > 1 def test_pick_start_segment_too_short(self): """镜头段太短无法容纳片段时返回 None.""" result = _pick_start_in_scene_segment(0.0, 1.0, 2.0) assert result is None def test_different_clips_from_different_scenes(self): """不同片段应来自不同的镜头段(模拟分配逻辑).""" scene_changes = [0.0, 5.0, 10.0, 15.0] asset_duration = 18.0 clip_duration = 3.0 segments = _build_scene_segments(scene_changes, asset_duration) assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18) # 模拟 3 个片段从不同镜头段取点 scene_pool = list(segments) assigned_starts = [] for _ in range(3): if not scene_pool: break seg_start, seg_end = scene_pool.pop(0) start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration) assert start is not None assigned_starts.append(start) # 3 个片段分别从 3 个不同镜头段中选取 assert len(assigned_starts) == 3 # 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12] assert 0.0 <= assigned_starts[0] <= 2.0 assert 5.0 <= assigned_starts[1] <= 7.0 assert 10.0 <= assigned_starts[2] <= 12.0