"""clip_operations 单元测试 — 片段分割/合并纯逻辑层。 覆盖: - validate_split_time: 分割时间校验 - calculate_split: 分割参数计算 - validate_merge_clips: 合并校验 - calculate_merge: 合并参数计算 - calculate_reorder_new_orders: 重排 order 计算 - calculate_shift_orders: order 偏移计算 """ from __future__ import annotations import unittest from dataclasses import FrozenInstanceError, dataclass from packages.domain.clip_operations import ( ROUND_PRECISION, MergeResult, SplitResult, calculate_merge, calculate_reorder_new_orders, calculate_shift_orders, calculate_split, validate_merge_clips, validate_split_time, ) # ── Mock Clip ──────────────────────────────────────────────────────────────── @dataclass class _MockClip: id: str = "c1" plan_id: str = "p1" order: int = 0 clip_type: str = "main" duration: float = 5.0 start_time: float = 0.0 text_content: str = "" config: dict | None = None # ── validate_split_time 测试 ───────────────────────────────────────────────── class TestValidateSplitTime(unittest.TestCase): """validate_split_time 分割时间校验测试。""" def test_valid_split(self): """合法分割时间不报错。""" validate_split_time(2.5, 5.0) # 不抛异常 def test_split_at_zero(self): """分割时间为 0 时报错。""" with self.assertRaises(ValueError) as ctx: validate_split_time(0.0, 5.0) self.assertIn("分割时间", str(ctx.exception)) def test_split_negative(self): """分割时间为负数时报错。""" with self.assertRaises(ValueError): validate_split_time(-1.0, 5.0) def test_split_at_duration(self): """分割时间等于 duration 时报错。""" with self.assertRaises(ValueError): validate_split_time(5.0, 5.0) def test_split_over_duration(self): """分割时间超过 duration 时报错。""" with self.assertRaises(ValueError): validate_split_time(6.0, 5.0) def test_split_very_small(self): """很小的正数是合法的。""" validate_split_time(0.001, 5.0) # 不抛异常 def test_split_just_below_duration(self): """略小于 duration 是合法的。""" validate_split_time(4.999, 5.0) # 不抛异常 def test_error_message_contains_duration(self): """错误消息包含 duration 值。""" with self.assertRaises(ValueError) as ctx: validate_split_time(6.0, 5.0) self.assertIn("5.000", str(ctx.exception)) # ── calculate_split 测试 ───────────────────────────────────────────────────── class TestCalculateSplit(unittest.TestCase): """calculate_split 分割计算测试。""" def test_middle_split(self): """从中间分割。""" result = calculate_split(duration=10.0, split_time=5.0) self.assertIsInstance(result, SplitResult) self.assertEqual(result.left_duration, 5.0) self.assertEqual(result.right_duration, 5.0) self.assertEqual(result.right_start_time, 5.0) self.assertEqual(result.left_trim_end, 5.0) self.assertEqual(result.right_trim_start, 5.0) def test_early_split(self): """从开头附近分割。""" result = calculate_split(duration=10.0, split_time=2.0) self.assertEqual(result.left_duration, 2.0) self.assertEqual(result.right_duration, 8.0) self.assertEqual(result.right_start_time, 2.0) def test_late_split(self): """从结尾附近分割。""" result = calculate_split(duration=10.0, split_time=8.0) self.assertEqual(result.left_duration, 8.0) self.assertEqual(result.right_duration, 2.0) def test_with_start_time_offset(self): """带 start_time 偏移。""" result = calculate_split(duration=5.0, split_time=2.0, start_time=10.0) self.assertEqual(result.left_duration, 2.0) self.assertEqual(result.right_duration, 3.0) self.assertEqual(result.right_start_time, 12.0) def test_zero_start_time(self): """start_time 为 0 时 right_start_time 等于 left_duration。""" result = calculate_split(duration=5.0, split_time=2.0, start_time=0.0) self.assertEqual(result.right_start_time, result.left_duration) def test_round_to_precision(self): """结果精度符合设置。""" result = calculate_split(duration=1.0, split_time=1 / 3, precision=3) # 1/3 ≈ 0.333(3位精度) self.assertAlmostEqual(result.left_duration, 0.333, places=3) self.assertAlmostEqual(result.right_duration, 0.667, places=3) def test_default_precision_3(self): """默认精度为 3 位小数。""" self.assertEqual(ROUND_PRECISION, 3) result = calculate_split(duration=1.0, split_time=0.333333) # 默认用 ROUND_PRECISION = 3 self.assertEqual(result.left_duration, 0.333) def test_custom_precision(self): """自定义精度。""" result = calculate_split(duration=1.0, split_time=0.123456, precision=5) self.assertEqual(result.left_duration, 0.12346) self.assertEqual(result.right_duration, 0.87654) def test_invalid_split_time_raises(self): """不合法的分割时间抛出 ValueError。""" with self.assertRaises(ValueError): calculate_split(duration=5.0, split_time=0.0) with self.assertRaises(ValueError): calculate_split(duration=5.0, split_time=5.0) with self.assertRaises(ValueError): calculate_split(duration=5.0, split_time=-1.0) def test_trim_values_match_durations(self): """trim 值与对应时长一致。""" result = calculate_split(duration=7.5, split_time=3.0) self.assertEqual(result.right_trim_start, result.left_duration) self.assertEqual(result.left_trim_end, result.right_duration) def test_frozen_result(self): """SplitResult 是 frozen dataclass。""" result = calculate_split(duration=5.0, split_time=2.0) with self.assertRaises(FrozenInstanceError): result.left_duration = 3.0 # type: ignore[misc] # ── validate_merge_clips 测试 ──────────────────────────────────────────────── class TestValidateMergeClips(unittest.TestCase): """validate_merge_clips 合并校验测试。""" def test_two_consecutive_clips(self): """两个连续片段:校验通过。""" clips = [ _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), ] plan_id, first_order = validate_merge_clips(clips) self.assertEqual(plan_id, "p1") self.assertEqual(first_order, 0) def test_three_consecutive_clips(self): """三个连续片段:校验通过。""" clips = [ _MockClip(id="c1", plan_id="p1", order=2, clip_type="main"), _MockClip(id="c2", plan_id="p1", order=3, clip_type="main"), _MockClip(id="c3", plan_id="p1", order=4, clip_type="main"), ] plan_id, first_order = validate_merge_clips(clips) self.assertEqual(plan_id, "p1") self.assertEqual(first_order, 2) def test_unordered_input(self): """输入顺序不影响校验(自动排序)。""" clips = [ _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), ] plan_id, first_order = validate_merge_clips(clips) self.assertEqual(plan_id, "p1") self.assertEqual(first_order, 0) def test_single_clip_raises(self): """只有一个片段报错。""" clips = [_MockClip(id="c1", plan_id="p1", order=0)] with self.assertRaises(ValueError) as ctx: validate_merge_clips(clips) self.assertIn("至少需要 2 个", str(ctx.exception)) def test_empty_clips_raises(self): """空列表报错。""" with self.assertRaises(ValueError): validate_merge_clips([]) def test_different_plan_raises(self): """不同计划的片段报错。""" clips = [ _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), _MockClip(id="c2", plan_id="p2", order=1, clip_type="main"), ] with self.assertRaises(ValueError) as ctx: validate_merge_clips(clips) self.assertIn("同一计划", str(ctx.exception)) def test_non_consecutive_raises(self): """不连续的片段报错。""" clips = [ _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), _MockClip(id="c2", plan_id="p1", order=2, clip_type="main"), ] with self.assertRaises(ValueError) as ctx: validate_merge_clips(clips) self.assertIn("不连续", str(ctx.exception)) def test_different_type_raises(self): """类型不同报错。""" clips = [ _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), _MockClip(id="c2", plan_id="p1", order=1, clip_type="title"), ] with self.assertRaises(ValueError) as ctx: validate_merge_clips(clips) self.assertIn("相同类型", str(ctx.exception)) def test_gap_in_middle_raises(self): """中间有间隔报错(三个片段中间缺一个)。""" clips = [ _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), _MockClip(id="c3", plan_id="p1", order=3, clip_type="main"), ] with self.assertRaises(ValueError): validate_merge_clips(clips) # ── calculate_merge 测试 ───────────────────────────────────────────────────── class TestCalculateMerge(unittest.TestCase): """calculate_merge 合并计算测试。""" def test_two_clips_total_duration(self): """两个片段:总时长相加。""" clips = [ _MockClip(id="c1", order=0, duration=5.0), _MockClip(id="c2", order=1, duration=3.0), ] result = calculate_merge(clips) self.assertIsInstance(result, MergeResult) self.assertEqual(result.total_duration, 8.0) self.assertEqual(result.first_order, 0) self.assertEqual(result.shift_amount, 1) def test_three_clips_total_duration(self): """三个片段:总时长相加。""" clips = [ _MockClip(id="c1", order=2, duration=2.0), _MockClip(id="c2", order=3, duration=3.5), _MockClip(id="c3", order=4, duration=4.5), ] result = calculate_merge(clips) self.assertEqual(result.total_duration, 10.0) self.assertEqual(result.first_order, 2) self.assertEqual(result.shift_amount, 2) def test_unordered_input(self): """输入顺序不影响结果(自动按 order 排序)。""" clips = [ _MockClip(id="c2", order=1, duration=3.0), _MockClip(id="c1", order=0, duration=5.0), ] result = calculate_merge(clips) self.assertEqual(result.total_duration, 8.0) self.assertEqual(result.first_order, 0) def test_merge_text_newline_join(self): """文案用换行连接,跳过空字符串。""" clips = [ _MockClip(id="c1", order=0, duration=2.0, text_content="第一段"), _MockClip(id="c2", order=1, duration=3.0, text_content="第二段"), ] result = calculate_merge(clips) self.assertEqual(result.merged_text, "第一段\n第二段") def test_merge_text_skip_empty(self): """空文案或纯空格被跳过。""" clips = [ _MockClip(id="c1", order=0, duration=2.0, text_content=""), _MockClip(id="c2", order=1, duration=3.0, text_content="有内容"), _MockClip(id="c3", order=2, duration=1.0, text_content=" "), ] result = calculate_merge(clips) self.assertEqual(result.merged_text, "有内容") def test_all_empty_text(self): """所有文案都空时合并结果为空字符串。""" clips = [ _MockClip(id="c1", order=0, duration=2.0, text_content=""), _MockClip(id="c2", order=1, duration=3.0, text_content=" "), ] result = calculate_merge(clips) self.assertEqual(result.merged_text, "") def test_merge_config_later_overwrites(self): """后面的 config 覆盖前面的。""" clips = [ _MockClip( id="c1", order=0, duration=2.0, config={"color": "red", "speed": 1.0}, ), _MockClip( id="c2", order=1, duration=3.0, config={"color": "blue", "filter": "vintage"}, ), ] result = calculate_merge(clips) self.assertEqual(result.merged_config["color"], "blue") # 后面的覆盖 self.assertEqual(result.merged_config["speed"], 1.0) # 保留前面的 self.assertEqual(result.merged_config["filter"], "vintage") # 新增的 def test_merge_config_none_handled(self): """config 为 None 时正常处理。""" clips = [ _MockClip(id="c1", order=0, duration=2.0, config=None), _MockClip(id="c2", order=1, duration=3.0, config={"key": "value"}), ] result = calculate_merge(clips) self.assertEqual(result.merged_config["key"], "value") def test_trim_fields_removed(self): """trim_start 和 trim_end 被移除(合并后是完整片段)。""" clips = [ _MockClip( id="c1", order=0, duration=2.0, config={"trim_end": 2.0, "color": "red"}, ), _MockClip( id="c2", order=1, duration=3.0, config={"trim_start": 1.0, "speed": 1.5}, ), ] result = calculate_merge(clips) self.assertNotIn("trim_start", result.merged_config) self.assertNotIn("trim_end", result.merged_config) self.assertEqual(result.merged_config["color"], "red") self.assertEqual(result.merged_config["speed"], 1.5) def test_empty_clips_raises(self): """空列表报错。""" with self.assertRaises(ValueError): calculate_merge([]) def test_single_clip_merge(self): """单个片段也能计算(虽然 validate 会限制,但函数本身支持)。""" clips = [_MockClip(id="c1", order=5, duration=5.0, text_content="唯一")] result = calculate_merge(clips) self.assertEqual(result.total_duration, 5.0) self.assertEqual(result.first_order, 5) self.assertEqual(result.shift_amount, 0) self.assertEqual(result.merged_text, "唯一") def test_round_precision(self): """时长精度符合设置。""" clips = [ _MockClip(id="c1", order=0, duration=1 / 3), _MockClip(id="c2", order=1, duration=1 / 3), ] result = calculate_merge(clips, precision=3) self.assertEqual(result.total_duration, 0.667) def test_frozen_result(self): """MergeResult 是 frozen dataclass。""" clips = [ _MockClip(id="c1", order=0, duration=2.0), _MockClip(id="c2", order=1, duration=3.0), ] result = calculate_merge(clips) with self.assertRaises(FrozenInstanceError): result.total_duration = 10.0 # type: ignore[misc] # ── calculate_reorder_new_orders 测试 ──────────────────────────────────────── class TestCalculateReorderNewOrders(unittest.TestCase): """calculate_reorder_new_orders 重排计算测试。""" def test_two_items_swap(self): """两个元素交换顺序。""" items = [ _MockClip(id="a", order=0), _MockClip(id="b", order=1), ] result = calculate_reorder_new_orders(["b", "a"], items) self.assertEqual(result["b"], 0) self.assertEqual(result["a"], 1) def test_three_items_reorder(self): """三个元素重新排序。""" items = [ _MockClip(id="a", order=0), _MockClip(id="b", order=1), _MockClip(id="c", order=2), ] result = calculate_reorder_new_orders(["c", "a", "b"], items) self.assertEqual(result["c"], 0) self.assertEqual(result["a"], 1) self.assertEqual(result["b"], 2) def test_same_order(self): """顺序不变。""" items = [ _MockClip(id="a", order=0), _MockClip(id="b", order=1), ] result = calculate_reorder_new_orders(["a", "b"], items) self.assertEqual(result["a"], 0) self.assertEqual(result["b"], 1) def test_mismatched_ids_raises(self): """ID 不匹配报错。""" items = [ _MockClip(id="a", order=0), _MockClip(id="b", order=1), ] with self.assertRaises(ValueError) as ctx: calculate_reorder_new_orders(["a", "c"], items) self.assertIn("不匹配", str(ctx.exception)) def test_extra_id_in_list_raises(self): """有序列表多出 ID 报错。""" items = [_MockClip(id="a", order=0)] with self.assertRaises(ValueError): calculate_reorder_new_orders(["a", "b"], items) def test_missing_id_raises(self): """有序列表缺少 ID 报错。""" items = [ _MockClip(id="a", order=0), _MockClip(id="b", order=1), ] with self.assertRaises(ValueError): calculate_reorder_new_orders(["a"], items) def test_custom_attr_names(self): """自定义属性名。""" @dataclass class _Item: key: str pos: int items = [_Item(key="x", pos=0), _Item(key="y", pos=1)] result = calculate_reorder_new_orders(["y", "x"], items, id_attr="key", order_attr="pos") self.assertEqual(result["y"], 0) self.assertEqual(result["x"], 1) # ── calculate_shift_orders 测试 ────────────────────────────────────────────── class TestCalculateShiftOrders(unittest.TestCase): """calculate_shift_orders order 偏移计算测试。""" def test_shift_positive(self): """正偏移:order 增加。""" items = [ _MockClip(id="c1", order=0), _MockClip(id="c2", order=1), _MockClip(id="c3", order=2), ] result = calculate_shift_orders(items, threshold_order=0, shift=1) shifted = {c.id: new_order for c, new_order in result} # order > 0 的才会被偏移 self.assertEqual(len(shifted), 2) self.assertEqual(shifted["c2"], 2) self.assertEqual(shifted["c3"], 3) self.assertNotIn("c1", shifted) def test_shift_negative(self): """负偏移:order 减少。""" items = [ _MockClip(id="c1", order=0), _MockClip(id="c2", order=2), _MockClip(id="c3", order=3), ] result = calculate_shift_orders(items, threshold_order=1, shift=-1) shifted = {c.id: new_order for c, new_order in result} self.assertEqual(shifted["c2"], 1) self.assertEqual(shifted["c3"], 2) self.assertNotIn("c1", shifted) def test_excluded_ids(self): """排除指定 ID。""" items = [ _MockClip(id="c1", order=0), _MockClip(id="c2", order=1), _MockClip(id="c3", order=2), ] result = calculate_shift_orders( items, threshold_order=0, shift=1, excluded_ids={"c2"}, ) shifted = {c.id: new_order for c, new_order in result} self.assertNotIn("c1", shifted) self.assertNotIn("c2", shifted) # 被排除 self.assertEqual(shifted["c3"], 3) def test_no_items_above_threshold(self): """没有元素高于阈值时返回空。""" items = [ _MockClip(id="c1", order=0), _MockClip(id="c2", order=1), ] result = calculate_shift_orders(items, threshold_order=5, shift=1) self.assertEqual(result, []) def test_zero_shift(self): """偏移量为 0 时仍返回(虽然 order 不变)。""" items = [ _MockClip(id="c1", order=0), _MockClip(id="c2", order=1), ] result = calculate_shift_orders(items, threshold_order=0, shift=0) shifted = {c.id: new_order for c, new_order in result} self.assertEqual(shifted["c2"], 1) # 1 + 0 = 1 def test_threshold_exclusive(self): """阈值是严格大于(不包含等于)。""" items = [ _MockClip(id="c1", order=5), # 等于 threshold,不偏移 _MockClip(id="c2", order=6), # 大于 threshold,偏移 ] result = calculate_shift_orders(items, threshold_order=5, shift=2) shifted = {c.id: new_order for c, new_order in result} self.assertNotIn("c1", shifted) self.assertEqual(shifted["c2"], 8) def test_empty_items(self): """空 items 返回空列表。""" result = calculate_shift_orders([], threshold_order=0, shift=1) self.assertEqual(result, []) # ── 集成测试 ──────────────────────────────────────────────────────────────── class TestEndToEndClipOperations(unittest.TestCase): """端到端集成测试:完整分割+重排流程。""" def test_split_then_shift(self): """分割一个片段后,后面的片段 order +1。""" # 模拟:有3个片段,分割第1个(order=1),后面的+1 clips = [ _MockClip(id="c1", order=0, duration=3.0), _MockClip(id="c2", order=1, duration=5.0), _MockClip(id="c3", order=2, duration=4.0), ] # 计算分割 split = calculate_split(duration=5.0, split_time=2.0) # 后面的片段 order +1 shift_result = calculate_shift_orders( clips, threshold_order=1, shift=1, excluded_ids={"c2"}, ) shifted = {c.id: new_order for c, new_order in shift_result} self.assertEqual(shifted["c3"], 3) # 2+1 self.assertNotIn("c1", shifted) # order <= 1 self.assertNotIn("c2", shifted) # 被排除 # 左半部分保留 order=1,右半部分 order=2 self.assertEqual(split.left_duration, 2.0) self.assertEqual(split.right_duration, 3.0) def test_merge_then_shift(self): """合并两个片段后,后面的片段 order -1。""" clips = [ _MockClip(id="c1", order=0, duration=3.0, text_content="A"), _MockClip(id="c2", order=1, duration=2.0, text_content="B"), _MockClip(id="c3", order=2, duration=4.0), _MockClip(id="c4", order=3, duration=1.0), ] # 校验合并 validate_merge_clips(clips[:2]) # 计算合并 merge = calculate_merge(clips[:2]) self.assertEqual(merge.total_duration, 5.0) self.assertEqual(merge.merged_text, "A\nB") self.assertEqual(merge.shift_amount, 1) # 后面的片段前移 1 位 shift_result = calculate_shift_orders( clips, threshold_order=0, # order > 0 的 shift=-merge.shift_amount, excluded_ids={"c1", "c2"}, ) shifted = {c.id: new_order for c, new_order in shift_result} self.assertEqual(shifted["c3"], 1) # 2-1 self.assertEqual(shifted["c4"], 2) # 3-1 if __name__ == "__main__": unittest.main()