424 lines
15 KiB
Python
Executable File
424 lines
15 KiB
Python
Executable File
"""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:
|
|
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_valid(self):
|
|
cfg = TrimConfig.from_dict({"start_time": 5.0})
|
|
assert cfg is not None
|
|
assert cfg.start_time == 5.0
|
|
assert cfg.end_time == 0
|
|
assert cfg.duration == 0
|
|
|
|
def test_duration_only_valid(self):
|
|
cfg = TrimConfig.from_dict({"duration": 10.0})
|
|
assert cfg is not None
|
|
assert cfg.duration == 10.0
|
|
assert cfg.start_time == 0
|
|
|
|
def test_start_and_duration(self):
|
|
cfg = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
|
|
assert cfg is not None
|
|
assert cfg.start_time == 2.0
|
|
assert cfg.duration == 5.0
|
|
|
|
def test_start_and_end(self):
|
|
cfg = TrimConfig.from_dict({"start_time": 1.0, "end_time": 5.0})
|
|
assert cfg is not None
|
|
assert cfg.start_time == 1.0
|
|
assert cfg.end_time == 5.0
|
|
|
|
def test_end_only(self):
|
|
cfg = TrimConfig.from_dict({"end_time": 8.0})
|
|
assert cfg is not None
|
|
assert cfg.end_time == 8.0
|
|
|
|
def test_string_values_coerced(self):
|
|
cfg = TrimConfig.from_dict({"start_time": "3.5", "duration": "2.0"})
|
|
assert cfg is not None
|
|
assert cfg.start_time == 3.5
|
|
assert cfg.duration == 2.0
|
|
|
|
def test_falsy_values_treated_as_zero(self):
|
|
cfg = TrimConfig.from_dict({"start_time": None, "duration": None})
|
|
assert cfg is None
|
|
|
|
def test_default_values(self):
|
|
cfg = TrimConfig()
|
|
assert cfg.start_time == 0.0
|
|
assert cfg.end_time == 0.0
|
|
assert cfg.duration == 0.0
|
|
|
|
|
|
# ── validate_and_resolve 测试 ─────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateAndResolve:
|
|
def test_start_and_end_resolves_duration(self):
|
|
cfg = TrimConfig(start_time=2.0, end_time=7.0)
|
|
resolved = cfg.validate_and_resolve(100.0)
|
|
assert resolved.start_time == 2.0
|
|
assert resolved.end_time == 7.0
|
|
assert resolved.duration == 5.0
|
|
|
|
def test_start_and_duration_resolves_end(self):
|
|
cfg = TrimConfig(start_time=3.0, duration=10.0)
|
|
resolved = cfg.validate_and_resolve(100.0)
|
|
assert resolved.start_time == 3.0
|
|
assert resolved.duration == 10.0
|
|
assert resolved.end_time == 13.0
|
|
|
|
def test_end_and_duration_resolves_start(self):
|
|
cfg = TrimConfig(end_time=15.0, duration=5.0)
|
|
resolved = cfg.validate_and_resolve(100.0)
|
|
assert resolved.end_time == 15.0
|
|
assert resolved.duration == 5.0
|
|
assert resolved.start_time == 10.0
|
|
|
|
def test_start_only_takes_to_end(self):
|
|
cfg = TrimConfig(start_time=5.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time == 5.0
|
|
assert resolved.end_time == 30.0
|
|
assert resolved.duration == 25.0
|
|
|
|
def test_end_only_takes_from_start(self):
|
|
cfg = TrimConfig(end_time=8.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time == 0.0
|
|
assert resolved.end_time == 8.0
|
|
assert resolved.duration == 8.0
|
|
|
|
def test_duration_only_from_zero(self):
|
|
cfg = TrimConfig(duration=10.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time == 0.0
|
|
assert resolved.duration == 10.0
|
|
assert resolved.end_time == 10.0
|
|
|
|
def test_negative_start_clamped(self):
|
|
cfg = TrimConfig(start_time=-5.0, duration=10.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time == 0.0
|
|
|
|
def test_end_exceeds_asset_clamped(self):
|
|
cfg = TrimConfig(start_time=5.0, duration=50.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.end_time == 30.0
|
|
assert resolved.duration == 25.0
|
|
|
|
def test_start_exceeds_asset_clamped(self):
|
|
cfg = TrimConfig(start_time=50.0, duration=10.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time < 30.0
|
|
assert resolved.end_time == 30.0
|
|
|
|
def test_end_before_start_invalid(self):
|
|
cfg = TrimConfig(start_time=10.0, end_time=5.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.duration == 0.0
|
|
assert resolved.is_valid is False
|
|
|
|
def test_zero_asset_duration(self):
|
|
cfg = TrimConfig(start_time=1.0, duration=5.0)
|
|
resolved = cfg.validate_and_resolve(0.0)
|
|
assert resolved.is_noop
|
|
|
|
def test_negative_asset_duration(self):
|
|
cfg = TrimConfig(start_time=1.0, duration=5.0)
|
|
resolved = cfg.validate_and_resolve(-1.0)
|
|
assert resolved.is_noop
|
|
|
|
def test_end_and_duration_with_negative_start(self):
|
|
cfg = TrimConfig(end_time=3.0, duration=10.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.start_time == 0.0
|
|
assert resolved.end_time == 3.0
|
|
assert resolved.duration == 3.0
|
|
|
|
def test_all_three_params_uses_start_duration(self):
|
|
cfg = TrimConfig(start_time=2.0, end_time=8.0, duration=3.0)
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
# 有 start + end 时应该用 start+end 推导 duration
|
|
assert resolved.start_time == 2.0
|
|
assert resolved.end_time == 8.0
|
|
assert resolved.duration == 6.0
|
|
|
|
def test_empty_config_returns_noop(self):
|
|
cfg = TrimConfig()
|
|
resolved = cfg.validate_and_resolve(30.0)
|
|
assert resolved.is_noop
|
|
|
|
|
|
# ── is_valid / is_noop / trim_from_start 测试 ─────────────────────────────
|
|
|
|
|
|
class TestProperties:
|
|
def test_is_valid_true_for_normal(self):
|
|
cfg = TrimConfig(start_time=0, end_time=0, duration=5.0)
|
|
assert cfg.is_valid is True
|
|
|
|
def test_is_valid_false_for_zero(self):
|
|
cfg = TrimConfig(duration=0.0)
|
|
assert cfg.is_valid is False
|
|
|
|
def test_is_valid_false_for_very_small(self):
|
|
cfg = TrimConfig(duration=0.01)
|
|
assert cfg.is_valid is False
|
|
|
|
def test_is_valid_true_at_boundary(self):
|
|
cfg = TrimConfig(duration=MIN_TRIM_DURATION)
|
|
assert cfg.is_valid is True
|
|
|
|
def test_is_noop_true_for_default(self):
|
|
cfg = TrimConfig()
|
|
assert cfg.is_noop is True
|
|
|
|
def test_is_noop_false_with_start(self):
|
|
cfg = TrimConfig(start_time=1.0)
|
|
assert cfg.is_noop is False
|
|
|
|
def test_is_noop_false_with_duration(self):
|
|
cfg = TrimConfig(duration=1.0)
|
|
assert cfg.is_noop is False
|
|
|
|
def test_trim_from_start_true(self):
|
|
cfg = TrimConfig(start_time=0.0, duration=5.0)
|
|
assert cfg.trim_from_start is True
|
|
|
|
def test_trim_from_start_false(self):
|
|
cfg = TrimConfig(start_time=2.0, duration=5.0)
|
|
assert cfg.trim_from_start is False
|
|
|
|
|
|
# ── TrimSegment 测试 ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestTrimSegment:
|
|
def test_from_dict_basic(self):
|
|
seg = TrimSegment.from_dict({"segment_id": "s1", "start_time": 1.0, "duration": 3.0})
|
|
assert seg.segment_id == "s1"
|
|
assert seg.trim.start_time == 1.0
|
|
assert seg.trim.duration == 3.0
|
|
assert seg.order == 0
|
|
|
|
def test_from_dict_with_order(self):
|
|
seg = TrimSegment.from_dict({"segment_id": "s2", "start_time": 0, "end_time": 5.0, "order": 2})
|
|
assert seg.order == 2
|
|
|
|
def test_from_dict_default_order(self):
|
|
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=5)
|
|
assert seg.order == 5
|
|
|
|
def test_from_dict_default_segment_id(self):
|
|
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=3)
|
|
assert seg.segment_id == "seg_3"
|
|
|
|
|
|
# ── build_video_trim_filter 测试 ───────────────────────────────────────────
|
|
|
|
|
|
class TestBuildVideoTrimFilter:
|
|
def test_noop_returns_setpts(self):
|
|
cfg = TrimConfig()
|
|
result = build_video_trim_filter("[0:v]", cfg, "[v]")
|
|
assert "setpts=PTS-STARTPTS" in result
|
|
assert "trim=" not in result
|
|
assert "[0:v]" in result
|
|
assert "[v]" in result
|
|
|
|
def test_with_start_and_duration(self):
|
|
cfg = TrimConfig(start_time=5.0, end_time=10.0, duration=5.0)
|
|
result = build_video_trim_filter("[0:v]", cfg, "[out]")
|
|
assert "trim=" in result
|
|
assert "start=5.000" in result
|
|
assert "duration=5.000" in result
|
|
assert "setpts=PTS-STARTPTS" in result
|
|
|
|
def test_contains_input_and_output_labels(self):
|
|
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
|
result = build_video_trim_filter("[in_v]", cfg, "[out_v]")
|
|
assert "[in_v]" in result
|
|
assert "[out_v]" in result
|
|
|
|
def test_duration_only(self):
|
|
cfg = TrimConfig(duration=3.5)
|
|
result = build_video_trim_filter("[0:v]", cfg, "[v]")
|
|
assert "duration=3.500" in result
|
|
assert "start=" not in result
|
|
|
|
|
|
# ── build_audio_trim_filter 测试 ───────────────────────────────────────────
|
|
|
|
|
|
class TestBuildAudioTrimFilter:
|
|
def test_noop_returns_asetpts(self):
|
|
cfg = TrimConfig()
|
|
result = build_audio_trim_filter("[0:a]", cfg, "[a]")
|
|
assert "asetpts=PTS-STARTPTS" in result
|
|
assert "atrim=" not in result
|
|
|
|
def test_with_start_and_duration(self):
|
|
cfg = TrimConfig(start_time=2.0, end_time=7.0, duration=5.0)
|
|
result = build_audio_trim_filter("[0:a]", cfg, "[out]")
|
|
assert "atrim=" in result
|
|
assert "start=2.000" in result
|
|
assert "duration=5.000" in result
|
|
assert "asetpts=PTS-STARTPTS" in result
|
|
|
|
def test_contains_input_and_output_labels(self):
|
|
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
|
result = build_audio_trim_filter("[in_a]", cfg, "[out_a]")
|
|
assert "[in_a]" in result
|
|
assert "[out_a]" in result
|
|
|
|
|
|
# ── resolve_segments 测试 ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestResolveSegments:
|
|
def test_empty_list_returns_empty(self):
|
|
result = resolve_segments([], 30.0)
|
|
assert result == []
|
|
|
|
def test_single_segment(self):
|
|
segs = [TrimSegment(segment_id="s1", trim=TrimConfig(start_time=1.0, duration=5.0), order=0)]
|
|
result = resolve_segments(segs, 30.0)
|
|
assert len(result) == 1
|
|
assert result[0].segment_id == "s1"
|
|
assert result[0].trim.duration == 5.0
|
|
|
|
def test_invalid_segment_filters_out(self):
|
|
segs = [
|
|
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0, duration=5.0), order=0),
|
|
TrimSegment(
|
|
segment_id="bad",
|
|
trim=TrimConfig(start_time=5.0, end_time=5.0), # end == start → duration 0
|
|
order=1,
|
|
),
|
|
]
|
|
result = resolve_segments(segs, 30.0)
|
|
assert len(result) == 1
|
|
assert result[0].segment_id == "good"
|
|
|
|
def test_sorted_by_order(self):
|
|
segs = [
|
|
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=5.0, duration=3.0), order=2),
|
|
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0, duration=3.0), order=1),
|
|
TrimSegment(segment_id="s0", trim=TrimConfig(start_time=10.0, duration=3.0), order=0),
|
|
]
|
|
result = resolve_segments(segs, 30.0)
|
|
assert [s.segment_id for s in result] == ["s0", "s1", "s2"]
|
|
|
|
def test_negative_order_uses_index(self):
|
|
segs = [
|
|
TrimSegment(segment_id="s0", trim=TrimConfig(duration=3.0), order=-1),
|
|
]
|
|
result = resolve_segments(segs, 30.0)
|
|
assert len(result) == 1
|
|
assert result[0].order == 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": 3.0, "order": 0},
|
|
{"segment_id": "s2", "start_time": 5.0, "duration": 2.0, "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_skips_non_dict(self):
|
|
config = {"trim_segments": [{"segment_id": "s1", "duration": 3.0}, "invalid", None]}
|
|
result = parse_segments_from_config(config)
|
|
assert len(result) == 1
|
|
|
|
def test_single_trim_compat(self):
|
|
config = {"trim_start": 1.0, "trim_duration": 5.0}
|
|
result = parse_segments_from_config(config)
|
|
assert len(result) == 1
|
|
assert result[0].segment_id == "main"
|
|
assert result[0].trim.start_time == 1.0
|
|
assert result[0].trim.duration == 5.0
|
|
|
|
def test_no_trim_fields_returns_empty(self):
|
|
config = {"other_field": "value"}
|
|
assert parse_segments_from_config(config) == []
|
|
|
|
|
|
# ── 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": 2.0, "duration": 5.0}}
|
|
cfg = extract_trim_from_clip_config(config)
|
|
assert cfg is not None
|
|
assert cfg.start_time == 2.0
|
|
assert cfg.duration == 5.0
|
|
|
|
def test_flat_trim_fields(self):
|
|
config = {"trim_start": 1.0, "trim_end": 6.0}
|
|
cfg = extract_trim_from_clip_config(config)
|
|
assert cfg is not None
|
|
assert cfg.start_time == 1.0
|
|
assert cfg.end_time == 6.0
|
|
|
|
def test_trim_subdict_empty(self):
|
|
config = {"trim": {}}
|
|
assert extract_trim_from_clip_config(config) is None
|
|
|
|
def test_no_trim_fields(self):
|
|
config = {"foo": "bar"}
|
|
assert extract_trim_from_clip_config(config) is None
|
|
|
|
def test_flat_trim_duration_only(self):
|
|
config = {"trim_duration": 10.0}
|
|
cfg = extract_trim_from_clip_config(config)
|
|
assert cfg is not None
|
|
assert cfg.duration == 10.0
|