Files
xiaoxia-saas/tests/unit/test_clip_operations.py
xiaoxia b98acefe0f
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
fix(ci): 修复daily-check/acr-cleanup docker兼容性 + 清理ruff历史遗留 + 修复Unit Tests问题 (#981)
fix(ci): 修复daily-check/acr-cleanup docker兼容性 + 清理ruff历史遗留 + 修复Unit Tests死循环和ffmpeg兼容性问题 (#981)
2026-07-27 07:23:54 +08:00

650 lines
24 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.3333位精度)
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()