Files
xiaoxia-saas/tests/unit/test_trim_config.py
xiaoxia e2ddc679bb
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
test(wave146): trim_config 单测补全 +78 (#1069)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 12:23:46 +08:00

640 lines
23 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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):
"""只有durationstart=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