Files
xiaoxia-saas/tests/unit/test_speed_engine_pure.py
CI Bot 5fa915cdad
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
style: auto-format with black + isort + prettier
2026-07-26 08:29:38 +00:00

404 lines
14 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.
"""SpeedEngine 纯逻辑单测 — 配置解析 + 调速滤镜 + 时长计算.
全纯函数测试,不依赖 FFmpeg 或外部服务。
"""
from __future__ import annotations
import pytest
from video_processing.speed_engine import (
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
SpeedEngine,
)
# ── SpeedConfig 解析 ──────────────────────────────────────────────────────
class TestSpeedConfigParse:
"""SpeedConfig.from_dict / parse 解析测试."""
def test_none_data_returns_default(self):
"""None 输入返回默认配置."""
config = SpeedConfig.parse(None)
assert config.speed == 1.0
assert config.pitch_correct is True
def test_empty_dict_returns_default(self):
"""空 dict 返回默认配置."""
config = SpeedConfig.parse({})
assert config.speed == 1.0
assert config.pitch_correct is True
def test_valid_speed_and_pitch(self):
"""正常速度和音调配置."""
config = SpeedConfig.parse({"speed": 2.0, "pitch_correct": False})
assert config.speed == 2.0
assert config.pitch_correct is False
def test_speed_as_int(self):
"""整数 speed 自动转 float."""
config = SpeedConfig.parse({"speed": 2})
assert config.speed == 2.0
assert isinstance(config.speed, float)
def test_invalid_speed_type_falls_back(self):
"""speed 类型错误回退到默认."""
config = SpeedConfig.parse({"speed": "fast"})
assert config.speed == 1.0
def test_invalid_pitch_correct_type_falls_back(self):
"""pitch_correct 非 bool 回退到 True."""
config = SpeedConfig.parse({"pitch_correct": "yes"})
assert config.pitch_correct is True
def test_non_dict_input_falls_back(self):
"""非 dict 输入回退到默认."""
config = SpeedConfig.parse("speed=2x")
assert config.speed == 1.0
assert config.pitch_correct is True
class TestSpeedConfigClamp:
"""SpeedConfig.clamp 钳制测试."""
def test_zero_speed_clamps_to_default(self):
"""speed=0 钳制到默认 1.0."""
config = SpeedConfig(speed=0.0)
config.clamp()
assert config.speed == 1.0
def test_negative_speed_clamps_to_default(self):
"""负速度钳制到默认 1.0."""
config = SpeedConfig(speed=-1.0)
config.clamp()
assert config.speed == 1.0
def test_below_min_clamps_to_min(self):
"""低于最小速度钳制到 MIN_SPEED."""
config = SpeedConfig(speed=0.1)
config.clamp()
assert config.speed == MIN_SPEED
def test_at_min_stays(self):
"""恰好在最小值保持不变."""
config = SpeedConfig(speed=MIN_SPEED)
config.clamp()
assert config.speed == MIN_SPEED
def test_above_max_clamps_to_max(self):
"""超过最大速度钳制到 MAX_SPEED."""
config = SpeedConfig(speed=5.0)
config.clamp()
assert config.speed == MAX_SPEED
def test_at_max_stays(self):
"""恰好在最大值保持不变."""
config = SpeedConfig(speed=MAX_SPEED)
config.clamp()
assert config.speed == MAX_SPEED
def test_normal_speed_stays(self):
"""正常范围内速度保持不变."""
config = SpeedConfig(speed=1.5)
config.clamp()
assert config.speed == 1.5
def test_parse_auto_clamps(self):
"""parse 自动执行 clamp."""
config = SpeedConfig.parse({"speed": 10.0})
assert config.speed == MAX_SPEED
class TestSpeedConfigIsOriginal:
"""SpeedConfig.is_original 属性测试."""
def test_default_is_original(self):
"""默认配置为原速."""
assert SpeedConfig().is_original is True
def test_exactly_one_is_original(self):
"""speed=1.0 为原速."""
assert SpeedConfig(speed=1.0).is_original is True
def test_very_close_is_original(self):
"""浮点精度接近 1.0 视为原速."""
assert SpeedConfig(speed=1.0000001).is_original is True
def test_different_speed_not_original(self):
"""非 1.0 速度不是原速."""
assert SpeedConfig(speed=2.0).is_original is False
assert SpeedConfig(speed=0.5).is_original is False
# ── SpeedEngine 滤镜构建 ──────────────────────────────────────────────────
class TestSpeedEngineBuildVideoFilter:
"""SpeedEngine.build_video_filter 视频滤镜测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
"""原速返回空字符串(无滤镜)."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_video_filter(config) == ""
def test_double_speed(self):
"""2倍速 setpts=PTS/2."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/2.0000"
def test_half_speed(self):
"""0.5倍速 setpts=PTS/0.5."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/0.5000"
def test_quarter_speed(self):
"""0.25倍速."""
config = SpeedConfig(speed=0.25)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/0.25" in result
def test_quad_speed(self):
"""4倍速."""
config = SpeedConfig(speed=4.0)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/4.0" in result
def test_custom_speed_precision(self):
"""自定义速度保留4位小数."""
config = SpeedConfig(speed=1.333)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/1.3330"
class TestSpeedEngineBuildAudioFilter:
"""SpeedEngine.build_audio_filter 音频滤镜测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
"""原速返回空字符串."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_audio_filter(config) == ""
def test_within_single_stage_range(self):
"""单级 atempo 范围内返回一级."""
config = SpeedConfig(speed=1.5)
result = self.engine.build_audio_filter(config)
assert result == "atempo=1.5000"
def test_at_max_single_stage(self):
"""恰好 2.0 单级."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_audio_filter(config)
assert result == "atempo=2.0000"
def test_at_min_single_stage(self):
"""恰好 0.5 单级."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_audio_filter(config)
assert result == "atempo=0.5000"
def test_quad_speed_two_stages(self):
"""4倍速 = atempo=2.0,atempo=2.0."""
config = SpeedConfig(speed=4.0)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
assert stages[0] == "atempo=2.0000"
assert stages[1] == "atempo=2.0000"
def test_quarter_speed_two_stages(self):
"""0.25倍速 = atempo=0.5,atempo=0.5."""
config = SpeedConfig(speed=0.25)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
assert stages[0] == "atempo=0.5000"
assert stages[1] == "atempo=0.5000"
def test_triple_speed_two_stages(self):
"""3倍速 = atempo=2.0,atempo=1.5 (2.0 * 1.5 = 3.0)."""
config = SpeedConfig(speed=3.0)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
# 乘积应为 3.0
values = [float(s.split("=")[1]) for s in stages]
product = 1.0
for v in values:
product *= v
assert abs(product - 3.0) < 0.01
def test_low_speed_two_stages(self):
"""0.3倍速多级串联,乘积为 0.3."""
config = SpeedConfig(speed=0.3)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) >= 2
values = [float(s.split("=")[1]) for s in stages]
product = 1.0
for v in values:
product *= v
assert abs(product - 0.3) < 0.01
def test_all_stages_within_valid_range(self):
"""所有 atempo 级都在 [0.5, 2.0] 范围内."""
for speed in [0.25, 0.3, 0.5, 0.8, 1.5, 2.0, 3.0, 4.0]:
config = SpeedConfig(speed=speed)
result = self.engine.build_audio_filter(config)
if not result:
continue
stages = result.split(",")
for stage in stages:
val = float(stage.split("=")[1])
assert 0.5 <= val <= 2.0, f"speed={speed}, stage={val} out of range"
class TestSpeedEngineSplitAtempoStages:
"""SpeedEngine._split_atempo_stages 拆分算法测试."""
def test_single_stage_within_range(self):
"""范围内单级."""
stages = SpeedEngine._split_atempo_stages(1.5)
assert stages == [1.5]
def test_exactly_max_single_stage(self):
"""恰好 2.0 单级."""
stages = SpeedEngine._split_atempo_stages(2.0)
assert stages == [2.0]
def test_exactly_min_single_stage(self):
"""恰好 0.5 单级."""
stages = SpeedEngine._split_atempo_stages(0.5)
assert stages == [0.5]
def test_four_x_two_stages(self):
"""4.0 拆为两级 2.0."""
stages = SpeedEngine._split_atempo_stages(4.0)
assert stages == [2.0, 2.0]
def test_quarter_x_two_stages(self):
"""0.25 拆为两级 0.5."""
stages = SpeedEngine._split_atempo_stages(0.25)
assert stages == [0.5, 0.5]
def test_product_matches_original_speed(self):
"""拆分后乘积应等于原速度."""
for speed in [0.25, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0]:
stages = SpeedEngine._split_atempo_stages(speed)
product = 1.0
for s in stages:
product *= s
assert abs(product - speed) < 0.001, f"speed={speed}, product={product}"
class TestSpeedEngineAdjustDuration:
"""SpeedEngine.adjust_duration 时长计算测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_same_duration(self):
"""原速时长不变."""
config = SpeedConfig(speed=1.0)
assert self.engine.adjust_duration(10.0, config) == 10.0
def test_double_speed_half_duration(self):
"""2倍速时长减半."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(10.0, config) == 5.0
def test_half_speed_double_duration(self):
"""0.5倍速时长加倍."""
config = SpeedConfig(speed=0.5)
assert self.engine.adjust_duration(10.0, config) == 20.0
def test_zero_duration_stays_zero(self):
"""零时长保持零."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(0.0, config) == 0.0
def test_negative_duration_stays(self):
"""负时长直接返回(不做调速)."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(-5.0, config) == -5.0
def test_quad_speed_quarter_duration(self):
"""4倍速时长为1/4."""
config = SpeedConfig(speed=4.0)
assert self.engine.adjust_duration(20.0, config) == 5.0
class TestSpeedEngineBuildClipSpeedFilter:
"""SpeedEngine.build_clip_speed_filter 便捷方法测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_default_speed_returns_empty_filters(self):
"""默认速度返回空滤镜."""
vf, af, config = self.engine.build_clip_speed_filter(1.0)
assert vf == ""
assert af == ""
assert config.is_original is True
def test_double_speed_filters(self):
"""2倍速返回对应滤镜."""
vf, af, config = self.engine.build_clip_speed_filter(2.0)
assert vf == "setpts=PTS/2.0000"
assert af == "atempo=2.0000"
assert config.speed == 2.0
def test_speed_gets_clamped(self):
"""超范围速度自动钳制."""
vf, af, config = self.engine.build_clip_speed_filter(10.0)
assert config.speed == MAX_SPEED
assert "setpts" in vf
def test_pitch_correct_false_still_has_audio_filter(self):
"""pitch_correct=False 也返回音频滤镜(只是方式不同,当前实现仍用atempo."""
vf, af, config = self.engine.build_clip_speed_filter(2.0, pitch_correct=False)
assert config.pitch_correct is False
# 当前实现 pitch_correct 不影响滤镜输出(atempo 本身保持音调)
assert "atempo" in af
class TestSpeedEngineResolveClipSpeed:
"""SpeedEngine.resolve_clip_speed 速度解析测试."""
def test_no_clip_config_uses_global(self):
"""无 clip config 使用全局速度."""
assert SpeedEngine.resolve_clip_speed(None, 2.0) == 2.0
def test_empty_config_uses_global(self):
"""空 config 使用全局速度."""
assert SpeedEngine.resolve_clip_speed({}, 1.5) == 1.5
def test_zero_speed_uses_global(self):
"""playback_speed=0 使用全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": 0}, 2.0) == 2.0
def test_valid_clip_speed(self):
"""有效 clip 速度优先."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
def test_invalid_speed_type_uses_global(self):
"""速度类型错误回退到全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": "fast"}, 2.0) == 2.0
def test_negative_speed_uses_global(self):
"""负速度回退到全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": -1}, 1.0) == 1.0
def test_default_global_is_one(self):
"""默认全局速度为 1.0."""
assert SpeedEngine.resolve_clip_speed({}) == 1.0