test(wave176): clip_operations 片段操作工具 +56测 #1130

Closed
xiaoxia wants to merge 1 commits from test/wave176-clip-operations into develop
+518
View File
@@ -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