diff --git a/tests/unit/domain/test_trim_config.py b/tests/unit/domain/test_trim_config.py new file mode 100755 index 000000000..9e29d43fe --- /dev/null +++ b/tests/unit/domain/test_trim_config.py @@ -0,0 +1,612 @@ +"""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 → 钳制到0,duration=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): + # 只有 start,duration 由素材推导 + 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