Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia cbd8d9df5c test(wave165): trim_config裁剪配置 +78测
CI/CD Pipeline / Check if frontend-only change (pull_request) Waiting to run
CI/CD Pipeline / Validate - Code Quality (pull_request) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Waiting to run
CI/CD Pipeline / Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Waiting to run
CI/CD Pipeline / PR Build Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (pull_request) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
PR Automation / Auto Approve on CI Green (pull_request) Waiting to run
PR Automation / Auto Merge on CI Green + Approved (pull_request) Waiting to run
Preview Deploy / Deploy Preview Environment (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) CI runner不可用,手动设置
AI Code Review / AI Code Review (pull_request) CI runner不可用,手动设置
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
- TrimConfig.from_dict:None/空/全零/仅start/仅duration/start+end/start+duration/end+duration/全三个/字符串/falsy值
- validate_and_resolve三选二:start+end/start+duration/end+duration/负start钳制/仅start取末尾/仅end取开头/仅duration/全三个/end<=start
- 边界钳制:负start/超end/超start/零时长素材/负时长/无参数
- 属性:is_valid/is_noop/trim_from_start 各种边界
- TrimSegment.from_dict:最小/带ID/带order/默认order
- build_video_trim_filter:noop/完整参数/零start/仅start/逗号分隔
- build_audio_trim_filter:noop/完整参数/零start/atrim前缀
- resolve_segments:空列表/单段/无效段跳过/按order排序/负order/受素材时长限制
- parse_segments_from_config:None/空/多段列表/空列表/非列表/单段trim_start/单段trim_end/单段duration/单段全字段/多段优先/非dict条目
- extract_trim_from_clip_config:None/空/trim子字典/空子字典/扁平字段/trim_end_only/子字典优先/无字段/非dict子字典
2026-07-28 16:06:28 +08:00
+612
View File
@@ -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 → 钳制到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