"""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