Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af1d24d3c | |||
| 10897b3ee1 | |||
| c5a949d502 | |||
| 98e74646f9 | |||
| de53975784 | |||
| 0a447b4b68 | |||
| cad16b3f87 | |||
| 5ecc75c5ba | |||
| 17494a1cde | |||
| 0320bea6b5 |
Executable
+104
@@ -0,0 +1,104 @@
|
||||
"""classification 模块单元测试."""
|
||||
|
||||
import pytest
|
||||
from domain.classification import (
|
||||
AssetClassification,
|
||||
AssetLibraryKind,
|
||||
ClassificationJob,
|
||||
ClassificationJobStatus,
|
||||
IngestJobStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetLibraryKind:
|
||||
"""AssetLibraryKind 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert AssetLibraryKind.VIDEO == "video"
|
||||
assert AssetLibraryKind.VOICE == "voice"
|
||||
|
||||
|
||||
class TestIngestJobStatus:
|
||||
"""IngestJobStatus 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert IngestJobStatus.PENDING == "pending"
|
||||
assert IngestJobStatus.PROCESSING == "processing"
|
||||
assert IngestJobStatus.COMPLETED == "completed"
|
||||
assert IngestJobStatus.FAILED == "failed"
|
||||
|
||||
|
||||
class TestClassificationJobStatus:
|
||||
"""ClassificationJobStatus 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert ClassificationJobStatus.PENDING == "pending"
|
||||
assert ClassificationJobStatus.PROCESSING == "processing"
|
||||
assert ClassificationJobStatus.COMPLETED == "completed"
|
||||
assert ClassificationJobStatus.FAILED == "failed"
|
||||
|
||||
|
||||
class TestAssetClassification:
|
||||
"""AssetClassification 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert AssetClassification.SCENIC == "scenic"
|
||||
assert AssetClassification.PRODUCT == "product"
|
||||
assert AssetClassification.PERSON == "person"
|
||||
assert AssetClassification.ANIMAL == "animal"
|
||||
assert AssetClassification.FOOD == "food"
|
||||
assert AssetClassification.TECH == "tech"
|
||||
assert AssetClassification.SPORT == "sport"
|
||||
assert AssetClassification.MUSIC == "music"
|
||||
assert AssetClassification.OTHER == "other"
|
||||
|
||||
|
||||
class TestClassificationJobCreate:
|
||||
"""ClassificationJob.create 工厂方法测试."""
|
||||
|
||||
def test_create_with_valid_params(self):
|
||||
job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001")
|
||||
assert job.id
|
||||
assert len(job.id) == 32
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.asset_id == "asset_001"
|
||||
assert job.status == ClassificationJobStatus.PENDING
|
||||
assert job.classification == ""
|
||||
assert job.confidence == 0.0
|
||||
assert job.error_message == ""
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
job = ClassificationJob.create(
|
||||
project_id=" proj_002 ",
|
||||
asset_id=" asset_002 ",
|
||||
)
|
||||
assert job.project_id == "proj_002"
|
||||
assert job.asset_id == "asset_002"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
ClassificationJob.create(project_id="", asset_id="a")
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
ClassificationJob.create(project_id=" ", asset_id="a")
|
||||
|
||||
def test_create_empty_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
ClassificationJob.create(project_id="p", asset_id="")
|
||||
|
||||
def test_create_whitespace_asset_id_raises(self):
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
ClassificationJob.create(project_id="p", asset_id=" ")
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
j1 = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
j2 = ClassificationJob.create(project_id="p", asset_id="b")
|
||||
assert j1.id != j2.id
|
||||
|
||||
def test_create_timestamps_are_utc(self):
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.created_at.tzinfo is not None
|
||||
assert job.updated_at.tzinfo is not None
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
"""edit_plan_clip 领域模型单元测试."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
|
||||
class TestEditPlanClipStatus:
|
||||
"""EditPlanClipStatus 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert EditPlanClipStatus.PENDING == "pending"
|
||||
assert EditPlanClipStatus.READY == "ready"
|
||||
assert EditPlanClipStatus.RENDERED == "rendered"
|
||||
assert EditPlanClipStatus.FAILED == "failed"
|
||||
|
||||
|
||||
class TestEditPlanClipCreate:
|
||||
"""EditPlanClip.create 工厂方法测试."""
|
||||
|
||||
def test_create_with_required_fields(self):
|
||||
clip = EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
|
||||
assert clip.id # 自动生成的 UUID
|
||||
assert len(clip.id) == 32 # hex 格式
|
||||
assert clip.plan_id == "plan_001"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.order == 1
|
||||
assert clip.status == EditPlanClipStatus.PENDING
|
||||
assert clip.start_time == 0.0
|
||||
assert clip.duration == 0.0
|
||||
assert clip.transition_effect == "cut"
|
||||
assert clip.playback_speed == 1.0
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id="plan_002",
|
||||
clip_type="audio",
|
||||
order=2,
|
||||
template_clip_config_id="tpl_001",
|
||||
asset_id="asset_001",
|
||||
text_content="测试文案",
|
||||
start_time=5.0,
|
||||
duration=10.0,
|
||||
transition_effect="fade",
|
||||
transition_duration=0.5,
|
||||
playback_speed=1.5,
|
||||
config={"key": "value"},
|
||||
)
|
||||
assert clip.plan_id == "plan_002"
|
||||
assert clip.clip_type == "audio"
|
||||
assert clip.order == 2
|
||||
assert clip.template_clip_config_id == "tpl_001"
|
||||
assert clip.asset_id == "asset_001"
|
||||
assert clip.text_content == "测试文案"
|
||||
assert clip.start_time == 5.0
|
||||
assert clip.duration == 10.0
|
||||
assert clip.transition_effect == "fade"
|
||||
assert clip.transition_duration == 0.5
|
||||
assert clip.playback_speed == 1.5
|
||||
assert clip.config == {"key": "value"}
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=" plan_003 ",
|
||||
clip_type=" video ",
|
||||
order=1,
|
||||
asset_id=" asset_001 ",
|
||||
template_clip_config_id=" tpl_001 ",
|
||||
text_content=" 测试 ",
|
||||
transition_effect=" fade ",
|
||||
)
|
||||
assert clip.plan_id == "plan_003"
|
||||
assert clip.clip_type == "video"
|
||||
assert clip.asset_id == "asset_001"
|
||||
assert clip.template_clip_config_id == "tpl_001"
|
||||
assert clip.text_content == "测试"
|
||||
assert clip.transition_effect == "fade"
|
||||
|
||||
def test_create_empty_plan_id_raises(self):
|
||||
with pytest.raises(ValueError, match="plan_id"):
|
||||
EditPlanClip.create(plan_id="", clip_type="video", order=1)
|
||||
|
||||
def test_create_whitespace_plan_id_raises(self):
|
||||
with pytest.raises(ValueError, match="plan_id"):
|
||||
EditPlanClip.create(plan_id=" ", clip_type="video", order=1)
|
||||
|
||||
def test_create_empty_clip_type_raises(self):
|
||||
with pytest.raises(ValueError, match="clip_type"):
|
||||
EditPlanClip.create(plan_id="plan_001", clip_type="", order=1)
|
||||
|
||||
def test_create_negative_start_time_raises(self):
|
||||
with pytest.raises(ValueError, match="start_time"):
|
||||
EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=-1.0)
|
||||
|
||||
def test_create_negative_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="duration"):
|
||||
EditPlanClip.create(plan_id="p", clip_type="v", order=1, duration=-5.0)
|
||||
|
||||
def test_create_zero_speed_clamps_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_negative_speed_clamps_to_1(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=-1.0)
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
def test_create_low_speed_clamps_to_min(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.1)
|
||||
assert clip.playback_speed == 0.25
|
||||
|
||||
def test_create_high_speed_clamps_to_max(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=5.0)
|
||||
assert clip.playback_speed == 4.0
|
||||
|
||||
def test_create_speed_at_boundary_values(self):
|
||||
# 边界值应该保持不变
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=0.25)
|
||||
assert clip.playback_speed == 0.25
|
||||
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, playback_speed=4.0)
|
||||
assert clip.playback_speed == 4.0
|
||||
|
||||
def test_create_negative_transition_duration_clamps_to_0(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_duration=-1.0)
|
||||
assert clip.transition_duration == 0.0
|
||||
|
||||
def test_create_empty_transition_effect_defaults_to_cut(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, transition_effect="")
|
||||
assert clip.transition_effect == "cut"
|
||||
|
||||
def test_create_empty_asset_id_stays_empty(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
|
||||
assert clip.asset_id == ""
|
||||
|
||||
def test_create_none_config_defaults_to_empty_dict(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, config=None)
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
c1 = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
c2 = EditPlanClip.create(plan_id="p", clip_type="v", order=2)
|
||||
assert c1.id != c2.id
|
||||
|
||||
def test_create_timestamps_are_utc(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
assert clip.created_at.tzinfo is not None
|
||||
assert clip.updated_at.tzinfo is not None
|
||||
|
||||
|
||||
class TestEditPlanClipStateMachine:
|
||||
"""状态机流转测试."""
|
||||
|
||||
@pytest.fixture
|
||||
def pending_clip(self):
|
||||
return EditPlanClip.create(plan_id="plan_001", clip_type="video", order=1)
|
||||
|
||||
def test_initial_status_is_pending(self, pending_clip):
|
||||
assert pending_clip.status == EditPlanClipStatus.PENDING
|
||||
|
||||
def test_pending_to_ready(self, pending_clip):
|
||||
pending_clip.mark_ready()
|
||||
assert pending_clip.status == EditPlanClipStatus.READY
|
||||
|
||||
def test_pending_cannot_mark_rendered(self, pending_clip):
|
||||
with pytest.raises(ValueError, match="只有 ready"):
|
||||
pending_clip.mark_rendered()
|
||||
|
||||
def test_pending_cannot_mark_failed(self, pending_clip):
|
||||
with pytest.raises(ValueError, match="只有 ready"):
|
||||
pending_clip.mark_failed()
|
||||
|
||||
def test_ready_to_rendered(self, pending_clip):
|
||||
pending_clip.mark_ready()
|
||||
pending_clip.mark_rendered()
|
||||
assert pending_clip.status == EditPlanClipStatus.RENDERED
|
||||
|
||||
def test_ready_to_failed(self, pending_clip):
|
||||
pending_clip.mark_ready()
|
||||
pending_clip.mark_failed()
|
||||
assert pending_clip.status == EditPlanClipStatus.FAILED
|
||||
|
||||
def test_rendered_cannot_mark_ready_again(self, pending_clip):
|
||||
pending_clip.mark_ready()
|
||||
pending_clip.mark_rendered()
|
||||
with pytest.raises(ValueError):
|
||||
pending_clip.mark_ready()
|
||||
|
||||
def test_failed_cannot_mark_ready_again(self, pending_clip):
|
||||
pending_clip.mark_ready()
|
||||
pending_clip.mark_failed()
|
||||
with pytest.raises(ValueError):
|
||||
pending_clip.mark_ready()
|
||||
|
||||
def test_state_transition_updates_updated_at(self, pending_clip):
|
||||
old_updated = pending_clip.updated_at
|
||||
# 确保时间不同
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
pending_clip.mark_ready()
|
||||
assert pending_clip.updated_at > old_updated
|
||||
|
||||
|
||||
class TestEditPlanClipAssignAsset:
|
||||
"""assign_asset 方法测试."""
|
||||
|
||||
def test_assign_asset(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
assert not clip.has_asset
|
||||
clip.assign_asset("asset_001")
|
||||
assert clip.asset_id == "asset_001"
|
||||
assert clip.has_asset
|
||||
|
||||
def test_assign_asset_strips_whitespace(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
clip.assign_asset(" asset_001 ")
|
||||
assert clip.asset_id == "asset_001"
|
||||
|
||||
def test_assign_empty_asset_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
clip.assign_asset("")
|
||||
|
||||
def test_assign_whitespace_asset_raises(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
with pytest.raises(ValueError, match="asset_id"):
|
||||
clip.assign_asset(" ")
|
||||
|
||||
def test_assign_updates_updated_at(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
old_updated = clip.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
clip.assign_asset("asset_001")
|
||||
assert clip.updated_at > old_updated
|
||||
|
||||
|
||||
class TestEditPlanClipProperties:
|
||||
"""属性方法测试."""
|
||||
|
||||
def test_end_time(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=5.0, duration=10.0)
|
||||
assert clip.end_time == 15.0
|
||||
|
||||
def test_end_time_zero_duration(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, start_time=3.0, duration=0.0)
|
||||
assert clip.end_time == 3.0
|
||||
|
||||
def test_has_asset_true(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="a001")
|
||||
assert clip.has_asset is True
|
||||
|
||||
def test_has_asset_false(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1)
|
||||
assert clip.has_asset is False
|
||||
|
||||
def test_has_asset_empty_string(self):
|
||||
clip = EditPlanClip.create(plan_id="p", clip_type="v", order=1, asset_id="")
|
||||
assert clip.has_asset is False
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
"""filter_presets 模块单元测试."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from domain.filter_presets import (
|
||||
FILTER_PRESET_LIBRARY,
|
||||
FilterPreset,
|
||||
build_ffmpeg_filter,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
|
||||
class TestFilterPreset:
|
||||
"""FilterPreset 数据类测试."""
|
||||
|
||||
def test_create_required_fields(self):
|
||||
f = FilterPreset(id="test_001", name="测试滤镜", category="basic")
|
||||
assert f.id == "test_001"
|
||||
assert f.name == "测试滤镜"
|
||||
assert f.category == "basic"
|
||||
# 默认值
|
||||
assert f.description == ""
|
||||
assert f.tags == []
|
||||
assert f.brightness == 0.0
|
||||
assert f.contrast == 1.0
|
||||
assert f.saturation == 1.0
|
||||
assert f.gamma == 1.0
|
||||
assert f.gamma_r == 1.0
|
||||
assert f.gamma_g == 1.0
|
||||
assert f.gamma_b == 1.0
|
||||
assert f.hue == 0.0
|
||||
assert f.lut_url == ""
|
||||
|
||||
def test_create_all_fields(self):
|
||||
f = FilterPreset(
|
||||
id="test_002",
|
||||
name="完整滤镜",
|
||||
category="cinematic",
|
||||
description="测试描述",
|
||||
tags=["标签1", "标签2"],
|
||||
brightness=0.1,
|
||||
contrast=1.2,
|
||||
saturation=0.8,
|
||||
gamma=1.1,
|
||||
gamma_r=1.05,
|
||||
gamma_g=0.95,
|
||||
gamma_b=1.15,
|
||||
hue=10.0,
|
||||
lut_url="https://example.com/lut.png",
|
||||
)
|
||||
assert f.category == "cinematic"
|
||||
assert f.brightness == 0.1
|
||||
assert f.contrast == 1.2
|
||||
assert f.saturation == 0.8
|
||||
assert f.gamma == 1.1
|
||||
assert f.gamma_r == 1.05
|
||||
assert f.gamma_g == 0.95
|
||||
assert f.gamma_b == 1.15
|
||||
assert f.hue == 10.0
|
||||
assert f.lut_url == "https://example.com/lut.png"
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
f = FilterPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
f.name = "修改" # type: ignore[misc]
|
||||
|
||||
def test_tags_default_new_list(self):
|
||||
f1 = FilterPreset(id="1", name="a", category="basic")
|
||||
f2 = FilterPreset(id="2", name="b", category="basic")
|
||||
assert f1.tags is not f2.tags
|
||||
assert f1.tags == []
|
||||
|
||||
|
||||
class TestFilterPresetLibrary:
|
||||
"""FILTER_PRESET_LIBRARY 预设库测试."""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(FILTER_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_unique_ids(self):
|
||||
ids = [f.id for f in FILTER_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_all_are_filter_preset_instances(self):
|
||||
for f in FILTER_PRESET_LIBRARY:
|
||||
assert isinstance(f, FilterPreset)
|
||||
|
||||
def test_contains_basic_category(self):
|
||||
cats = {f.category for f in FILTER_PRESET_LIBRARY}
|
||||
assert "basic" in cats
|
||||
|
||||
def test_none_filter_is_identity(self):
|
||||
"""filter_none 应该所有参数都是默认值(不改变画面)"""
|
||||
f = get_filter_preset("filter_none")
|
||||
assert f is not None
|
||||
assert f.brightness == 0.0
|
||||
assert f.contrast == 1.0
|
||||
assert f.saturation == 1.0
|
||||
assert f.gamma == 1.0
|
||||
|
||||
|
||||
class TestGetFilterPreset:
|
||||
"""get_filter_preset 函数测试."""
|
||||
|
||||
def test_existing_id(self):
|
||||
f = get_filter_preset("filter_brighten")
|
||||
assert f is not None
|
||||
assert f.id == "filter_brighten"
|
||||
assert f.name == "明亮"
|
||||
|
||||
def test_nonexistent_id(self):
|
||||
assert get_filter_preset("nonexistent") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert get_filter_preset("") is None
|
||||
|
||||
|
||||
class TestListFilterPresets:
|
||||
"""list_filter_presets 函数测试."""
|
||||
|
||||
def test_no_filters_returns_all(self):
|
||||
result = list_filter_presets()
|
||||
assert len(result) == len(FILTER_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category_basic(self):
|
||||
result = list_filter_presets(category="basic")
|
||||
assert len(result) >= 4
|
||||
for f in result:
|
||||
assert f.category == "basic"
|
||||
|
||||
def test_filter_by_unknown_category_returns_empty(self):
|
||||
result = list_filter_presets(category="nonexistent")
|
||||
assert result == []
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
result = list_filter_presets(keyword="明亮")
|
||||
assert len(result) >= 1
|
||||
assert any(f.name == "明亮" for f in result)
|
||||
|
||||
def test_filter_by_keyword_tag(self):
|
||||
result = list_filter_presets(keyword="提亮")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
result = list_filter_presets(keyword="偏暗")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_filter_keyword_case_insensitive(self):
|
||||
r1 = list_filter_presets(keyword="FILTER")
|
||||
r2 = list_filter_presets(keyword="filter")
|
||||
assert len(r1) == len(r2)
|
||||
|
||||
def test_filter_keyword_no_match(self):
|
||||
result = list_filter_presets(keyword="xyz_nonexistent_12345")
|
||||
assert result == []
|
||||
|
||||
def test_combined_category_and_keyword(self):
|
||||
result = list_filter_presets(category="basic", keyword="明亮")
|
||||
assert len(result) >= 1
|
||||
for f in result:
|
||||
assert f.category == "basic"
|
||||
|
||||
def test_combined_no_match(self):
|
||||
result = list_filter_presets(category="basic", keyword="电影感")
|
||||
# 基础分类里没有电影感关键词
|
||||
pass # 不做强断言,看实际数据
|
||||
|
||||
|
||||
class TestBuildFFmpegFilter:
|
||||
"""build_ffmpeg_filter 函数测试."""
|
||||
|
||||
def test_none_preset_returns_empty(self):
|
||||
result = build_ffmpeg_filter("nonexistent")
|
||||
assert result == ""
|
||||
|
||||
def test_zero_intensity_returns_empty(self):
|
||||
result = build_ffmpeg_filter("filter_brighten", intensity=0)
|
||||
assert result == ""
|
||||
|
||||
def test_negative_intensity_returns_empty(self):
|
||||
result = build_ffmpeg_filter("filter_brighten", intensity=-10)
|
||||
assert result == ""
|
||||
|
||||
def test_full_intensity_brighten(self):
|
||||
result = build_ffmpeg_filter("filter_brighten", intensity=100)
|
||||
assert result.startswith("eq=")
|
||||
assert "brightness=0.120" in result
|
||||
assert "contrast=1.050" in result
|
||||
assert "saturation=1.050" in result
|
||||
assert "gamma=1.100" in result
|
||||
|
||||
def test_half_intensity(self):
|
||||
"""强度 50% 时参数应该是全量的一半(向原值插值)"""
|
||||
full = build_ffmpeg_filter("filter_brighten", intensity=100)
|
||||
half = build_ffmpeg_filter("filter_brighten", intensity=50)
|
||||
|
||||
# 50% 强度的 brightness 应该是 0.060 (0.120 * 0.5)
|
||||
assert "brightness=0.060" in half
|
||||
# full 和 half 都应该有 eq= 前缀
|
||||
assert full.startswith("eq=")
|
||||
assert half.startswith("eq=")
|
||||
|
||||
def test_intensity_over_100_clamps_to_100(self):
|
||||
result1 = build_ffmpeg_filter("filter_brighten", intensity=100)
|
||||
result2 = build_ffmpeg_filter("filter_brighten", intensity=150)
|
||||
assert result1 == result2
|
||||
|
||||
def test_filter_none_returns_empty(self):
|
||||
"""原图滤镜所有参数都是默认值,应该返回空字符串"""
|
||||
result = build_ffmpeg_filter("filter_none")
|
||||
assert result == ""
|
||||
|
||||
def test_warm_filter_has_gamma_channels(self):
|
||||
"""暖色滤镜应该调整 RGB 通道伽马"""
|
||||
result = build_ffmpeg_filter("filter_warm", intensity=100)
|
||||
assert "gamma_r=" in result
|
||||
# 暖色红通道伽马 > 1.0
|
||||
assert "gamma_r=1.100" in result
|
||||
|
||||
def test_result_format_is_eq_params(self):
|
||||
"""结果格式应该是 eq=param1=val:param2=val..."""
|
||||
result = build_ffmpeg_filter("filter_brighten", intensity=100)
|
||||
assert result.startswith("eq=")
|
||||
# 参数之间用冒号分隔
|
||||
parts = result[3:].split(":")
|
||||
assert len(parts) >= 4 # 至少 brightness/contrast/saturation/gamma
|
||||
for part in parts:
|
||||
assert "=" in part # 每个部分都是 key=value 格式
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
"""generated_video 领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
from domain.generated_video import GeneratedVideo
|
||||
|
||||
|
||||
class TestGeneratedVideoCreate:
|
||||
"""GeneratedVideo.create 工厂方法测试."""
|
||||
|
||||
def test_create_with_required_fields(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj_001",
|
||||
generation_task_id="task_001",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/out.mp4",
|
||||
)
|
||||
assert video.id
|
||||
assert len(video.id) == 32
|
||||
assert video.project_id == "proj_001"
|
||||
assert video.generation_task_id == "task_001"
|
||||
assert video.name == "测试视频"
|
||||
assert video.file_url == "https://example.com/out.mp4"
|
||||
# 默认值
|
||||
assert video.user_id == ""
|
||||
assert video.file_size == 0
|
||||
assert video.duration == 0.0
|
||||
assert video.width == 0
|
||||
assert video.height == 0
|
||||
assert video.fps == 0.0
|
||||
assert video.thumbnail_url is None
|
||||
assert video.status == "completed"
|
||||
assert video.review_status == "pending_review"
|
||||
assert video.generation_params == {}
|
||||
assert video.video_fingerprint is None
|
||||
assert video.is_duplicate is False
|
||||
assert video.duplicate_of is None
|
||||
assert video.generated_at is not None
|
||||
assert video.created_at is not None
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="proj_002",
|
||||
generation_task_id="task_002",
|
||||
name="完整视频",
|
||||
file_url="https://example.com/full.mp4",
|
||||
user_id="user_001",
|
||||
file_size=1024000,
|
||||
duration=30.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=30.0,
|
||||
thumbnail_url="https://example.com/thumb.jpg",
|
||||
generation_params={"quality": "high"},
|
||||
)
|
||||
assert video.user_id == "user_001"
|
||||
assert video.file_size == 1024000
|
||||
assert video.duration == 30.5
|
||||
assert video.width == 1920
|
||||
assert video.height == 1080
|
||||
assert video.fps == 30.0
|
||||
assert video.thumbnail_url == "https://example.com/thumb.jpg"
|
||||
assert video.generation_params == {"quality": "high"}
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id=" proj_003 ",
|
||||
generation_task_id=" task_003 ",
|
||||
name=" 测试视频 ",
|
||||
file_url=" https://example.com/out.mp4 ",
|
||||
user_id=" user_003 ",
|
||||
)
|
||||
assert video.project_id == "proj_003"
|
||||
assert video.generation_task_id == "task_003"
|
||||
assert video.name == "测试视频"
|
||||
assert video.file_url == "https://example.com/out.mp4"
|
||||
assert video.user_id == "user_003"
|
||||
|
||||
def test_create_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
GeneratedVideo.create(
|
||||
project_id="",
|
||||
generation_task_id="t",
|
||||
name="n",
|
||||
file_url="u",
|
||||
)
|
||||
|
||||
def test_create_whitespace_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
GeneratedVideo.create(
|
||||
project_id=" ",
|
||||
generation_task_id="t",
|
||||
name="n",
|
||||
file_url="u",
|
||||
)
|
||||
|
||||
def test_create_empty_generation_task_id_raises(self):
|
||||
with pytest.raises(ValueError, match="generation_task_id"):
|
||||
GeneratedVideo.create(
|
||||
project_id="p",
|
||||
generation_task_id="",
|
||||
name="n",
|
||||
file_url="u",
|
||||
)
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
GeneratedVideo.create(
|
||||
project_id="p",
|
||||
generation_task_id="t",
|
||||
name="",
|
||||
file_url="u",
|
||||
)
|
||||
|
||||
def test_create_empty_file_url_raises(self):
|
||||
with pytest.raises(ValueError, match="file_url"):
|
||||
GeneratedVideo.create(
|
||||
project_id="p",
|
||||
generation_task_id="t",
|
||||
name="n",
|
||||
file_url="",
|
||||
)
|
||||
|
||||
def test_create_none_generation_params_defaults_to_empty_dict(self):
|
||||
video = GeneratedVideo.create(
|
||||
project_id="p",
|
||||
generation_task_id="t",
|
||||
name="n",
|
||||
file_url="u",
|
||||
generation_params=None,
|
||||
)
|
||||
assert video.generation_params == {}
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
v1 = GeneratedVideo.create(project_id="p", generation_task_id="t1", name="n1", file_url="u1")
|
||||
v2 = GeneratedVideo.create(project_id="p", generation_task_id="t2", name="n2", file_url="u2")
|
||||
assert v1.id != v2.id
|
||||
|
||||
def test_create_timestamps_are_utc(self):
|
||||
video = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
assert video.created_at.tzinfo is not None
|
||||
assert video.generated_at.tzinfo is not None
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
"""preset_bgm 模块单元测试."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from domain.preset_bgm import (
|
||||
BGM_STYLES,
|
||||
PRESET_BGM_LIBRARY,
|
||||
PresetBGM,
|
||||
get_preset_bgm,
|
||||
list_preset_bgm_by_style,
|
||||
search_preset_bgm,
|
||||
)
|
||||
|
||||
|
||||
class TestPresetBGM:
|
||||
"""PresetBGM 数据类测试."""
|
||||
|
||||
def test_create_required_fields(self):
|
||||
bgm = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=120.0)
|
||||
assert bgm.id == "test_001"
|
||||
assert bgm.name == "测试音乐"
|
||||
assert bgm.style == "upbeat"
|
||||
assert bgm.duration == 120.0
|
||||
# 默认值
|
||||
assert bgm.artist == ""
|
||||
assert bgm.description == ""
|
||||
assert bgm.tags == []
|
||||
assert bgm.audio_url == ""
|
||||
|
||||
def test_create_all_fields(self):
|
||||
bgm = PresetBGM(
|
||||
id="test_002",
|
||||
name="完整版",
|
||||
style="relax",
|
||||
duration=180.5,
|
||||
artist="测试艺术家",
|
||||
description="测试描述",
|
||||
tags=["标签1", "标签2"],
|
||||
audio_url="https://example.com/test.mp3",
|
||||
)
|
||||
assert bgm.artist == "测试艺术家"
|
||||
assert bgm.description == "测试描述"
|
||||
assert bgm.tags == ["标签1", "标签2"]
|
||||
assert bgm.audio_url == "https://example.com/test.mp3"
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen=True,实例不可变."""
|
||||
bgm = PresetBGM(id="test", name="测试", style="upbeat", duration=60.0)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
bgm.name = "修改" # type: ignore[misc]
|
||||
|
||||
def test_tags_default_new_list(self):
|
||||
"""每次创建都有独立的 tags 列表."""
|
||||
b1 = PresetBGM(id="1", name="a", style="upbeat", duration=60.0)
|
||||
b2 = PresetBGM(id="2", name="b", style="upbeat", duration=60.0)
|
||||
assert b1.tags is not b2.tags
|
||||
assert b1.tags == []
|
||||
assert b2.tags == []
|
||||
|
||||
|
||||
class TestPresetBGMLibrary:
|
||||
"""PRESET_BGM_LIBRARY 预设库测试."""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(PRESET_BGM_LIBRARY) > 0
|
||||
|
||||
def test_all_unique_ids(self):
|
||||
ids = [b.id for b in PRESET_BGM_LIBRARY]
|
||||
assert len(ids) == len(set(ids)), "BGM ID 不能重复"
|
||||
|
||||
def test_all_are_preset_bgm_instances(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert isinstance(bgm, PresetBGM)
|
||||
|
||||
def test_all_have_positive_duration(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert bgm.duration > 0, f"{bgm.id} duration 必须为正"
|
||||
|
||||
def test_styles_are_known(self):
|
||||
for bgm in PRESET_BGM_LIBRARY:
|
||||
assert bgm.style in BGM_STYLES, f"{bgm.id} style {bgm.style} 不在 BGM_STYLES 中"
|
||||
|
||||
def test_style_distribution(self):
|
||||
"""每种风格至少有 1 个 BGM."""
|
||||
styles_found = {b.style for b in PRESET_BGM_LIBRARY}
|
||||
for style in ["upbeat", "relax", "tech", "commerce"]:
|
||||
assert style in styles_found
|
||||
|
||||
|
||||
class TestBGMStyles:
|
||||
"""BGM_STYLES 风格字典测试."""
|
||||
|
||||
def test_has_expected_styles(self):
|
||||
assert "upbeat" in BGM_STYLES
|
||||
assert "relax" in BGM_STYLES
|
||||
assert "tech" in BGM_STYLES
|
||||
assert "commerce" in BGM_STYLES
|
||||
assert "emotional" in BGM_STYLES
|
||||
assert "cinematic" in BGM_STYLES
|
||||
|
||||
def test_values_are_chinese_labels(self):
|
||||
assert BGM_STYLES["upbeat"] == "轻快"
|
||||
assert BGM_STYLES["relax"] == "治愈"
|
||||
|
||||
|
||||
class TestGetPresetBGM:
|
||||
"""get_preset_bgm 函数测试."""
|
||||
|
||||
def test_existing_id(self):
|
||||
bgm = get_preset_bgm("bgm_upbeat_001")
|
||||
assert bgm is not None
|
||||
assert bgm.id == "bgm_upbeat_001"
|
||||
assert bgm.name == "阳光清晨"
|
||||
assert bgm.style == "upbeat"
|
||||
|
||||
def test_nonexistent_id(self):
|
||||
assert get_preset_bgm("nonexistent") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert get_preset_bgm("") is None
|
||||
|
||||
def test_returns_preset_bgm_instance(self):
|
||||
bgm = get_preset_bgm("bgm_relax_001")
|
||||
assert isinstance(bgm, PresetBGM)
|
||||
|
||||
|
||||
class TestListPresetBGMByStyle:
|
||||
"""list_preset_bgm_by_style 函数测试."""
|
||||
|
||||
def test_upbeat_style(self):
|
||||
result = list_preset_bgm_by_style("upbeat")
|
||||
assert len(result) >= 3
|
||||
for bgm in result:
|
||||
assert bgm.style == "upbeat"
|
||||
|
||||
def test_relax_style(self):
|
||||
result = list_preset_bgm_by_style("relax")
|
||||
assert len(result) >= 3
|
||||
for bgm in result:
|
||||
assert bgm.style == "relax"
|
||||
|
||||
def test_tech_style(self):
|
||||
result = list_preset_bgm_by_style("tech")
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_unknown_style_returns_empty(self):
|
||||
result = list_preset_bgm_by_style("nonexistent_style")
|
||||
assert result == []
|
||||
|
||||
def test_empty_style_returns_empty(self):
|
||||
result = list_preset_bgm_by_style("")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSearchPresetBGM:
|
||||
"""search_preset_bgm 函数测试."""
|
||||
|
||||
def test_search_by_name(self):
|
||||
result = search_preset_bgm("阳光")
|
||||
assert len(result) >= 1
|
||||
assert any(b.name == "阳光清晨" for b in result)
|
||||
|
||||
def test_search_by_tag(self):
|
||||
result = search_preset_bgm("钢琴")
|
||||
assert len(result) >= 1
|
||||
for bgm in result:
|
||||
assert any("钢琴" in tag for tag in bgm.tags) or "钢琴" in bgm.name or "钢琴" in bgm.description
|
||||
|
||||
def test_search_by_description(self):
|
||||
result = search_preset_bgm("vlog")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_search_case_insensitive(self):
|
||||
r1 = search_preset_bgm("BGM")
|
||||
r2 = search_preset_bgm("bgm")
|
||||
assert len(r1) == len(r2)
|
||||
|
||||
def test_search_no_match(self):
|
||||
result = search_preset_bgm("xyz_nonexistent_keyword_12345")
|
||||
assert result == []
|
||||
|
||||
def test_search_empty_keyword_returns_all(self):
|
||||
"""空关键词应该匹配所有(keyword in string 恒成立)."""
|
||||
result = search_preset_bgm("")
|
||||
assert len(result) == len(PRESET_BGM_LIBRARY)
|
||||
|
||||
def test_search_partial_match(self):
|
||||
result = search_preset_bgm("科技")
|
||||
assert len(result) >= 1
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
"""template_clip_config 领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
from domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
|
||||
|
||||
class TestClipType:
|
||||
"""ClipType 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert ClipType.INTRO == "intro"
|
||||
assert ClipType.MAIN == "main"
|
||||
assert ClipType.TRANSITION == "transition"
|
||||
assert ClipType.OUTRO == "outro"
|
||||
assert ClipType.TITLE == "title"
|
||||
assert ClipType.SUBTITLE == "subtitle"
|
||||
|
||||
|
||||
class TestTransitionEffect:
|
||||
"""TransitionEffect 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert TransitionEffect.CUT == "cut"
|
||||
assert TransitionEffect.FADE == "fade"
|
||||
assert TransitionEffect.SLIDE_LEFT == "slide_left"
|
||||
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
|
||||
assert TransitionEffect.DISSOLVE == "dissolve"
|
||||
assert TransitionEffect.WIPE == "wipe"
|
||||
|
||||
|
||||
class TestTemplateClipConfigCreate:
|
||||
"""TemplateClipConfig.create 工厂方法测试."""
|
||||
|
||||
def test_create_with_required_fields(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1)
|
||||
assert clip.id
|
||||
assert len(clip.id) == 32
|
||||
assert clip.template_id == "tpl_001"
|
||||
assert clip.clip_type == ClipType.MAIN
|
||||
assert clip.order == 1
|
||||
assert clip.min_duration == 0.0
|
||||
assert clip.max_duration == 0.0
|
||||
assert clip.text_template == ""
|
||||
assert clip.material_requirements == {}
|
||||
assert clip.transition_effect == TransitionEffect.CUT
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl_002",
|
||||
clip_type=ClipType.INTRO,
|
||||
order=2,
|
||||
min_duration=3.0,
|
||||
max_duration=10.0,
|
||||
text_template="欢迎来到{channel}",
|
||||
material_requirements={"type": "video", "min_count": 1},
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"key": "value"},
|
||||
)
|
||||
assert clip.clip_type == ClipType.INTRO
|
||||
assert clip.min_duration == 3.0
|
||||
assert clip.max_duration == 10.0
|
||||
assert clip.text_template == "欢迎来到{channel}"
|
||||
assert clip.material_requirements == {"type": "video", "min_count": 1}
|
||||
assert clip.transition_effect == TransitionEffect.FADE
|
||||
assert clip.config == {"key": "value"}
|
||||
|
||||
def test_create_with_string_clip_type(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl_003", clip_type="title", order=1)
|
||||
assert clip.clip_type == ClipType.TITLE
|
||||
|
||||
def test_create_with_string_transition_effect(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl_004",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
transition_effect="dissolve",
|
||||
)
|
||||
assert clip.transition_effect == TransitionEffect.DISSOLVE
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id=" tpl_005 ",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
text_template=" 测试模板 ",
|
||||
)
|
||||
assert clip.template_id == "tpl_005"
|
||||
assert clip.text_template == "测试模板"
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id"):
|
||||
TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1)
|
||||
|
||||
def test_create_whitespace_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id"):
|
||||
TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1)
|
||||
|
||||
def test_create_invalid_clip_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TemplateClipConfig.create(template_id="tpl", clip_type="invalid_type", order=1)
|
||||
|
||||
def test_create_invalid_transition_effect_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TemplateClipConfig.create(
|
||||
template_id="tpl",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
transition_effect="invalid_effect",
|
||||
)
|
||||
|
||||
def test_create_negative_min_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="min_duration"):
|
||||
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=-1.0)
|
||||
|
||||
def test_create_negative_max_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="max_duration"):
|
||||
TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=-1.0)
|
||||
|
||||
def test_create_min_greater_than_max_raises(self):
|
||||
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="tpl",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=10.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
|
||||
def test_create_min_equals_max_ok(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
assert clip.min_duration == 5.0
|
||||
assert clip.max_duration == 5.0
|
||||
|
||||
def test_create_zero_duration_range_ok(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
|
||||
assert clip.min_duration == 0.0
|
||||
assert clip.max_duration == 0.0
|
||||
|
||||
def test_create_none_material_requirements_defaults_to_empty_dict(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl", clip_type=ClipType.MAIN, order=1, material_requirements=None
|
||||
)
|
||||
assert clip.material_requirements == {}
|
||||
|
||||
def test_create_none_config_defaults_to_empty_dict(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, config=None)
|
||||
assert clip.config == {}
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
c1 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
|
||||
c2 = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=2)
|
||||
assert c1.id != c2.id
|
||||
|
||||
def test_create_timestamps_are_utc(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
|
||||
assert clip.created_at.tzinfo is not None
|
||||
assert clip.updated_at.tzinfo is not None
|
||||
|
||||
|
||||
class TestTemplateClipConfigProperties:
|
||||
"""属性方法测试."""
|
||||
|
||||
def test_has_duration_range_false_when_both_zero(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
|
||||
assert clip.has_duration_range is False
|
||||
|
||||
def test_has_duration_range_true_when_min_set(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=2.0)
|
||||
assert clip.has_duration_range is True
|
||||
|
||||
def test_has_duration_range_true_when_max_set(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
|
||||
assert clip.has_duration_range is True
|
||||
|
||||
def test_default_duration_both_zero(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1)
|
||||
assert clip.default_duration == 0.0
|
||||
|
||||
def test_default_duration_only_min(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0)
|
||||
assert clip.default_duration == 5.0
|
||||
|
||||
def test_default_duration_only_max(self):
|
||||
clip = TemplateClipConfig.create(template_id="tpl", clip_type=ClipType.MAIN, order=1, max_duration=10.0)
|
||||
assert clip.default_duration == 10.0
|
||||
|
||||
def test_default_duration_both_set_is_midpoint(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=15.0
|
||||
)
|
||||
assert clip.default_duration == 10.0
|
||||
|
||||
def test_default_duration_min_equals_max(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="tpl", clip_type=ClipType.MAIN, order=1, min_duration=5.0, max_duration=5.0
|
||||
)
|
||||
assert clip.default_duration == 5.0
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
"""transition_presets 模块单元测试."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
from domain.transition_presets import (
|
||||
TRANSITION_PRESET_LIBRARY,
|
||||
TransitionPreset,
|
||||
get_default_transition,
|
||||
get_transition_preset,
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
|
||||
class TestTransitionPreset:
|
||||
"""TransitionPreset 数据类测试."""
|
||||
|
||||
def test_create_required_fields(self):
|
||||
t = TransitionPreset(id="test_001", name="测试转场", category="basic")
|
||||
assert t.id == "test_001"
|
||||
assert t.name == "测试转场"
|
||||
assert t.category == "basic"
|
||||
# 默认值
|
||||
assert t.description == ""
|
||||
assert t.tags == []
|
||||
assert t.transition == "fade"
|
||||
assert t.default_duration == 0.5
|
||||
assert t.min_duration == 0.1
|
||||
assert t.max_duration == 3.0
|
||||
assert t.has_custom_params is False
|
||||
|
||||
def test_create_all_fields(self):
|
||||
t = TransitionPreset(
|
||||
id="test_002",
|
||||
name="完整转场",
|
||||
category="slide",
|
||||
description="测试描述",
|
||||
tags=["标签1", "标签2"],
|
||||
transition="slideleft",
|
||||
default_duration=1.0,
|
||||
min_duration=0.3,
|
||||
max_duration=2.5,
|
||||
has_custom_params=True,
|
||||
)
|
||||
assert t.category == "slide"
|
||||
assert t.description == "测试描述"
|
||||
assert t.tags == ["标签1", "标签2"]
|
||||
assert t.transition == "slideleft"
|
||||
assert t.default_duration == 1.0
|
||||
assert t.min_duration == 0.3
|
||||
assert t.max_duration == 2.5
|
||||
assert t.has_custom_params is True
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
t = TransitionPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
t.name = "修改" # type: ignore[misc]
|
||||
|
||||
def test_tags_default_new_list(self):
|
||||
t1 = TransitionPreset(id="1", name="a", category="basic")
|
||||
t2 = TransitionPreset(id="2", name="b", category="basic")
|
||||
assert t1.tags is not t2.tags
|
||||
assert t1.tags == []
|
||||
|
||||
|
||||
class TestTransitionPresetLibrary:
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库测试."""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(TRANSITION_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_unique_ids(self):
|
||||
ids = [t.id for t in TRANSITION_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids)), "转场 ID 不能重复"
|
||||
|
||||
def test_all_are_transition_preset_instances(self):
|
||||
for t in TRANSITION_PRESET_LIBRARY:
|
||||
assert isinstance(t, TransitionPreset)
|
||||
|
||||
def test_contains_basic_categories(self):
|
||||
cats = {t.category for t in TRANSITION_PRESET_LIBRARY}
|
||||
assert "basic" in cats
|
||||
assert "fade" in cats
|
||||
|
||||
def test_duration_constraints_valid(self):
|
||||
"""每个预设的 min <= default <= max."""
|
||||
for t in TRANSITION_PRESET_LIBRARY:
|
||||
assert t.min_duration <= t.default_duration, f"{t.id}: min > default"
|
||||
assert t.default_duration <= t.max_duration, f"{t.id}: default > max"
|
||||
|
||||
def test_none_transition_zero_duration(self):
|
||||
t = get_transition_preset("transition_none")
|
||||
assert t is not None
|
||||
assert t.default_duration == 0.0
|
||||
assert t.min_duration == 0.0
|
||||
assert t.max_duration == 0.0
|
||||
|
||||
|
||||
class TestGetTransitionPreset:
|
||||
"""get_transition_preset 函数测试."""
|
||||
|
||||
def test_existing_id(self):
|
||||
t = get_transition_preset("transition_fade")
|
||||
assert t is not None
|
||||
assert t.id == "transition_fade"
|
||||
assert t.name == "淡入淡出"
|
||||
assert t.category == "fade"
|
||||
|
||||
def test_nonexistent_id(self):
|
||||
assert get_transition_preset("nonexistent") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert get_transition_preset("") is None
|
||||
|
||||
|
||||
class TestListTransitionPresets:
|
||||
"""list_transition_presets 函数测试."""
|
||||
|
||||
def test_no_filters_returns_all(self):
|
||||
result = list_transition_presets()
|
||||
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category_basic(self):
|
||||
result = list_transition_presets(category="basic")
|
||||
assert len(result) >= 2
|
||||
for t in result:
|
||||
assert t.category == "basic"
|
||||
|
||||
def test_filter_by_category_fade(self):
|
||||
result = list_transition_presets(category="fade")
|
||||
assert len(result) >= 3
|
||||
for t in result:
|
||||
assert t.category == "fade"
|
||||
|
||||
def test_filter_by_unknown_category_returns_empty(self):
|
||||
result = list_transition_presets(category="nonexistent")
|
||||
assert result == []
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
result = list_transition_presets(keyword="淡入")
|
||||
assert len(result) >= 1
|
||||
assert any(t.name == "淡入淡出" for t in result)
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
result = list_transition_presets(keyword="经典")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_filter_by_keyword_tag(self):
|
||||
result = list_transition_presets(keyword="电影感")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_filter_keyword_case_insensitive(self):
|
||||
r1 = list_transition_presets(keyword="FADE")
|
||||
r2 = list_transition_presets(keyword="fade")
|
||||
assert len(r1) == len(r2)
|
||||
|
||||
def test_filter_keyword_no_match(self):
|
||||
result = list_transition_presets(keyword="xyz_nonexistent_12345")
|
||||
assert result == []
|
||||
|
||||
def test_combined_category_and_keyword(self):
|
||||
result = list_transition_presets(category="fade", keyword="黑场")
|
||||
assert len(result) >= 1
|
||||
for t in result:
|
||||
assert t.category == "fade"
|
||||
|
||||
def test_combined_no_match(self):
|
||||
result = list_transition_presets(category="basic", keyword="黑场")
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetDefaultTransition:
|
||||
"""get_default_transition 函数测试."""
|
||||
|
||||
def test_returns_none_transition(self):
|
||||
t = get_default_transition()
|
||||
assert t.id == "transition_none"
|
||||
assert t.name == "无转场"
|
||||
|
||||
def test_returns_transition_preset_instance(self):
|
||||
assert isinstance(get_default_transition(), TransitionPreset)
|
||||
Executable
+393
@@ -0,0 +1,393 @@
|
||||
"""tts_job 领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
from domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
|
||||
|
||||
|
||||
class TestTTSJobStatus:
|
||||
"""TTSJobStatus 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert TTSJobStatus.PENDING == "pending"
|
||||
assert TTSJobStatus.PROCESSING == "processing"
|
||||
assert TTSJobStatus.COMPLETED == "completed"
|
||||
assert TTSJobStatus.FAILED == "failed"
|
||||
assert TTSJobStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_terminal_statuses(self):
|
||||
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
|
||||
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
class TestTTSJobCreate:
|
||||
"""TTSJob.create 工厂方法测试."""
|
||||
|
||||
def test_create_with_required_fields(self):
|
||||
job = TTSJob.create(user_id="user_001", input_text="你好世界")
|
||||
assert job.id
|
||||
assert len(job.id) == 32
|
||||
assert job.user_id == "user_001"
|
||||
assert job.input_text == "你好世界"
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.voice_id == ""
|
||||
assert job.sample_rate == 22050
|
||||
assert job.format == "mp3"
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.metadata == {}
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
job = TTSJob.create(
|
||||
user_id="user_002",
|
||||
input_text="测试文本",
|
||||
voice_id="voice_001",
|
||||
voice_model="cosyvoice",
|
||||
project_id="proj_001",
|
||||
voice_clone_profile_id="clone_001",
|
||||
sample_rate=16000,
|
||||
format="wav",
|
||||
max_retries=5,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.voice_clone_profile_id == "clone_001"
|
||||
assert job.sample_rate == 16000
|
||||
assert job.format == "wav"
|
||||
assert job.max_retries == 5
|
||||
assert job.metadata == {"key": "value"}
|
||||
|
||||
def test_create_strips_strings(self):
|
||||
job = TTSJob.create(
|
||||
user_id=" user_003 ",
|
||||
input_text=" 测试文本 ",
|
||||
voice_id=" voice_001 ",
|
||||
voice_model=" cosyvoice ",
|
||||
project_id=" proj_001 ",
|
||||
voice_clone_profile_id=" clone_001 ",
|
||||
format="wav",
|
||||
)
|
||||
assert job.user_id == "user_003"
|
||||
assert job.input_text == "测试文本"
|
||||
assert job.voice_id == "voice_001"
|
||||
assert job.voice_model == "cosyvoice"
|
||||
assert job.project_id == "proj_001"
|
||||
assert job.voice_clone_profile_id == "clone_001"
|
||||
assert job.format == "wav"
|
||||
|
||||
def test_create_empty_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
TTSJob.create(user_id="", input_text="test")
|
||||
|
||||
def test_create_whitespace_user_id_raises(self):
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
TTSJob.create(user_id=" ", input_text="test")
|
||||
|
||||
def test_create_empty_input_text_raises(self):
|
||||
with pytest.raises(ValueError, match="input_text"):
|
||||
TTSJob.create(user_id="u", input_text="")
|
||||
|
||||
def test_create_input_text_too_long_raises(self):
|
||||
long_text = "a" * 10001
|
||||
with pytest.raises(ValueError, match="10000"):
|
||||
TTSJob.create(user_id="u", input_text=long_text)
|
||||
|
||||
def test_create_input_text_at_limit_ok(self):
|
||||
text = "a" * 10000
|
||||
job = TTSJob.create(user_id="u", input_text=text)
|
||||
assert job.input_text == text
|
||||
|
||||
def test_create_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError, match="不支持的输出格式"):
|
||||
TTSJob.create(user_id="u", input_text="t", format="flac")
|
||||
|
||||
def test_create_supported_formats(self):
|
||||
for fmt in ["mp3", "wav", "pcm"]:
|
||||
job = TTSJob.create(user_id="u", input_text="t", format=fmt)
|
||||
assert job.format == fmt
|
||||
|
||||
def test_create_none_metadata_defaults_to_empty_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", metadata=None)
|
||||
assert job.metadata == {}
|
||||
|
||||
def test_create_ids_are_unique(self):
|
||||
j1 = TTSJob.create(user_id="u", input_text="t")
|
||||
j2 = TTSJob.create(user_id="u", input_text="t")
|
||||
assert j1.id != j2.id
|
||||
|
||||
|
||||
class TestTTSJobStateMachine:
|
||||
"""TTSJob 状态机测试."""
|
||||
|
||||
@pytest.fixture
|
||||
def pending_job(self):
|
||||
return TTSJob.create(user_id="user_001", input_text="测试")
|
||||
|
||||
def test_initial_status_is_pending(self, pending_job):
|
||||
assert pending_job.status == TTSJobStatus.PENDING
|
||||
assert not pending_job.is_terminal
|
||||
|
||||
def test_pending_to_processing(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
assert pending_job.status == TTSJobStatus.PROCESSING
|
||||
assert pending_job.started_at is not None
|
||||
assert pending_job.error_message == ""
|
||||
|
||||
def test_pending_can_fail_directly(self, pending_job):
|
||||
"""pending 可以直接到 failed(比如入参校验失败)"""
|
||||
pending_job.mark_failed("校验失败")
|
||||
assert pending_job.status == TTSJobStatus.FAILED
|
||||
assert pending_job.error_message == "校验失败"
|
||||
|
||||
def test_pending_can_be_cancelled(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_processing_to_completed(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert pending_job.status == TTSJobStatus.COMPLETED
|
||||
assert pending_job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert pending_job.completed_at is not None
|
||||
assert pending_job.error_message == ""
|
||||
|
||||
def test_processing_to_failed(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_failed("API 超时")
|
||||
assert pending_job.status == TTSJobStatus.FAILED
|
||||
assert pending_job.error_message == "API 超时"
|
||||
|
||||
def test_processing_can_be_cancelled(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.status == TTSJobStatus.CANCELLED
|
||||
|
||||
def test_completed_is_terminal(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert pending_job.is_terminal
|
||||
assert pending_job.is_completed
|
||||
|
||||
def test_failed_is_terminal_but_retryable(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_failed("error")
|
||||
assert pending_job.is_terminal
|
||||
assert pending_job.is_retryable
|
||||
|
||||
def test_cancelled_is_terminal_and_not_retryable(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
assert pending_job.is_terminal
|
||||
assert not pending_job.is_retryable
|
||||
|
||||
def test_invalid_transition_completed_to_processing_raises(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
pending_job.mark_processing()
|
||||
|
||||
def test_invalid_transition_completed_to_failed_raises(self, pending_job):
|
||||
pending_job.mark_processing()
|
||||
pending_job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
pending_job.mark_failed("test")
|
||||
|
||||
def test_cancelled_cannot_transition(self, pending_job):
|
||||
pending_job.mark_cancelled()
|
||||
with pytest.raises(ValueError):
|
||||
pending_job.mark_processing()
|
||||
with pytest.raises(ValueError):
|
||||
pending_job.mark_failed("test")
|
||||
|
||||
def test_transition_to_with_string(self, pending_job):
|
||||
"""transition_to 支持字符串参数"""
|
||||
pending_job.transition_to("processing")
|
||||
assert pending_job.status == TTSJobStatus.PROCESSING
|
||||
|
||||
def test_transition_to_invalid_string_raises(self, pending_job):
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
pending_job.transition_to("invalid_status")
|
||||
|
||||
def test_state_transition_updates_updated_at(self, pending_job):
|
||||
old_updated = pending_job.updated_at
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
pending_job.mark_processing()
|
||||
assert pending_job.updated_at > old_updated
|
||||
|
||||
|
||||
class TestTTSJobRetry:
|
||||
"""TTSJob 重试逻辑测试."""
|
||||
|
||||
def test_failed_can_retry(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=3)
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
assert job.is_retryable
|
||||
assert job.retry_count == 0
|
||||
|
||||
def test_prepare_retry_resets_to_pending(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_failed("error")
|
||||
|
||||
job.prepare_retry()
|
||||
assert job.status == TTSJobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
def test_retry_up_to_max_retries(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=2)
|
||||
# 第 1 次失败 + 重试 → retry_count=1,还可以重试
|
||||
job.mark_processing()
|
||||
job.mark_failed("e1")
|
||||
assert job.is_retryable
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 1
|
||||
|
||||
# 第 2 次失败 → retry_count=1,还是 failed 状态,还可以重试(max_retries=2)
|
||||
job.mark_processing()
|
||||
job.mark_failed("e2")
|
||||
assert job.is_retryable # retry_count=1 < max_retries=2
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 2
|
||||
|
||||
# 第 3 次失败 → retry_count=2,达到上限,不可重试
|
||||
job.mark_processing()
|
||||
job.mark_failed("e3")
|
||||
assert not job.is_retryable # retry_count=2 == max_retries=2
|
||||
|
||||
def test_retry_exceed_max_raises(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t", max_retries=1)
|
||||
job.mark_processing()
|
||||
job.mark_failed("e")
|
||||
job.prepare_retry() # 第 1 次重试,用完了
|
||||
|
||||
job.mark_processing()
|
||||
job.mark_failed("e2")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_pending_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_completed_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert not job.is_retryable
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_cancelled_not_retryable(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_cancelled()
|
||||
assert not job.is_retryable
|
||||
|
||||
|
||||
class TestTTSJobMarkCompleted:
|
||||
"""mark_completed 方法测试."""
|
||||
|
||||
def test_requires_output_url(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
with pytest.raises(ValueError, match="output_audio_url"):
|
||||
job.mark_completed(output_audio_url="")
|
||||
|
||||
def test_sets_all_fields(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://example.com/out.mp3",
|
||||
output_audio_key="audio/001.mp3",
|
||||
duration=30.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert job.output_audio_key == "audio/001.mp3"
|
||||
assert job.duration == 30.5
|
||||
assert job.file_size == 102400
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url=" https://example.com/out.mp3 ",
|
||||
output_audio_key=" audio/001.mp3 ",
|
||||
)
|
||||
assert job.output_audio_url == "https://example.com/out.mp3"
|
||||
assert job.output_audio_key == "audio/001.mp3"
|
||||
|
||||
|
||||
class TestTTSJobIsCompleted:
|
||||
"""is_completed 属性测试."""
|
||||
|
||||
def test_completed_with_url_is_completed(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3")
|
||||
assert job.is_completed
|
||||
|
||||
def test_completed_without_url_not_completed(self):
|
||||
"""极端情况:completed 状态但没有 URL(理论不会发生)"""
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.transition_to(TTSJobStatus.COMPLETED) # 直接转,不设 URL
|
||||
assert not job.is_completed
|
||||
|
||||
def test_pending_not_completed(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
assert not job.is_completed
|
||||
|
||||
|
||||
class TestTTSJobToDict:
|
||||
"""to_dict 序列化测试."""
|
||||
|
||||
def test_pending_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="user_001", input_text="测试文本", voice_id="v001")
|
||||
d = job.to_dict()
|
||||
assert d["id"] == job.id
|
||||
assert d["user_id"] == "user_001"
|
||||
assert d["status"] == "pending"
|
||||
assert d["input_text"] == "测试文本"
|
||||
assert d["voice_id"] == "v001"
|
||||
assert d["retry_count"] == 0
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_completed"] is False
|
||||
assert d["metadata"] == {}
|
||||
assert d["started_at"] is None
|
||||
assert d["completed_at"] is None
|
||||
assert d["created_at"] is not None
|
||||
assert d["updated_at"] is not None
|
||||
|
||||
def test_completed_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_processing()
|
||||
job.mark_completed(output_audio_url="https://example.com/out.mp3", duration=10.0)
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "completed"
|
||||
assert d["output_audio_url"] == "https://example.com/out.mp3"
|
||||
assert d["duration"] == 10.0
|
||||
assert d["is_completed"] is True
|
||||
assert d["started_at"] is not None
|
||||
assert d["completed_at"] is not None
|
||||
|
||||
def test_failed_job_to_dict(self):
|
||||
job = TTSJob.create(user_id="u", input_text="t")
|
||||
job.mark_failed("出错了")
|
||||
d = job.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "出错了"
|
||||
assert d["is_retryable"] is True
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
"""voice_presets 模块单元测试."""
|
||||
|
||||
import pytest
|
||||
from domain.voice_presets import (
|
||||
MOCK_VOICES,
|
||||
VoiceGender,
|
||||
VoicePreset,
|
||||
VoiceStyle,
|
||||
get_default_voice,
|
||||
get_voice,
|
||||
list_voices,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceGender:
|
||||
"""VoiceGender 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert VoiceGender.MALE == "male"
|
||||
assert VoiceGender.FEMALE == "female"
|
||||
assert VoiceGender.CHILD == "child"
|
||||
|
||||
def test_is_str_enum(self):
|
||||
# StrEnum 在不同 Python 版本 str() 行为可能不同(3.11+ 返回值,旧版自定义 StrEnum 可能返回类名)
|
||||
# 用 value 比较更稳妥
|
||||
assert isinstance(VoiceGender.FEMALE, str)
|
||||
assert VoiceGender.MALE.value == "male"
|
||||
assert VoiceGender.FEMALE.value == "female"
|
||||
|
||||
|
||||
class TestVoiceStyle:
|
||||
"""VoiceStyle 枚举测试."""
|
||||
|
||||
def test_values(self):
|
||||
assert VoiceStyle.STABLE == "stable"
|
||||
assert VoiceStyle.LIVELY == "lively"
|
||||
assert VoiceStyle.CUSTOMER_SERVICE == "customer_service"
|
||||
assert VoiceStyle.NARRATION == "narration"
|
||||
assert VoiceStyle.NEWS == "news"
|
||||
assert VoiceStyle.STORY == "story"
|
||||
|
||||
|
||||
class TestVoicePreset:
|
||||
"""VoicePreset 数据类测试."""
|
||||
|
||||
def test_create_with_required_fields(self):
|
||||
v = VoicePreset(voice_id="test_001", name="测试音色")
|
||||
assert v.voice_id == "test_001"
|
||||
assert v.name == "测试音色"
|
||||
# 默认值
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.NARRATION
|
||||
assert v.provider == "mock"
|
||||
assert v.default_speed == 1.0
|
||||
assert v.default_pitch == 0.0
|
||||
assert v.sample_rate == 22050
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
v = VoicePreset(
|
||||
voice_id="male_news",
|
||||
name="新闻男声",
|
||||
gender=VoiceGender.MALE,
|
||||
style=VoiceStyle.NEWS,
|
||||
description="字正腔圆",
|
||||
provider="aliyun",
|
||||
provider_voice_id="zhiqiang",
|
||||
default_speed=0.9,
|
||||
default_pitch=1.0,
|
||||
sample_rate=16000,
|
||||
language="zh-CN",
|
||||
)
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.NEWS
|
||||
assert v.provider == "aliyun"
|
||||
assert v.default_speed == 0.9
|
||||
assert v.sample_rate == 16000
|
||||
|
||||
def test_slots(self):
|
||||
"""dataclass slots=True,不能添加新属性."""
|
||||
v = VoicePreset(voice_id="test", name="测试")
|
||||
with pytest.raises(AttributeError):
|
||||
v.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestMockVoices:
|
||||
"""MOCK_VOICES 预设列表测试."""
|
||||
|
||||
def test_not_empty(self):
|
||||
assert len(MOCK_VOICES) > 0
|
||||
|
||||
def test_all_have_unique_voice_id(self):
|
||||
ids = [v.voice_id for v in MOCK_VOICES]
|
||||
assert len(ids) == len(set(ids)), "voice_id 不能重复"
|
||||
|
||||
def test_all_are_voice_preset_instances(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert isinstance(v, VoicePreset)
|
||||
assert v.provider == "mock"
|
||||
|
||||
def test_contains_expected_voices(self):
|
||||
ids = {v.voice_id for v in MOCK_VOICES}
|
||||
assert "female_warm" in ids
|
||||
assert "male_stable" in ids
|
||||
assert "female_lively" in ids
|
||||
assert "child_cute" in ids
|
||||
|
||||
def test_voice_genders_coverage(self):
|
||||
genders = {v.gender for v in MOCK_VOICES}
|
||||
assert VoiceGender.FEMALE in genders
|
||||
assert VoiceGender.MALE in genders
|
||||
assert VoiceGender.CHILD in genders
|
||||
|
||||
|
||||
class TestGetVoice:
|
||||
"""get_voice 函数测试."""
|
||||
|
||||
def test_existing_mock_voice(self):
|
||||
v = get_voice("female_warm")
|
||||
assert v is not None
|
||||
assert v.voice_id == "female_warm"
|
||||
assert v.name == "温暖女声"
|
||||
|
||||
def test_nonexistent_voice(self):
|
||||
assert get_voice("nonexistent") is None
|
||||
|
||||
def test_provider_mock(self):
|
||||
v = get_voice("male_stable", provider="mock")
|
||||
assert v is not None
|
||||
assert v.voice_id == "male_stable"
|
||||
|
||||
def test_unknown_provider_returns_none(self):
|
||||
assert get_voice("female_warm", provider="aliyun") is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
assert get_voice("") is None
|
||||
|
||||
|
||||
class TestListVoices:
|
||||
"""list_voices 函数测试."""
|
||||
|
||||
def test_no_filters_returns_all(self):
|
||||
result = list_voices()
|
||||
assert len(result) == len(MOCK_VOICES)
|
||||
|
||||
def test_filter_by_gender_female(self):
|
||||
result = list_voices(gender="female")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
|
||||
def test_filter_by_gender_male(self):
|
||||
result = list_voices(gender="male")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.MALE
|
||||
|
||||
def test_filter_by_gender_child(self):
|
||||
result = list_voices(gender="child")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.CHILD
|
||||
|
||||
def test_filter_by_style(self):
|
||||
result = list_voices(style="story")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.style == VoiceStyle.STORY
|
||||
|
||||
def test_filter_by_style_narration(self):
|
||||
result = list_voices(style="narration")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_filter_by_unknown_provider_returns_empty(self):
|
||||
result = list_voices(provider="aliyun")
|
||||
assert result == []
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
result = list_voices(keyword="男声")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert "男声" in v.name or "男声" in v.description
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
result = list_voices(keyword="vlog")
|
||||
assert len(result) > 0
|
||||
|
||||
def test_filter_by_keyword_voice_id(self):
|
||||
result = list_voices(keyword="female")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert "female" in v.voice_id.lower()
|
||||
|
||||
def test_filter_by_keyword_case_insensitive(self):
|
||||
r1 = list_voices(keyword="FEMALE")
|
||||
r2 = list_voices(keyword="female")
|
||||
assert len(r1) == len(r2)
|
||||
|
||||
def test_filter_keyword_no_match(self):
|
||||
result = list_voices(keyword="xyz_nonexistent_keyword")
|
||||
assert result == []
|
||||
|
||||
def test_combined_filters_gender_and_style(self):
|
||||
result = list_voices(gender="female", style="story")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.FEMALE
|
||||
assert v.style == VoiceStyle.STORY
|
||||
|
||||
def test_combined_filters_gender_and_keyword(self):
|
||||
result = list_voices(gender="male", keyword="新闻")
|
||||
assert len(result) > 0
|
||||
for v in result:
|
||||
assert v.gender == VoiceGender.MALE
|
||||
|
||||
def test_combined_no_match(self):
|
||||
result = list_voices(gender="child", style="news")
|
||||
# 童声没有新闻风格
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetDefaultVoice:
|
||||
"""get_default_voice 函数测试."""
|
||||
|
||||
def test_returns_first_mock_voice(self):
|
||||
v = get_default_voice()
|
||||
assert v == MOCK_VOICES[0]
|
||||
assert v.voice_id == "female_warm"
|
||||
|
||||
def test_returns_voice_preset(self):
|
||||
assert isinstance(get_default_voice(), VoicePreset)
|
||||
Reference in New Issue
Block a user