Files
xiaoxia-saas/tests/unit/domain/test_trim_config.py
T

613 lines
21 KiB
Python
Executable File
Raw 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.
"""trim_config 单元测试 - wave165
覆盖:
- TrimConfig.from_dict 构造
- TrimConfig.validate_and_resolve 三选二推导 + 边界钳制
- TrimConfig.is_valid / is_noop / trim_from_start 属性
- TrimSegment.from_dict 构造
- build_video_trim_filter 视频裁剪滤镜
- build_audio_trim_filter 音频裁剪滤镜
- resolve_segments 多段解析
- parse_segments_from_config 配置解析
- extract_trim_from_clip_config 提取工具
"""
import pytest
from packages.domain.trim_config import (
MIN_TRIM_DURATION,
TrimConfig,
TrimSegment,
build_audio_trim_filter,
build_video_trim_filter,
extract_trim_from_clip_config,
parse_segments_from_config,
resolve_segments,
)
# ============================================================
# TrimConfig.from_dict
# ============================================================
class TestTrimConfigFromDict:
def test_none_returns_none(self):
assert TrimConfig.from_dict(None) is None
def test_empty_dict_returns_none(self):
assert TrimConfig.from_dict({}) is None
def test_all_zero_returns_none(self):
assert TrimConfig.from_dict({"start_time": 0, "end_time": 0, "duration": 0}) is None
def test_start_only_returns_config(self):
result = TrimConfig.from_dict({"start_time": 5.0})
assert result is not None
assert result.start_time == 5.0
assert result.end_time == 0
assert result.duration == 0
def test_duration_only_returns_config(self):
result = TrimConfig.from_dict({"duration": 10.0})
assert result is not None
assert result.duration == 10.0
assert result.start_time == 0
def test_start_and_end(self):
result = TrimConfig.from_dict({"start_time": 5.0, "end_time": 15.0})
assert result is not None
assert result.start_time == 5.0
assert result.end_time == 15.0
def test_start_and_duration(self):
result = TrimConfig.from_dict({"start_time": 2.0, "duration": 8.0})
assert result is not None
assert result.start_time == 2.0
assert result.duration == 8.0
def test_end_and_duration(self):
result = TrimConfig.from_dict({"end_time": 20.0, "duration": 5.0})
assert result is not None
assert result.end_time == 20.0
assert result.duration == 5.0
def test_all_three(self):
result = TrimConfig.from_dict({"start_time": 1, "end_time": 5, "duration": 4})
assert result is not None
assert result.start_time == 1.0
def test_string_values(self):
result = TrimConfig.from_dict({"start_time": "3.5", "duration": "2.0"})
assert result is not None
assert result.start_time == 3.5
assert result.duration == 2.0
def test_falsy_values_treated_as_zero(self):
result = TrimConfig.from_dict({"start_time": None, "duration": None})
assert result is None # 两个都是None等价于0
def test_zero_start_with_duration(self):
result = TrimConfig.from_dict({"start_time": 0, "duration": 5.0})
assert result is not None
assert result.duration == 5.0
# ============================================================
# validate_and_resolve - 三选二推导
# ============================================================
class TestValidateAndResolveDerive:
ASSET_DUR = 60.0 # 素材时长60秒
def test_start_plus_end(self):
config = TrimConfig(start_time=10.0, end_time=25.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 10.0
assert result.end_time == 25.0
assert result.duration == 15.0
def test_start_plus_duration(self):
config = TrimConfig(start_time=5.0, duration=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 5.0
assert result.end_time == 15.0
assert result.duration == 10.0
def test_end_plus_duration(self):
config = TrimConfig(end_time=30.0, duration=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 20.0
assert result.end_time == 30.0
assert result.duration == 10.0
def test_end_plus_duration_start_negative_clamped(self):
# end=5, duration=10 → start=-5 → 钳制到0duration=5
config = TrimConfig(end_time=5.0, duration=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 0.0
assert result.end_time == 5.0
assert result.duration == 5.0
def test_start_only_takes_to_end(self):
config = TrimConfig(start_time=50.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 50.0
assert result.end_time == 60.0
assert result.duration == 10.0
def test_end_only_starts_from_zero(self):
config = TrimConfig(end_time=30.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 0.0
assert result.end_time == 30.0
assert result.duration == 30.0
def test_duration_only_starts_from_zero(self):
config = TrimConfig(duration=20.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 0.0
assert result.end_time == 20.0
assert result.duration == 20.0
def test_all_three_uses_start_end(self):
# start=5, end=20, duration=10 → 优先用 start+end 推导 → duration=15
config = TrimConfig(start_time=5.0, end_time=20.0, duration=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 5.0
assert result.end_time == 20.0
assert result.duration == 15.0 # start+end 优先
def test_start_equals_end_invalid(self):
config = TrimConfig(start_time=10.0, end_time=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.duration == 0.0
assert result.is_valid is False
# ============================================================
# validate_and_resolve - 边界钳制
# ============================================================
class TestValidateAndResolveClamp:
ASSET_DUR = 60.0
def test_negative_start_clamped(self):
config = TrimConfig(start_time=-5.0, duration=10.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time == 0.0
assert result.duration == 10.0
def test_end_exceeds_asset_duration(self):
config = TrimConfig(start_time=50.0, duration=20.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.end_time == 60.0
assert result.duration == 10.0
def test_start_exceeds_asset_duration(self):
config = TrimConfig(start_time=70.0, duration=5.0)
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.start_time < self.ASSET_DUR
assert result.end_time == self.ASSET_DUR
def test_zero_asset_duration_returns_noop(self):
config = TrimConfig(start_time=5.0, duration=10.0)
result = config.validate_and_resolve(0.0)
assert result.is_noop is True
def test_negative_asset_duration_returns_noop(self):
config = TrimConfig(start_time=5.0, duration=10.0)
result = config.validate_and_resolve(-1.0)
assert result.is_noop is True
def test_no_params_returns_noop(self):
config = TrimConfig()
result = config.validate_and_resolve(self.ASSET_DUR)
assert result.is_noop is True
# ============================================================
# 属性 is_valid / is_noop / trim_from_start
# ============================================================
class TestTrimConfigProperties:
def test_is_valid_true(self):
config = TrimConfig(start_time=0, end_time=0, duration=5.0)
assert config.is_valid is True
def test_is_valid_false_zero(self):
config = TrimConfig(duration=0.0)
assert config.is_valid is False
def test_is_valid_false_negative(self):
config = TrimConfig(duration=-1.0)
assert config.is_valid is False
def test_is_valid_exactly_minimum(self):
config = TrimConfig(duration=MIN_TRIM_DURATION)
assert config.is_valid is True
def test_is_valid_below_minimum(self):
config = TrimConfig(duration=MIN_TRIM_DURATION / 2)
assert config.is_valid is False
def test_is_noop_true(self):
config = TrimConfig()
assert config.is_noop is True
def test_is_noop_false_with_start(self):
config = TrimConfig(start_time=5.0)
assert config.is_noop is False
def test_is_noop_false_with_duration(self):
config = TrimConfig(duration=10.0)
assert config.is_noop is False
def test_trim_from_start_true(self):
config = TrimConfig(start_time=0.0, duration=10.0)
assert config.trim_from_start is True
def test_trim_from_start_false(self):
config = TrimConfig(start_time=5.0, duration=10.0)
assert config.trim_from_start is False
def test_trim_from_start_negative_treated_as_start(self):
# trim_from_start 检查 start_time <= 0,负值也算从开头
config = TrimConfig(start_time=-1.0)
assert config.trim_from_start is True
# ============================================================
# TrimSegment
# ============================================================
class TestTrimSegment:
def test_from_dict_minimal(self):
seg = TrimSegment.from_dict({"start_time": 5.0, "duration": 10.0})
assert seg.segment_id # 有默认值
assert seg.trim.start_time == 5.0
assert seg.trim.duration == 10.0
assert seg.order == 0
def test_from_dict_with_segment_id(self):
seg = TrimSegment.from_dict({"segment_id": "seg_abc", "start_time": 1.0, "duration": 2.0})
assert seg.segment_id == "seg_abc"
def test_from_dict_with_order(self):
seg = TrimSegment.from_dict({"order": 3, "start_time": 1.0, "duration": 2.0})
assert seg.order == 3
def test_from_dict_default_order(self):
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=5)
assert seg.order == 5
def test_segment_dataclass(self):
seg = TrimSegment(
segment_id="s1",
trim=TrimConfig(start_time=0, duration=5),
order=1,
)
assert seg.segment_id == "s1"
assert seg.order == 1
assert seg.trim.duration == 5.0
# ============================================================
# build_video_trim_filter
# ============================================================
class TestBuildVideoTrimFilter:
def test_noop_passthrough(self):
trim = TrimConfig()
result = build_video_trim_filter("[0:v]", trim, "[out]")
assert result == "[0:v]setpts=PTS-STARTPTS[out]"
def test_with_start_and_duration(self):
trim = TrimConfig(start_time=5.5, duration=10.0).validate_and_resolve(60.0)
result = build_video_trim_filter("[0:v]", trim, "[v0]")
assert "trim=" in result
assert "start=5.500" in result
assert "duration=10.000" in result
assert "setpts=PTS-STARTPTS" in result
assert result.startswith("[0:v]")
assert result.endswith("[v0]")
def test_start_at_zero(self):
trim = TrimConfig(duration=5.0).validate_and_resolve(60.0)
result = build_video_trim_filter("[in]", trim, "[out]")
assert "trim=" in result
assert "start=" not in result # start=0 不写
assert "duration=5.000" in result
def test_trim_only_start(self):
# 只有 startduration 由素材推导
trim = TrimConfig(start_time=10.0).validate_and_resolve(30.0)
result = build_video_trim_filter("[0:v]", trim, "[out]")
assert "start=10.000" in result
assert "duration=20.000" in result
def test_comma_separated_filters(self):
trim = TrimConfig(start_time=1.0, duration=2.0).validate_and_resolve(10.0)
result = build_video_trim_filter("[in]", trim, "[out]")
assert "trim=" in result
# trim 和 setpts 用逗号分隔
assert "setpts=PTS-STARTPTS" in result
# ============================================================
# build_audio_trim_filter
# ============================================================
class TestBuildAudioTrimFilter:
def test_noop_passthrough(self):
trim = TrimConfig()
result = build_audio_trim_filter("[0:a]", trim, "[out]")
assert result == "[0:a]asetpts=PTS-STARTPTS[out]"
def test_with_start_and_duration(self):
trim = TrimConfig(start_time=3.0, duration=7.0).validate_and_resolve(60.0)
result = build_audio_trim_filter("[0:a]", trim, "[a0]")
assert "atrim=" in result
assert "start=3.000" in result
assert "duration=7.000" in result
assert "asetpts=PTS-STARTPTS" in result
assert result.startswith("[0:a]")
assert result.endswith("[a0]")
def test_start_at_zero(self):
trim = TrimConfig(duration=5.0).validate_and_resolve(60.0)
result = build_audio_trim_filter("[in]", trim, "[out]")
assert "atrim=" in result
assert "start=" not in result
assert "duration=5.000" in result
def test_uses_atrim_not_trim(self):
trim = TrimConfig(start_time=1.0, duration=2.0).validate_and_resolve(10.0)
result = build_audio_trim_filter("[in]", trim, "[out]")
assert "atrim=" in result
# 不应该有单独的 trim=(即视频的 trim)
# 注意:atrim= 包含 "trim=" 子串,所以检查完整的
# ============================================================
# resolve_segments
# ============================================================
class TestResolveSegments:
def test_empty_list(self):
result = resolve_segments([], 60.0)
assert result == []
def test_single_segment(self):
segs = [
TrimSegment(
segment_id="s1",
trim=TrimConfig(start_time=0, duration=10.0),
order=0,
)
]
result = resolve_segments(segs, 60.0)
assert len(result) == 1
assert result[0].segment_id == "s1"
assert result[0].trim.duration == 10.0
def test_invalid_segment_skipped(self):
segs = [
TrimSegment(
segment_id="good",
trim=TrimConfig(start_time=0, duration=10.0),
order=0,
),
TrimSegment(
segment_id="bad",
trim=TrimConfig(start_time=10, end_time=10), # 0时长
order=1,
),
]
result = resolve_segments(segs, 60.0)
assert len(result) == 1
assert result[0].segment_id == "good"
def test_sorted_by_order(self):
segs = [
TrimSegment(
segment_id="third",
trim=TrimConfig(duration=1.0),
order=2,
),
TrimSegment(
segment_id="first",
trim=TrimConfig(duration=1.0),
order=0,
),
TrimSegment(
segment_id="second",
trim=TrimConfig(duration=1.0),
order=1,
),
]
result = resolve_segments(segs, 60.0)
assert len(result) == 3
assert [s.segment_id for s in result] == ["first", "second", "third"]
def test_negative_order_uses_index(self):
segs = [
TrimSegment(
segment_id="s0",
trim=TrimConfig(duration=1.0),
order=-1,
),
TrimSegment(
segment_id="s1",
trim=TrimConfig(duration=1.0),
order=-1,
),
]
result = resolve_segments(segs, 60.0)
assert len(result) == 2
# order 为负时使用索引 i,所以 s0 order=0, s1 order=1
assert result[0].segment_id == "s0"
assert result[1].segment_id == "s1"
def test_respects_asset_duration(self):
segs = [
TrimSegment(
segment_id="s1",
trim=TrimConfig(start_time=50, duration=20.0), # 超出60s素材
order=0,
)
]
result = resolve_segments(segs, 60.0)
assert len(result) == 1
assert result[0].trim.end_time == 60.0
assert result[0].trim.duration == 10.0
# ============================================================
# parse_segments_from_config
# ============================================================
class TestParseSegmentsFromConfig:
def test_none_returns_empty(self):
assert parse_segments_from_config(None) == []
def test_empty_dict_returns_empty(self):
assert parse_segments_from_config({}) == []
def test_trim_segments_list(self):
config = {
"trim_segments": [
{"segment_id": "s1", "start_time": 0, "duration": 5, "order": 0},
{"segment_id": "s2", "start_time": 10, "duration": 5, "order": 1},
]
}
result = parse_segments_from_config(config)
assert len(result) == 2
assert result[0].segment_id == "s1"
assert result[1].segment_id == "s2"
def test_trim_segments_empty_list(self):
config = {"trim_segments": []}
result = parse_segments_from_config(config)
assert result == []
def test_trim_segments_not_list_ignored(self):
config = {"trim_segments": "not_a_list"}
result = parse_segments_from_config(config)
assert result == []
def test_single_trim_start(self):
config = {"trim_start": 5.0}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].segment_id == "main"
assert result[0].trim.start_time == 5.0
def test_single_trim_end(self):
config = {"trim_end": 30.0}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].trim.end_time == 30.0
def test_single_trim_duration(self):
config = {"trim_duration": 10.0}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].trim.duration == 10.0
def test_single_full(self):
config = {"trim_start": 2.0, "trim_end": 8.0, "trim_duration": 6.0}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].trim.start_time == 2.0
def test_segments_priority_over_single(self):
# 同时有 trim_segments 和单段字段,优先多段
config = {
"trim_segments": [
{"segment_id": "s1", "start_time": 0, "duration": 5},
],
"trim_start": 10.0,
}
result = parse_segments_from_config(config)
assert len(result) == 1
assert result[0].segment_id == "s1"
def test_segment_dict_with_non_dict_entries_ignored(self):
config = {
"trim_segments": [
{"segment_id": "s1", "start_time": 0, "duration": 5},
"not_a_dict",
None,
]
}
result = parse_segments_from_config(config)
assert len(result) == 1
# ============================================================
# extract_trim_from_clip_config
# ============================================================
class TestExtractTrimFromClipConfig:
def test_none_returns_none(self):
assert extract_trim_from_clip_config(None) is None
def test_empty_dict_returns_none(self):
assert extract_trim_from_clip_config({}) is None
def test_trim_subdict(self):
config = {"trim": {"start_time": 5.0, "duration": 10.0}}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 5.0
assert result.duration == 10.0
def test_trim_subdict_empty(self):
config = {"trim": {}}
result = extract_trim_from_clip_config(config)
assert result is None
def test_flat_trim_start(self):
config = {"trim_start": 5.0, "trim_duration": 10.0}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 5.0
assert result.duration == 10.0
def test_flat_trim_end_only(self):
config = {"trim_end": 20.0}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.end_time == 20.0
def test_trim_subdict_priority_over_flat(self):
# trim子字典优先
config = {
"trim": {"start_time": 1.0, "duration": 2.0},
"trim_start": 10.0,
"trim_duration": 20.0,
}
result = extract_trim_from_clip_config(config)
assert result is not None
assert result.start_time == 1.0
assert result.duration == 2.0
def test_no_trim_fields(self):
config = {"other_field": "value"}
assert extract_trim_from_clip_config(config) is None
def test_trim_not_dict_ignored(self):
config = {"trim": "not_a_dict"}
# trim 不是 dict,继续看扁平字段 → 没有 → None
result = extract_trim_from_clip_config(config)
assert result is None