Compare commits

...

10 Commits

Author SHA1 Message Date
xiaoxia 1af1d24d3c chore: trigger ci for #665
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 14m47s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 14m48s
AI Code Review / AI Code Review (pull_request) Failing after 14m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m2s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
2026-07-21 14:04:29 +08:00
CI Bot 10897b3ee1 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:53:54 +08:00
xiaoxia c5a949d502 fix(test): 修复B017宽泛异常捕获 + StrEnum跨版本兼容问题
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
AI Code Review / AI Code Review (pull_request) Successful in 3m13s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 4m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 7m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m55s
1. 将3个frozen测试的 pytest.raises(Exception) 改为 FrozenInstanceError
   (B017 assert-raises-exception 规则)
2. VoiceGender 测试 str() 断言改为 .value 比较
   (不同Python版本StrEnum的str()行为可能不同)
2026-07-21 12:06:32 +08:00
xiaoxia 98e74646f9 chore: trigger ci 2026-07-21 12:06:28 +08:00
CI Bot de53975784 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:06:25 +08:00
CI Bot 0a447b4b68 test(unit): 新增 classification/generated_video 两个模块测试
- classification: 12 个测试(枚举 + ClassificationJob 工厂方法)
- generated_video: 11 个测试(工厂方法 + 参数校验)
2026-07-21 12:06:22 +08:00
CI Bot cad16b3f87 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:06:19 +08:00
CI Bot 5ecc75c5ba test(unit): 新增 filter_presets/template_clip_config 两个模块测试
- filter_presets: 31 个测试,含 build_ffmpeg_filter 强度插值逻辑
- template_clip_config: 28 个测试

本轮 Batch2 共 7 个模块,231 个测试用例
2026-07-21 12:06:15 +08:00
CI Bot 17494a1cde test(unit): 新增 transition_presets/tts_job 两个模块测试
- transition_presets: 32 个测试,100% 覆盖率
- tts_job: 40 个测试,96% 覆盖率

共新增 72 个测试用例
2026-07-21 12:06:11 +08:00
CI Bot 0320bea6b5 test(unit): 新增 voice_presets/preset_bgm/edit_plan_clip 三个模块测试
- voice_presets: 33 个测试,94% 覆盖率
- preset_bgm: 28 个测试,100% 覆盖率
- edit_plan_clip: 39 个测试,96% 覆盖率

共新增 100 个测试用例
2026-07-21 12:06:07 +08:00
9 changed files with 1940 additions and 0 deletions
+104
View File
@@ -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
+262
View File
@@ -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
+230
View File
@@ -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 格式
+141
View File
@@ -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
+190
View File
@@ -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
View File
@@ -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
+181
View File
@@ -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)
+393
View File
@@ -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
+231
View File
@@ -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)