"""trim_config 裁剪配置领域模型单元测试.""" from __future__ import annotations 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: """TrimConfig.from_dict 测试.""" def test_none_returns_none(self): """None返回None.""" assert TrimConfig.from_dict(None) is None def test_empty_dict_returns_none(self): """空dict返回None.""" assert TrimConfig.from_dict({}) is None def test_all_zero_returns_none(self): """全0返回None(不裁剪).""" assert TrimConfig.from_dict({"start_time": 0, "end_time": 0, "duration": 0}) is None def test_start_and_end(self): """start + end.""" result = TrimConfig.from_dict({"start_time": 5, "end_time": 10}) assert result is not None assert result.start_time == 5.0 assert result.end_time == 10.0 def test_start_and_duration(self): """start + duration.""" result = TrimConfig.from_dict({"start_time": 2, "duration": 5}) assert result is not None assert result.start_time == 2.0 assert result.duration == 5.0 def test_end_and_duration(self): """end + duration.""" result = TrimConfig.from_dict({"end_time": 10, "duration": 3}) assert result is not None assert result.end_time == 10.0 assert result.duration == 3.0 def test_only_start_returns_config(self): """只有start_time也返回有效配置(从start取到末尾语义).""" result = TrimConfig.from_dict({"start_time": 3}) assert result is not None assert result.start_time == 3.0 def test_only_duration_returns_config(self): """只有duration也返回(从开头取duration).""" result = TrimConfig.from_dict({"duration": 5}) assert result is not None assert result.duration == 5.0 def test_only_end_returns_config(self): """只有end_time也返回.""" result = TrimConfig.from_dict({"end_time": 8}) assert result is not None assert result.end_time == 8.0 def test_string_values(self): """字符串值能正确解析.""" result = TrimConfig.from_dict({"start_time": "2.5", "duration": "3"}) assert result is not None assert result.start_time == 2.5 assert result.duration == 3.0 def test_none_values_treated_as_zero(self): """None值当作0处理.""" result = TrimConfig.from_dict({"start_time": None, "duration": 5}) assert result is not None assert result.start_time == 0.0 assert result.duration == 5.0 def test_false_values_treated_as_zero(self): """0/false值当作0处理.""" result = TrimConfig.from_dict({"start_time": 0, "duration": 0}) assert result is None def test_all_three_params(self): """三个参数都给了.""" result = TrimConfig.from_dict({"start_time": 1, "end_time": 6, "duration": 5}) assert result is not None assert result.start_time == 1.0 assert result.end_time == 6.0 assert result.duration == 5.0 # ── TrimConfig.validate_and_resolve 测试 ──────────────────────────────────── class TestTrimConfigValidateAndResolve: """validate_and_resolve 三选二推导 + 边界钳制测试.""" # 基础三选二推导 def test_start_and_end(self): """start + end → 推导duration.""" cfg = TrimConfig(start_time=5, end_time=15) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 5.0 assert result.end_time == 15.0 assert result.duration == 10.0 def test_start_and_duration(self): """start + duration → 推导end.""" cfg = TrimConfig(start_time=3, duration=7) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 3.0 assert result.duration == 7.0 assert result.end_time == 10.0 def test_end_and_duration(self): """end + duration → 推导start.""" cfg = TrimConfig(end_time=20, duration=5) result = cfg.validate_and_resolve(asset_duration=30) assert result.end_time == 20.0 assert result.duration == 5.0 assert result.start_time == 15.0 def test_end_minus_duration_negative(self): """end + duration 但算出start<0 → 钳制到0重新计算.""" cfg = TrimConfig(end_time=3, duration=10) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 0.0 assert result.end_time == 3.0 assert result.duration == 3.0 def test_only_start_takes_to_end(self): """只有start → 取到素材末尾.""" cfg = TrimConfig(start_time=5) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 5.0 assert result.end_time == 30.0 assert result.duration == 25.0 def test_only_end_takes_from_start(self): """只有end → 从开头取到end.""" cfg = TrimConfig(end_time=10) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 0.0 assert result.end_time == 10.0 assert result.duration == 10.0 def test_only_duration(self): """只有duration → 从开头取duration.""" cfg = TrimConfig(duration=8) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 0.0 assert result.end_time == 8.0 assert result.duration == 8.0 def test_all_zero_noop(self): """全0 → noop不裁剪.""" cfg = TrimConfig() result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 0.0 assert result.duration == 0.0 assert result.is_noop # 边界钳制 def test_start_negative_clamped(self): """start为负 → 钳制到0.""" cfg = TrimConfig(start_time=-5, duration=10) result = cfg.validate_and_resolve(asset_duration=30) assert result.start_time == 0.0 assert result.duration == 10.0 assert result.end_time == 10.0 def test_end_exceeds_asset_duration(self): """end超过素材时长 → 钳制.""" cfg = TrimConfig(start_time=5, end_time=50) result = cfg.validate_and_resolve(asset_duration=30) assert result.end_time == 30.0 assert result.duration == 25.0 def test_start_exceeds_asset_duration(self): """start超过素材时长 → 移到末尾取最小片段.""" cfg = TrimConfig(start_time=40, duration=5) result = cfg.validate_and_resolve(asset_duration=30) assert result.end_time == 30.0 assert result.start_time >= 0 assert result.duration >= 0 def test_start_equals_end_invalid(self): """start >= end → 无效(duration=0).""" cfg = TrimConfig(start_time=10, end_time=10) result = cfg.validate_and_resolve(asset_duration=30) assert result.duration == 0.0 assert result.is_valid is False def test_start_greater_than_end(self): """start > end → 无效.""" cfg = TrimConfig(start_time=15, end_time=10) result = cfg.validate_and_resolve(asset_duration=30) assert result.duration == 0.0 assert result.is_valid is False def test_zero_asset_duration(self): """素材时长为0 → 返回noop.""" cfg = TrimConfig(start_time=5, duration=10) result = cfg.validate_and_resolve(asset_duration=0) assert result.start_time == 0.0 assert result.duration == 0.0 assert result.is_noop def test_negative_asset_duration(self): """素材时长为负 → 返回noop.""" cfg = TrimConfig(start_time=1, duration=2) result = cfg.validate_and_resolve(asset_duration=-5) assert result.is_noop # duration 边界 def test_duration_preserved_exactly(self): """精确时长保持.""" cfg = TrimConfig(start_time=1.234, duration=2.567) result = cfg.validate_and_resolve(asset_duration=10) assert abs(result.duration - 2.567) < 0.001 assert abs(result.start_time - 1.234) < 0.001 def test_duration_never_negative(self): """duration永远不为负.""" cfg = TrimConfig(start_time=10, end_time=5) result = cfg.validate_and_resolve(asset_duration=30) assert result.duration >= 0 # ── TrimConfig 属性测试 ───────────────────────────────────────────────────── class TestTrimConfigProperties: """TrimConfig 属性测试.""" def test_is_valid_valid_trim(self): """有效裁剪.""" cfg = TrimConfig(start_time=0, end_time=0, duration=5) assert cfg.is_valid is True def test_is_valid_zero_duration(self): """duration=0无效.""" cfg = TrimConfig(duration=0) assert cfg.is_valid is False def test_is_valid_min_threshold(self): """刚好等于最小阈值也算有效.""" cfg = TrimConfig(duration=MIN_TRIM_DURATION) assert cfg.is_valid is True def test_is_valid_below_min(self): """低于最小阈值无效.""" cfg = TrimConfig(duration=MIN_TRIM_DURATION / 2) assert cfg.is_valid is False def test_is_noop_true(self): """从0开始且duration=0是noop.""" cfg = TrimConfig(start_time=0, duration=0) assert cfg.is_noop is True def test_is_noop_false_has_start(self): """有start不是noop.""" cfg = TrimConfig(start_time=5, duration=0) assert cfg.is_noop is False def test_is_noop_false_has_duration(self): """有duration不是noop.""" cfg = TrimConfig(start_time=0, duration=1) assert cfg.is_noop is False def test_trim_from_start_true(self): """start=0是从开头裁剪.""" cfg = TrimConfig(start_time=0, duration=5) assert cfg.trim_from_start is True def test_trim_from_start_false(self): """start>0不是从开头裁剪.""" cfg = TrimConfig(start_time=2, duration=5) assert cfg.trim_from_start is False def test_trim_from_start_negative_treated_as_zero(self): """start<0也认为从开头.""" cfg = TrimConfig(start_time=-1, duration=5) assert cfg.trim_from_start is True # ── TrimSegment 测试 ───────────────────────────────────────────────────────── class TestTrimSegment: """TrimSegment 测试.""" def test_from_dict_basic(self): """基础构造.""" data = {"segment_id": "seg1", "start_time": 1, "end_time": 5, "order": 2} seg = TrimSegment.from_dict(data) assert seg.segment_id == "seg1" assert seg.order == 2 assert seg.trim.start_time == 1.0 assert seg.trim.end_time == 5.0 def test_from_dict_default_order(self): """缺order使用默认值.""" data = {"segment_id": "s1", "start_time": 0, "duration": 3} seg = TrimSegment.from_dict(data, default_order=5) assert seg.order == 5 def test_from_dict_missing_segment_id(self): """缺segment_id用默认名.""" data = {"start_time": 0, "duration": 2} seg = TrimSegment.from_dict(data, default_order=3) assert seg.segment_id == "seg_3" def test_from_dict_duration(self): """duration正确传递.""" data = {"segment_id": "s1", "duration": 10} seg = TrimSegment.from_dict(data) assert seg.trim.duration == 10.0 # ── build_video_trim_filter 测试 ──────────────────────────────────────────── class TestBuildVideoTrimFilter: """build_video_trim_filter 视频滤镜构建测试.""" def test_noop_filter(self): """noop时只有setpts.""" cfg = TrimConfig() result = build_video_trim_filter("[0:v]", cfg, "[vout]") assert "trim=" not in result assert "setpts=PTS-STARTPTS" in result assert result.startswith("[0:v]") assert result.endswith("[vout]") def test_start_and_duration(self): """start + duration 完整滤镜.""" cfg = TrimConfig(start_time=10, end_time=15, duration=5) result = build_video_trim_filter("[0:v]", cfg, "[v0]") assert "trim=start=10.000:duration=5.000" in result assert "setpts=PTS-STARTPTS" in result assert result.startswith("[0:v]") assert result.endswith("[v0]") def test_only_start(self): """只有start(取到末尾的情况resolve后也有duration).""" cfg = TrimConfig(start_time=5, end_time=30, duration=25) result = build_video_trim_filter("[1:v]", cfg, "[v1]") assert "start=5.000" in result assert "duration=25.000" in result def test_only_duration_from_start(self): """从开头裁剪duration.""" cfg = TrimConfig(start_time=0, end_time=3, duration=3) result = build_video_trim_filter("[0:v]", cfg, "[out]") assert "trim=duration=3.000" in result or "trim=start=0" in result # start=0 不写,只有duration assert "start=0" not in result def test_preserves_input_output_labels(self): """保持输入输出标签.""" cfg = TrimConfig(start_time=1, duration=2) result = build_video_trim_filter("[in_label]", cfg, "[out_label]") assert result.startswith("[in_label]") assert result.endswith("[out_label]") def test_three_decimal_precision(self): """三位小数精度.""" cfg = TrimConfig(start_time=1.234, duration=2.678) result = build_video_trim_filter("[0:v]", cfg, "[v]") assert "start=1.234" in result assert "duration=2.678" in result # ── build_audio_trim_filter 测试 ──────────────────────────────────────────── class TestBuildAudioTrimFilter: """build_audio_trim_filter 音频滤镜构建测试.""" def test_noop_filter(self): """noop时只有asetpts.""" cfg = TrimConfig() result = build_audio_trim_filter("[0:a]", cfg, "[aout]") assert "atrim=" not in result assert "asetpts=PTS-STARTPTS" in result assert result.startswith("[0:a]") assert result.endswith("[aout]") def test_start_and_duration(self): """start + duration 完整滤镜.""" cfg = TrimConfig(start_time=5, duration=3) result = build_audio_trim_filter("[0:a]", cfg, "[a0]") assert "atrim=start=5.000:duration=3.000" in result assert "asetpts=PTS-STARTPTS" in result def test_only_duration(self): """只有duration(start=0时不写start参数).""" cfg = TrimConfig(start_time=0, duration=4) result = build_audio_trim_filter("[0:a]", cfg, "[a0]") assert "atrim=duration=4.000" in result def test_uses_atrim_not_trim(self): """用atrim不是trim.""" cfg = TrimConfig(start_time=1, duration=2) result = build_audio_trim_filter("[0:a]", cfg, "[a]") assert "atrim=" in result assert ",trim=" not in result # ── resolve_segments 测试 ──────────────────────────────────────────────────── class TestResolveSegments: """resolve_segments 多段裁剪解析测试.""" def test_empty_list(self): """空列表返回空.""" result = resolve_segments([], asset_duration=30) assert result == [] def test_single_segment(self): """单段解析.""" seg = TrimSegment( segment_id="s1", trim=TrimConfig(start_time=0, duration=5), order=0, ) result = resolve_segments([seg], asset_duration=30) assert len(result) == 1 assert result[0].segment_id == "s1" assert result[0].trim.duration == 5.0 def test_multiple_segments_sorted(self): """多段按order排序.""" segs = [ TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=2), TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=0), TrimSegment(segment_id="s3", trim=TrimConfig(duration=1), order=1), ] result = resolve_segments(segs, asset_duration=30) assert len(result) == 3 assert result[0].segment_id == "s2" assert result[1].segment_id == "s3" assert result[2].segment_id == "s1" def test_filter_invalid_segments(self): """过滤无效段.""" segs = [ TrimSegment(segment_id="valid", trim=TrimConfig(duration=5), order=0), TrimSegment(segment_id="invalid", trim=TrimConfig(duration=0), order=1), ] result = resolve_segments(segs, asset_duration=30) assert len(result) == 1 assert result[0].segment_id == "valid" def test_negative_order_uses_index(self): """order为负时使用索引.""" segs = [ TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=-1), TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=-1), ] result = resolve_segments(segs, asset_duration=30) assert len(result) == 2 # order用各自的index值(0, 1) def test_resolves_with_asset_duration(self): """用素材时长做边界钳制.""" seg = TrimSegment( segment_id="s1", trim=TrimConfig(start_time=0, duration=50), # 超过素材时长 order=0, ) result = resolve_segments([seg], asset_duration=30) assert len(result) == 1 assert result[0].trim.end_time == 30.0 assert result[0].trim.duration == 30.0 # ── parse_segments_from_config 测试 ───────────────────────────────────────── class TestParseSegmentsFromConfig: """parse_segments_from_config 测试.""" def test_none_config(self): """None返回空.""" assert parse_segments_from_config(None) == [] def test_empty_config(self): """空dict返回空.""" assert parse_segments_from_config({}) == [] def test_trim_segments_list(self): """多段配置解析.""" config = { "trim_segments": [ {"segment_id": "s1", "start_time": 0, "duration": 3, "order": 0}, {"segment_id": "s2", "start_time": 5, "duration": 4, "order": 1}, ] } result = parse_segments_from_config(config) assert len(result) == 2 assert result[0].segment_id == "s1" assert result[0].trim.duration == 3.0 assert result[1].segment_id == "s2" assert result[1].trim.start_time == 5.0 def test_trim_segments_empty_list(self): """空segments列表 + 无单段 → 空.""" config = {"trim_segments": []} assert parse_segments_from_config(config) == [] def test_trim_segments_not_list(self): """segments不是list → 回退到单段(如果有).""" config = {"trim_segments": "not_a_list"} assert parse_segments_from_config(config) == [] def test_single_trim_start(self): """单段:trim_start.""" config = {"trim_start": 2, "trim_duration": 5} result = parse_segments_from_config(config) assert len(result) == 1 assert result[0].segment_id == "main" assert result[0].trim.start_time == 2.0 assert result[0].trim.duration == 5.0 def test_single_trim_end(self): """单段:trim_end.""" config = {"trim_end": 10} result = parse_segments_from_config(config) assert len(result) == 1 assert result[0].trim.end_time == 10.0 def test_segments_take_priority_over_single(self): """多段配置优先于单段.""" config = { "trim_segments": [{"segment_id": "s1", "start_time": 0, "duration": 2}], "trim_start": 5, "trim_duration": 3, } result = parse_segments_from_config(config) assert len(result) == 1 assert result[0].segment_id == "s1" # 多段优先 def test_segments_filter_non_dict(self): """过滤非dict元素.""" config = { "trim_segments": [ {"segment_id": "s1", "duration": 2}, "not_a_dict", None, 123, ] } result = parse_segments_from_config(config) assert len(result) == 1 assert result[0].segment_id == "s1" # ── extract_trim_from_clip_config 测试 ────────────────────────────────────── class TestExtractTrimFromClipConfig: """extract_trim_from_clip_config 测试.""" def test_none_config(self): """None返回None.""" assert extract_trim_from_clip_config(None) is None def test_empty_config(self): """空dict返回None.""" assert extract_trim_from_clip_config({}) is None def test_trim_subdict(self): """trim子字典提取.""" config = {"trim": {"start_time": 2, "duration": 5}} result = extract_trim_from_clip_config(config) assert result is not None assert result.start_time == 2.0 assert result.duration == 5.0 def test_trim_subdict_empty(self): """trim子字典为空 → None.""" config = {"trim": {}} assert extract_trim_from_clip_config(config) is None def test_flat_trim_fields(self): """扁平trim_字段.""" config = {"trim_start": 1, "trim_end": 6} result = extract_trim_from_clip_config(config) assert result is not None assert result.start_time == 1.0 assert result.end_time == 6.0 def test_flat_trim_duration(self): """扁平trim_duration.""" config = {"trim_duration": 10} result = extract_trim_from_clip_config(config) assert result is not None assert result.duration == 10.0 def test_trim_subdict_priority(self): """trim子字典优先于扁平字段.""" config = { "trim": {"start_time": 1, "duration": 2}, "trim_start": 10, "trim_duration": 20, } 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_trim_not_dict_ignored(self): """trim不是dict时忽略(回退到扁平字段).""" config = {"trim": "not_a_dict", "trim_duration": 5} result = extract_trim_from_clip_config(config) assert result is not None assert result.duration == 5.0 def test_no_trim_fields(self): """无裁剪字段返回None.""" config = {"other_field": "value", "font_size": 12} assert extract_trim_from_clip_config(config) is None