Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot bb8ed8df0c style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m15s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
AI Code Review / AI Code Review (pull_request) Successful in 3m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m6s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m19s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1172h12m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1172h19m50s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1172h27m34s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1172h27m38s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1172h31m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1172h31m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1172h31m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1172h39m7s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1172h39m20s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1172h39m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1172h44m30s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1172h44m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1172h44m31s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1173h0m36s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1173h12m18s
2026-07-29 10:38:08 +00:00
xiaoxia fa991831b1 test(wave183): voice_presets 配音音色预设 +59测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Successful in 1m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1172h52m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1172h52m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1172h52m28s
2026-07-29 18:32:07 +08:00
2 changed files with 368 additions and 280 deletions
-280
View File
@@ -1,280 +0,0 @@
"""VerificationCode 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.id is not None
assert len(vc.id) == 32
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_login"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
assert vc.created_at is not None
assert vc.expires_at > vc.created_at
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_login")
assert vc.recipient == "test@example.com"
def test_create_custom_code(self):
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
assert vc.code == "123456"
def test_create_custom_ttl(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
assert vc.expires_at == fixed_now + timedelta(seconds=60)
def test_create_default_ttl_300(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.expires_at == fixed_now + timedelta(seconds=300)
def test_create_unique_ids(self):
vc1 = VerificationCode.create("a@b.com", "email_login")
vc2 = VerificationCode.create("a@b.com", "email_login")
assert vc1.id != vc2.id
def test_create_unique_codes(self):
codes = set()
for _ in range(20):
vc = VerificationCode.create("a@b.com", "email_login")
codes.add(vc.code)
# 20个随机6位码几乎肯定不都一样
assert len(codes) > 1
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_all_code_types(self):
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
vc = VerificationCode.create("test@example.com", ct)
assert vc.code_type == ct
class TestVerificationCodeIsExpired:
"""is_expired 属性测试."""
def test_not_expired_future(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_expired is False
def test_expired_past(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_expired is True
def test_expired_boundary_exact(self):
# 用mock固定时间,expires_at等于当前时间不算过期
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=fixed_now,
)
assert vc.is_expired is False
class TestVerificationCodeIsUsed:
"""is_used 属性测试."""
def test_not_used_default(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.is_used is False
def test_is_used_after_mark(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
assert vc.is_used is True
class TestVerificationCodeIsValid:
"""is_valid 属性测试."""
def test_valid_fresh(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_valid is True
def test_invalid_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_valid is False
def test_invalid_used(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
vc.mark_used()
assert vc.is_valid is False
def test_invalid_expired_and_used(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeMarkUsed:
"""mark_used 方法测试."""
def test_mark_used_sets_timestamp(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.used_at is None
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert vc.used_at is not None
assert before <= vc.used_at <= after
def test_mark_used_twice_overwrites(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
first = vc.used_at
# 时间足够短,一般不会不同,但确保可以重复调用
vc.mark_used()
assert vc.used_at is not None
class TestVerificationCodeIncrementAttempts:
"""increment_attempts 方法测试."""
def test_default_zero(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.attempts == 0
def test_increment_once(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_multiple(self):
vc = VerificationCode.create("a@b.com", "email_login")
for _i in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeBasics:
"""基础构造和 slots 测试."""
def test_direct_construction(self):
now = datetime.now(timezone.utc)
vc = VerificationCode(
id="abc123",
recipient="test@test.com",
code="000000",
code_type="email_bind",
expires_at=now + timedelta(minutes=5),
used_at=None,
attempts=0,
created_at=now,
)
assert vc.id == "abc123"
assert vc.recipient == "test@test.com"
assert vc.code == "000000"
def test_slots_no_extra_attrs(self):
vc = VerificationCode.create("a@b.com", "email_login")
with pytest.raises((AttributeError, TypeError)):
vc.new_field = "value" # type: ignore[attr-defined]
def test_equality_same_id(self):
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
vc1 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
vc2 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
assert vc1 == vc2
def test_equality_different_id(self):
now = datetime.now(timezone.utc)
vc1 = VerificationCode(
id="id1",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
vc2 = VerificationCode(
id="id2",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
assert vc1 != vc2
+368
View File
@@ -0,0 +1,368 @@
"""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_enum_values(self):
assert VoiceGender.MALE.value == "male"
assert VoiceGender.FEMALE.value == "female"
assert VoiceGender.CHILD.value == "child"
def test_is_str(self):
assert isinstance(VoiceGender.MALE, str)
assert VoiceGender.FEMALE == "female"
def test_from_string(self):
assert VoiceGender("male") == VoiceGender.MALE
assert VoiceGender("child") == VoiceGender.CHILD
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceGender("unknown")
class TestVoiceStyle:
"""VoiceStyle 音色风格枚举"""
def test_enum_values(self):
assert VoiceStyle.STABLE.value == "stable"
assert VoiceStyle.LIVELY.value == "lively"
assert VoiceStyle.CUSTOMER_SERVICE.value == "customer_service"
assert VoiceStyle.NARRATION.value == "narration"
assert VoiceStyle.NEWS.value == "news"
assert VoiceStyle.STORY.value == "story"
def test_is_str(self):
assert isinstance(VoiceStyle.NARRATION, str)
assert VoiceStyle.STORY == "story"
def test_from_string(self):
assert VoiceStyle("news") == VoiceStyle.NEWS
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceStyle("rock")
# ── VoicePreset dataclass ─────────────────────────────────────────────────────
class TestVoicePreset:
"""VoicePreset 音色预设 dataclass"""
def test_minimal_creation(self):
v = VoicePreset(voice_id="test_voice", name="测试音色")
assert v.voice_id == "test_voice"
assert v.name == "测试音色"
# 默认值
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.description == ""
assert v.provider == "mock"
assert v.provider_voice_id == ""
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_full_creation(self):
v = VoicePreset(
voice_id="male_deep",
name="深沉男声",
gender=VoiceGender.MALE,
style=VoiceStyle.STABLE,
description="非常深沉的男声",
provider="aliyun",
provider_voice_id="zhiyuan",
default_speed=0.8,
default_pitch=-1.0,
sample_rate=16000,
language="zh-CN",
)
assert v.voice_id == "male_deep"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.provider == "aliyun"
assert v.default_speed == 0.8
assert v.sample_rate == 16000
def test_str_gender_creation(self):
# 用字符串值创建也可以(因为是 StrEnum)
v = VoicePreset(voice_id="v1", name="V1", gender="male")
assert v.gender == VoiceGender.MALE
def test_str_style_creation(self):
v = VoicePreset(voice_id="v1", name="V1", style="news")
assert v.style == VoiceStyle.NEWS
def test_equality(self):
v1 = VoicePreset(voice_id="same", name="同名")
v2 = VoicePreset(voice_id="same", name="同名")
assert v1 == v2
def test_inequality(self):
v1 = VoicePreset(voice_id="a", name="A")
v2 = VoicePreset(voice_id="b", name="B")
assert v1 != v2
def test_slots_no_extra_attrs(self):
v = VoicePreset(voice_id="test", name="Test")
with pytest.raises(AttributeError):
v.nonexistent_field = "value"
# ── MOCK_VOICES 列表 ─────────────────────────────────────────────────────────
class TestMockVoices:
"""Mock 音色预设列表"""
def test_not_empty(self):
assert len(MOCK_VOICES) > 0
def test_count(self):
assert len(MOCK_VOICES) == 8
def test_all_are_voice_preset(self):
for v in MOCK_VOICES:
assert isinstance(v, VoicePreset)
def test_unique_voice_ids(self):
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids))
def test_female_warm_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_warm")
assert v.name == "温暖女声"
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.default_speed == 1.0
assert "温柔" in v.description
def test_male_stable_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "male_stable")
assert v.name == "沉稳男声"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.default_speed == 0.9
def test_female_lively_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_lively")
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.LIVELY
assert v.default_speed == 1.2
assert v.default_pitch == 2.0
def test_child_cute_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "child_cute")
assert v.gender == VoiceGender.CHILD
assert v.style == VoiceStyle.STORY
assert v.default_pitch == 4.0
def test_all_mock_provider(self):
for v in MOCK_VOICES:
assert v.provider == "mock"
def test_all_have_provider_voice_id(self):
for v in MOCK_VOICES:
assert v.provider_voice_id != ""
def test_all_chinese(self):
for v in MOCK_VOICES:
assert v.language == "zh-CN"
# ── get_voice ─────────────────────────────────────────────────────────────────
class TestGetVoice:
"""get_voice 函数"""
def test_get_existing_voice(self):
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_get_male_stable(self):
v = get_voice("male_stable")
assert v is not None
assert v.gender == VoiceGender.MALE
def test_get_child_cute(self):
v = get_voice("child_cute")
assert v is not None
assert v.gender == VoiceGender.CHILD
def test_get_nonexistent_returns_none(self):
v = get_voice("nonexistent_voice")
assert v is None
def test_get_empty_string_returns_none(self):
v = get_voice("")
assert v is None
def test_non_mock_provider_returns_none(self):
v = get_voice("female_warm", provider="aliyun")
assert v is None
def test_non_mock_provider_nonexistent(self):
v = get_voice("whatever", provider="xunfei")
assert v is None
def test_mock_provider_explicit(self):
v = get_voice("female_warm", provider="mock")
assert v is not None
assert v.voice_id == "female_warm"
def test_returns_same_instance(self):
# 应该返回同一个对象(缓存的)
v1 = get_voice("female_warm")
v2 = get_voice("female_warm")
assert v1 is v2
# ── list_voices ───────────────────────────────────────────────────────────────
class TestListVoices:
"""list_voices 函数"""
def test_no_filters_returns_all(self):
result = list_voices()
assert len(result) == len(MOCK_VOICES)
assert len(result) == 8
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_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_child(self):
result = list_voices(gender="child")
assert len(result) == 1
assert result[0].voice_id == "child_cute"
def test_filter_by_gender_invalid_returns_empty(self):
result = list_voices(gender="alien")
assert len(result) == 0
def test_filter_by_style_stable(self):
result = list_voices(style="stable")
assert len(result) > 0
for v in result:
assert v.style == VoiceStyle.STABLE
def test_filter_by_style_lively(self):
result = list_voices(style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_filter_by_style_story(self):
result = list_voices(style="story")
assert len(result) >= 2
for v in result:
assert v.style == VoiceStyle.STORY
def test_filter_by_style_invalid_returns_empty(self):
result = list_voices(style="punk")
assert len(result) == 0
def test_filter_by_provider_mock(self):
result = list_voices(provider="mock")
assert len(result) == len(MOCK_VOICES)
def test_filter_by_provider_other_returns_empty(self):
result = list_voices(provider="aliyun")
assert len(result) == 0
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 or "女声" in v.voice_id
def test_filter_by_keyword_description(self):
result = list_voices(keyword="商务")
assert len(result) > 0
# 沉稳男声描述里有"商务"
def test_filter_by_keyword_voice_id(self):
result = list_voices(keyword="male_stable")
assert len(result) == 1
assert result[0].voice_id == "male_stable"
def test_filter_by_keyword_case_insensitive(self):
result1 = list_voices(keyword="Female")
result2 = list_voices(keyword="female")
assert len(result1) == len(result2)
def test_filter_by_keyword_nonexistent(self):
result = list_voices(keyword="不存在的关键词999")
assert len(result) == 0
def test_combined_gender_and_style(self):
result = list_voices(gender="female", style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_combined_gender_style_keyword(self):
result = list_voices(gender="male", style="story", keyword="磁性")
assert len(result) == 1
assert result[0].voice_id == "male_magnetic"
def test_combined_no_match(self):
result = list_voices(gender="child", style="news")
assert len(result) == 0
def test_returns_new_list(self):
# 修改返回值不应影响原始列表
result = list_voices()
result.clear()
assert len(MOCK_VOICES) == 8
# ── get_default_voice ─────────────────────────────────────────────────────────
class TestGetDefaultVoice:
"""get_default_voice 函数"""
def test_returns_voice_preset(self):
v = get_default_voice()
assert isinstance(v, VoicePreset)
def test_returns_first_mock_voice(self):
v = get_default_voice()
assert v == MOCK_VOICES[0]
def test_default_is_female_warm(self):
v = get_default_voice()
assert v.voice_id == "female_warm"
assert v.gender == VoiceGender.FEMALE
def test_multiple_calls_same(self):
v1 = get_default_voice()
v2 = get_default_voice()
assert v1 is v2