test(wave176): clip_operations 片段操作工具 +56测 #1130
Executable
+518
@@ -0,0 +1,518 @@
|
||||
"""片段操作工具单测.
|
||||
|
||||
纯函数模块,覆盖:分割校验/计算、合并校验/计算、
|
||||
order重排、order偏移。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.clip_operations import (
|
||||
DEFAULT_SPLIT_DURATION,
|
||||
MergeResult,
|
||||
SplitResult,
|
||||
calculate_merge,
|
||||
calculate_reorder_new_orders,
|
||||
calculate_shift_orders,
|
||||
calculate_split,
|
||||
validate_merge_clips,
|
||||
validate_split_time,
|
||||
)
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_default_split_duration(self):
|
||||
assert DEFAULT_SPLIT_DURATION == 5.0
|
||||
|
||||
|
||||
class TestValidateSplitTime:
|
||||
def test_valid_middle(self):
|
||||
validate_split_time(5.0, 10.0) # 不抛异常就是通过
|
||||
|
||||
def test_valid_small(self):
|
||||
validate_split_time(0.1, 10.0)
|
||||
|
||||
def test_valid_near_end(self):
|
||||
validate_split_time(9.9, 10.0)
|
||||
|
||||
def test_zero_invalid(self):
|
||||
try:
|
||||
validate_split_time(0.0, 10.0)
|
||||
except ValueError as e:
|
||||
assert "分割时间" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_negative_invalid(self):
|
||||
try:
|
||||
validate_split_time(-1.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_equal_to_duration_invalid(self):
|
||||
try:
|
||||
validate_split_time(10.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_greater_than_duration_invalid(self):
|
||||
try:
|
||||
validate_split_time(15.0, 10.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
class TestCalculateSplit:
|
||||
def test_split_half(self):
|
||||
result = calculate_split(10.0, 5.0)
|
||||
assert isinstance(result, SplitResult)
|
||||
assert result.left_duration == 5.0
|
||||
assert result.right_duration == 5.0
|
||||
assert result.right_start_time == 5.0
|
||||
assert result.left_trim_end == 5.0
|
||||
assert result.right_trim_start == 5.0
|
||||
|
||||
def test_split_one_third(self):
|
||||
result = calculate_split(9.0, 3.0)
|
||||
assert result.left_duration == 3.0
|
||||
assert result.right_duration == 6.0
|
||||
assert result.right_start_time == 3.0
|
||||
|
||||
def test_split_with_start_time(self):
|
||||
result = calculate_split(10.0, 4.0, start_time=100.0)
|
||||
assert result.left_duration == 4.0
|
||||
assert result.right_duration == 6.0
|
||||
assert result.right_start_time == 104.0
|
||||
|
||||
def test_split_precision_rounding(self):
|
||||
result = calculate_split(1.0, 1 / 3, precision=3)
|
||||
assert result.left_duration == round(1 / 3, 3)
|
||||
assert result.right_duration == round(2 / 3, 3)
|
||||
|
||||
def test_split_default_precision_is_3(self):
|
||||
result = calculate_split(1.0, 0.123456)
|
||||
# 默认精度3位
|
||||
assert result.left_duration == 0.123
|
||||
|
||||
def test_custom_precision(self):
|
||||
result = calculate_split(1.0, 0.123456, precision=5)
|
||||
assert result.left_duration == 0.12346 # 5位精度,四舍五入
|
||||
|
||||
def test_split_returns_frozen_dataclass(self):
|
||||
result = calculate_split(10.0, 5.0)
|
||||
try:
|
||||
result.left_duration = 3.0 # type: ignore
|
||||
except AttributeError:
|
||||
pass # frozen,应该抛异常
|
||||
else:
|
||||
raise AssertionError("SplitResult should be frozen")
|
||||
|
||||
def test_invalid_split_time_raises(self):
|
||||
try:
|
||||
calculate_split(10.0, 0.0)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
"""模拟 EditPlanClip 的最小数据类."""
|
||||
|
||||
id: str = ""
|
||||
plan_id: str = "plan_1"
|
||||
order: int = 0
|
||||
duration: float = 3.0
|
||||
clip_type: str = "main"
|
||||
text_content: str = ""
|
||||
config: dict | None = None
|
||||
|
||||
|
||||
class TestValidateMergeClips:
|
||||
def test_valid_two_clips(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=1),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert plan_id == "plan_1"
|
||||
assert first_order == 0
|
||||
|
||||
def test_valid_three_clips(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=2),
|
||||
FakeClip(id="c2", order=3),
|
||||
FakeClip(id="c3", order=4),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert plan_id == "plan_1"
|
||||
assert first_order == 2
|
||||
|
||||
def test_unordered_input_still_valid(self):
|
||||
"""输入顺序不影响,内部会排序."""
|
||||
clips = [
|
||||
FakeClip(id="c3", order=2),
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=1),
|
||||
]
|
||||
plan_id, first_order = validate_merge_clips(clips)
|
||||
assert first_order == 0
|
||||
|
||||
def test_single_clip_invalid(self):
|
||||
try:
|
||||
validate_merge_clips([FakeClip()])
|
||||
except ValueError as e:
|
||||
assert "至少需要 2 个" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_empty_list_invalid(self):
|
||||
try:
|
||||
validate_merge_clips([])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_different_plan_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", plan_id="plan_a", order=0),
|
||||
FakeClip(id="c2", plan_id="plan_b", order=1),
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "同一计划" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_non_consecutive_order_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0),
|
||||
FakeClip(id="c2", order=2), # 跳过1
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "不连续" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_different_clip_type_invalid(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, clip_type="main"),
|
||||
FakeClip(id="c2", order=1, clip_type="title"),
|
||||
]
|
||||
try:
|
||||
validate_merge_clips(clips)
|
||||
except ValueError as e:
|
||||
assert "相同类型" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
class TestCalculateMerge:
|
||||
def test_merge_two_clips_duration(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=3.0),
|
||||
FakeClip(id="c2", order=1, duration=5.0),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert isinstance(result, MergeResult)
|
||||
assert result.total_duration == 8.0
|
||||
|
||||
def test_merge_three_clips_duration(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=2.0),
|
||||
FakeClip(id="c2", order=1, duration=3.0),
|
||||
FakeClip(id="c3", order=2, duration=4.0),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.total_duration == 9.0
|
||||
|
||||
def test_merge_text_concatenation(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="第一句"),
|
||||
FakeClip(id="c2", order=1, text_content="第二句"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "第一句\n第二句"
|
||||
|
||||
def test_merge_empty_text_skipped(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="hello"),
|
||||
FakeClip(id="c2", order=1, text_content=""),
|
||||
FakeClip(id="c3", order=2, text_content="world"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "hello\nworld"
|
||||
|
||||
def test_merge_whitespace_text_skipped(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content="a"),
|
||||
FakeClip(id="c2", order=1, text_content=" "),
|
||||
FakeClip(id="c3", order=2, text_content="b"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == "a\nb"
|
||||
|
||||
def test_merge_all_empty_text(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, text_content=""),
|
||||
FakeClip(id="c2", order=1, text_content=""),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_text == ""
|
||||
|
||||
def test_merge_config_later_overrides(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config={"font_size": 20, "color": "red"}),
|
||||
FakeClip(id="c2", order=1, config={"font_size": 24, "bold": True}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_config["font_size"] == 24 # 后面的覆盖
|
||||
assert result.merged_config["color"] == "red"
|
||||
assert result.merged_config["bold"] is True
|
||||
|
||||
def test_merge_config_removes_trim_fields(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config={"trim_start": 1.0, "a": 1}),
|
||||
FakeClip(id="c2", order=1, config={"trim_end": 2.0, "b": 2}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert "trim_start" not in result.merged_config
|
||||
assert "trim_end" not in result.merged_config
|
||||
assert result.merged_config["a"] == 1
|
||||
assert result.merged_config["b"] == 2
|
||||
|
||||
def test_merge_none_config_handled(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, config=None),
|
||||
FakeClip(id="c2", order=1, config={"key": "val"}),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.merged_config == {"key": "val"}
|
||||
|
||||
def test_merge_first_order_and_shift(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=5),
|
||||
FakeClip(id="c2", order=6),
|
||||
FakeClip(id="c3", order=7),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.first_order == 5
|
||||
assert result.shift_amount == 2 # 3个合并成1个,前移2位
|
||||
|
||||
def test_merge_two_clips_shift(self):
|
||||
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
|
||||
result = calculate_merge(clips)
|
||||
assert result.shift_amount == 1
|
||||
|
||||
def test_merge_unordered_input(self):
|
||||
"""输入乱序也能正确处理(内部排序)."""
|
||||
clips = [
|
||||
FakeClip(id="c3", order=2, duration=4.0, text_content="C"),
|
||||
FakeClip(id="c1", order=0, duration=2.0, text_content="A"),
|
||||
FakeClip(id="c2", order=1, duration=3.0, text_content="B"),
|
||||
]
|
||||
result = calculate_merge(clips)
|
||||
assert result.total_duration == 9.0
|
||||
assert result.merged_text == "A\nB\nC"
|
||||
assert result.first_order == 0
|
||||
|
||||
def test_merge_precision(self):
|
||||
clips = [
|
||||
FakeClip(id="c1", order=0, duration=1 / 3),
|
||||
FakeClip(id="c2", order=1, duration=1 / 3),
|
||||
]
|
||||
result = calculate_merge(clips, precision=3)
|
||||
assert result.total_duration == round(2 / 3, 3)
|
||||
|
||||
def test_merge_empty_list_raises(self):
|
||||
try:
|
||||
calculate_merge([])
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_merge_result_is_frozen(self):
|
||||
clips = [FakeClip(id="c1", order=0), FakeClip(id="c2", order=1)]
|
||||
result = calculate_merge(clips)
|
||||
try:
|
||||
result.total_duration = 10.0 # type: ignore
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("MergeResult should be frozen")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeItem:
|
||||
id: str
|
||||
order: int = 0
|
||||
|
||||
|
||||
class TestCalculateReorderNewOrders:
|
||||
def test_basic_reorder(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
new_order = ["c", "a", "b"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result == {"c": 0, "a": 1, "b": 2}
|
||||
|
||||
def test_reverse_order(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b"), FakeItem(id="c")]
|
||||
new_order = ["c", "b", "a"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result["c"] == 0
|
||||
assert result["b"] == 1
|
||||
assert result["a"] == 2
|
||||
|
||||
def test_same_order(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
new_order = ["a", "b"]
|
||||
result = calculate_reorder_new_orders(new_order, items)
|
||||
assert result == {"a": 0, "b": 1}
|
||||
|
||||
def test_mismatched_ids_raises(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a", "c"], items)
|
||||
except ValueError as e:
|
||||
assert "不匹配" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_extra_id_in_list_raises(self):
|
||||
items = [FakeItem(id="a")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a", "b"], items)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_missing_id_raises(self):
|
||||
items = [FakeItem(id="a"), FakeItem(id="b")]
|
||||
try:
|
||||
calculate_reorder_new_orders(["a"], items)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_custom_id_attr(self):
|
||||
@dataclass
|
||||
class CustomItem:
|
||||
key: str
|
||||
order: int = 0
|
||||
|
||||
items = [CustomItem(key="x"), CustomItem(key="y")]
|
||||
result = calculate_reorder_new_orders(["y", "x"], items, id_attr="key")
|
||||
assert result == {"y": 0, "x": 1}
|
||||
|
||||
def test_custom_order_attr_does_not_affect_return(self):
|
||||
"""order_attr不影响返回值(返回的是索引),只影响参数校验的ID提取."""
|
||||
items = [FakeItem(id="a", order=10), FakeItem(id="b", order=20)]
|
||||
result = calculate_reorder_new_orders(["b", "a"], items)
|
||||
assert result == {"b": 0, "a": 1} # 新order是索引,不是原值
|
||||
|
||||
|
||||
class TestCalculateShiftOrders:
|
||||
def test_shift_positive(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=5)
|
||||
# order > 0 的是 b(1) 和 c(2)
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert len(result) == 2
|
||||
assert shifted["b"] == 6
|
||||
assert shifted["c"] == 7
|
||||
|
||||
def test_shift_negative(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
FakeItem(id="c", order=2),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=-1)
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert shifted["b"] == 0
|
||||
assert shifted["c"] == 1
|
||||
|
||||
def test_threshold_not_included(self):
|
||||
"""threshold_order本身不包含在内(严格大于)."""
|
||||
items = [FakeItem(id="a", order=5)]
|
||||
result = calculate_shift_orders(items, threshold_order=5, shift=1)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_excluded_ids_skipped(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=1),
|
||||
FakeItem(id="b", order=2),
|
||||
FakeItem(id="c", order=3),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=10, excluded_ids={"b"})
|
||||
shifted = {item.id: new_order for item, new_order in result}
|
||||
assert "b" not in shifted
|
||||
assert shifted["a"] == 11
|
||||
assert shifted["c"] == 13
|
||||
|
||||
def test_none_excluded_ids(self):
|
||||
items = [FakeItem(id="a", order=1)]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=None)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_excluded_ids(self):
|
||||
items = [FakeItem(id="a", order=1)]
|
||||
result = calculate_shift_orders(items, threshold_order=0, shift=1, excluded_ids=set())
|
||||
assert len(result) == 1
|
||||
|
||||
def test_no_items_above_threshold(self):
|
||||
items = [
|
||||
FakeItem(id="a", order=0),
|
||||
FakeItem(id="b", order=1),
|
||||
]
|
||||
result = calculate_shift_orders(items, threshold_order=10, shift=5)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_custom_id_attr(self):
|
||||
@dataclass
|
||||
class CustomItem:
|
||||
key: str
|
||||
pos: int = 0
|
||||
|
||||
items = [CustomItem(key="x", pos=1), CustomItem(key="y", pos=2)]
|
||||
result = calculate_shift_orders(
|
||||
items,
|
||||
threshold_order=0,
|
||||
shift=3,
|
||||
id_attr="key",
|
||||
order_attr="pos",
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0][1] == 4
|
||||
assert result[1][1] == 5
|
||||
|
||||
def test_preserves_item_reference(self):
|
||||
item = FakeItem(id="a", order=5)
|
||||
items = [item]
|
||||
result = calculate_shift_orders(items, threshold_order=3, shift=2)
|
||||
assert len(result) == 1
|
||||
assert result[0][0] is item # 是同一个对象引用
|
||||
assert result[0][1] == 7
|
||||
Reference in New Issue
Block a user