test: P3-1 第48波单元测试(tag/verification_code/subtitle/voice_presets,+59) #836
Executable
+199
@@ -0,0 +1,199 @@
|
||||
"""Subtitle 领域模型单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.subtitle import SubtitleWord, SubtitleSegment, SubtitleTimeline
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
def test_duration_normal(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="啊", start=3.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
word = SubtitleWord(text="test", start=5.0, end=3.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=3.0)
|
||||
assert seg.duration == pytest.approx(3.0)
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="今天天气真好", start=0.0, end=5.0)
|
||||
assert seg.char_count == 6
|
||||
|
||||
def test_empty_text(self):
|
||||
seg = SubtitleSegment(text="", start=0.0, end=1.0)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_default_words_empty(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
assert seg.words == []
|
||||
|
||||
|
||||
class TestSubtitleTimeline:
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
assert tl.total_chars == 0
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="第一段", start=0.0, end=2.0),
|
||||
SubtitleSegment(text="第二段", start=2.0, end=5.0),
|
||||
])
|
||||
assert tl.segment_count == 2
|
||||
assert tl.total_chars == 6
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="abc", start=0, end=1),
|
||||
SubtitleSegment(text="defg", start=1, end=2),
|
||||
])
|
||||
assert tl.total_chars == 7
|
||||
|
||||
|
||||
class TestMergeShortSegments:
|
||||
def test_single_segment_no_change(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
])
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_merge_short_segments(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="今天", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="天气", start=2.0, end=3.0),
|
||||
SubtitleSegment(text="真好", start=3.0, end=4.0),
|
||||
])
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# 每段2字,min=4,应该每2段合并
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "你好今天"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 2.0
|
||||
assert result.segments[1].text == "天气真好"
|
||||
assert result.segments[1].start == 2.0
|
||||
assert result.segments[1].end == 4.0
|
||||
|
||||
def test_remaining_merged_to_last(self):
|
||||
# 3段,每段2字,min=5 → 前5字合并,剩余1字并到最后
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="一二", start=0, end=1),
|
||||
SubtitleSegment(text="三四", start=1, end=2),
|
||||
SubtitleSegment(text="五", start=2, end=3),
|
||||
])
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五"
|
||||
|
||||
def test_merge_with_words(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[
|
||||
SubtitleWord(text="你", start=0.0, end=0.5),
|
||||
SubtitleWord(text="好", start=0.5, end=1.0),
|
||||
]),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[
|
||||
SubtitleWord(text="世", start=1.0, end=1.5),
|
||||
SubtitleWord(text="界", start=1.5, end=2.0),
|
||||
]),
|
||||
])
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
def test_short_segments_no_split(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="短文本", start=0.0, end=1.0),
|
||||
])
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_split_by_punctuation(self):
|
||||
text = "今天天气真好。我们出去玩吧!"
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=5.0),
|
||||
])
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 合并起来应该等于原文
|
||||
assert "".join(s.text for s in result.segments) == text
|
||||
|
||||
def test_split_preserves_time_order(self):
|
||||
tl = SubtitleTimeline(segments=[
|
||||
SubtitleSegment(text="一二三四五六七八九十。十一二三四五六七八九十。", start=0.0, end=10.0),
|
||||
])
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 时间应该是递增的
|
||||
for i in range(len(result.segments) - 1):
|
||||
assert result.segments[i].end <= result.segments[i + 1].start + 0.001
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
def test_no_punctuation_short(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 20)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "你好世界"
|
||||
|
||||
def test_sentence_end_punctuation_long_enough(self):
|
||||
# 每段超过 max_chars//2 才会在句末标点断开
|
||||
text = "今天天气真的非常好。明天天气也不错。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_short_text_with_punctuation_no_split(self):
|
||||
# 文本太短(< max_chars//2),即使有标点也不断开
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 20)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_long_text_hard_split(self):
|
||||
text = "一二三四五六七八九十十一二三四五六七八九十"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_empty_text(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
def test_merge_two_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
"""Tag 领域实体单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tag import Tag
|
||||
|
||||
|
||||
class TestTagCreate:
|
||||
def test_create_normal(self):
|
||||
tag = Tag.create(user_id="user1", name=" 美食 ")
|
||||
assert tag.id
|
||||
assert tag.user_id == "user1"
|
||||
assert tag.name == "美食" # 自动 strip
|
||||
assert tag.created_at is not None
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user1", name=" ")
|
||||
|
||||
def test_create_generates_unique_ids(self):
|
||||
tag1 = Tag.create(user_id="u1", name="tag1")
|
||||
tag2 = Tag.create(user_id="u1", name="tag2")
|
||||
assert tag1.id != tag2.id
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
"""VerificationCode 领域实体单测."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
def test_create_default_ttl(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.id
|
||||
assert code.recipient == "test@example.com"
|
||||
assert code.code_type == "email_bind"
|
||||
assert len(code.code) == 6
|
||||
assert code.code.isdigit()
|
||||
assert code.used_at is None
|
||||
assert code.attempts == 0
|
||||
# 默认5分钟过期
|
||||
assert code.expires_at > code.created_at
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(300, abs=1)
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
code = VerificationCode.create(recipient="13800138000", code_type="phone_login", ttl_seconds=60)
|
||||
assert (code.expires_at - code.created_at).total_seconds() == pytest.approx(60, abs=1)
|
||||
|
||||
def test_create_custom_code(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="reset_password", custom_code="123456")
|
||||
assert code.code == "123456"
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
code = VerificationCode.create(recipient=" test@example.com ", code_type="email_bind")
|
||||
assert code.recipient == "test@example.com"
|
||||
|
||||
|
||||
class TestVerificationCodeStatus:
|
||||
def test_is_valid_initial(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.is_valid is True
|
||||
assert code.is_expired is False
|
||||
assert code.is_used is False
|
||||
|
||||
def test_mark_used(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
code.mark_used()
|
||||
assert code.is_used is True
|
||||
assert code.used_at is not None
|
||||
assert code.is_valid is False
|
||||
|
||||
def test_is_expired_future(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=3600)
|
||||
assert code.is_expired is False
|
||||
|
||||
def test_increment_attempts(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind")
|
||||
assert code.attempts == 0
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 1
|
||||
code.increment_attempts()
|
||||
assert code.attempts == 2
|
||||
|
||||
def test_is_valid_after_expired(self):
|
||||
code = VerificationCode.create(recipient="test@example.com", code_type="email_bind", ttl_seconds=0)
|
||||
# 0秒TTL,立即可能过期(有极小概率因时间差没过)
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
assert code.is_expired is True
|
||||
assert code.is_valid is False
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
"""voice_presets 音色预设单测."""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_presets import (
|
||||
MOCK_VOICES,
|
||||
VoiceGender,
|
||||
VoicePreset,
|
||||
VoiceStyle,
|
||||
get_default_voice,
|
||||
get_voice,
|
||||
list_voices,
|
||||
)
|
||||
|
||||
|
||||
class TestVoiceGender:
|
||||
def test_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.FEMALE, str)
|
||||
|
||||
|
||||
class TestVoiceStyle:
|
||||
def test_values(self):
|
||||
assert VoiceStyle.STABLE.value == "stable"
|
||||
assert VoiceStyle.LIVELY.value == "lively"
|
||||
assert VoiceStyle.NARRATION.value == "narration"
|
||||
assert VoiceStyle.NEWS.value == "news"
|
||||
assert VoiceStyle.STORY.value == "story"
|
||||
|
||||
|
||||
class TestVoicePreset:
|
||||
def test_default_values(self):
|
||||
v = VoicePreset(voice_id="test", 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_custom_values(self):
|
||||
v = VoicePreset(
|
||||
voice_id="male1",
|
||||
name="男声",
|
||||
gender=VoiceGender.MALE,
|
||||
style=VoiceStyle.STABLE,
|
||||
provider="aliyun",
|
||||
default_speed=0.9,
|
||||
)
|
||||
assert v.gender == VoiceGender.MALE
|
||||
assert v.style == VoiceStyle.STABLE
|
||||
assert v.provider == "aliyun"
|
||||
assert v.default_speed == 0.9
|
||||
|
||||
|
||||
class TestMockVoices:
|
||||
def test_mock_voices_not_empty(self):
|
||||
assert len(MOCK_VOICES) > 0
|
||||
|
||||
def test_all_mock_voices_have_ids(self):
|
||||
for v in MOCK_VOICES:
|
||||
assert v.voice_id
|
||||
assert v.name
|
||||
assert v.provider == "mock"
|
||||
|
||||
def test_unique_voice_ids(self):
|
||||
ids = [v.voice_id for v in MOCK_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
class TestGetVoice:
|
||||
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_nonexistent_voice(self):
|
||||
v = get_voice("nonexistent")
|
||||
assert v is None
|
||||
|
||||
def test_non_mock_provider_returns_none(self):
|
||||
v = get_voice("female_warm", provider="aliyun")
|
||||
assert v is None
|
||||
|
||||
|
||||
class TestListVoices:
|
||||
def test_list_all(self):
|
||||
voices = list_voices()
|
||||
assert len(voices) == len(MOCK_VOICES)
|
||||
|
||||
def test_filter_by_gender(self):
|
||||
female_voices = list_voices(gender="female")
|
||||
assert len(female_voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in female_voices)
|
||||
|
||||
def test_filter_by_style(self):
|
||||
story_voices = list_voices(style="story")
|
||||
assert len(story_voices) > 0
|
||||
assert all(v.style == VoiceStyle.STORY for v in story_voices)
|
||||
|
||||
def test_filter_by_keyword_name(self):
|
||||
voices = list_voices(keyword="女声")
|
||||
assert len(voices) > 0
|
||||
assert all("女声" in v.name for v in voices)
|
||||
|
||||
def test_filter_by_keyword_description(self):
|
||||
voices = list_voices(keyword="商务")
|
||||
assert len(voices) > 0
|
||||
assert any("商务" in v.description for v in voices)
|
||||
|
||||
def test_filter_by_provider_non_mock(self):
|
||||
voices = list_voices(provider="aliyun")
|
||||
assert len(voices) == 0
|
||||
|
||||
def test_filter_multiple_conditions(self):
|
||||
voices = list_voices(gender="female", style="narration")
|
||||
assert len(voices) > 0
|
||||
assert all(v.gender == VoiceGender.FEMALE for v in voices)
|
||||
assert all(v.style == VoiceStyle.NARRATION for v in voices)
|
||||
|
||||
def test_keyword_case_insensitive(self):
|
||||
voices1 = list_voices(keyword="FEMALE")
|
||||
voices2 = list_voices(keyword="female")
|
||||
assert len(voices1) == len(voices2)
|
||||
|
||||
|
||||
class TestGetDefaultVoice:
|
||||
def test_default_voice_exists(self):
|
||||
v = get_default_voice()
|
||||
assert v is not None
|
||||
assert v == MOCK_VOICES[0]
|
||||
Reference in New Issue
Block a user