From 2dfcb566797ea090305147141e3e3ecffbd56251 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 27 Jul 2026 15:51:37 +0800 Subject: [PATCH] =?UTF-8?q?test(wave132):=20TTS=E9=A2=86=E5=9F=9F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8D=95=E6=B5=8B=20+103?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 text_splitter 文本分段(句子边界/长句硬切/合并短段/边界用例) 覆盖 TtsConfig 配置解析(各字段解析/边界钳制/默认值/类型容错) 覆盖 TTSJob 状态机(创建/状态转换/重试/序列化/参数校验) --- tests/unit/test_tts_domain_package.py | 689 ++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100755 tests/unit/test_tts_domain_package.py diff --git a/tests/unit/test_tts_domain_package.py b/tests/unit/test_tts_domain_package.py new file mode 100755 index 000000000..daaee006f --- /dev/null +++ b/tests/unit/test_tts_domain_package.py @@ -0,0 +1,689 @@ +"""TTS 相关领域模块单测 — text_splitter + tts_config + tts_job.""" + +from __future__ import annotations + +import pytest + +from packages.application.tts_job.text_splitter import split_text +from packages.domain.tts_config import TtsConfig +from packages.domain.tts_job import TTSJob, TTSJobStatus, TERMINAL_STATUSES + +# ═══════════════════════════════════════════════════════════════════════════════ +# text_splitter 文本分段 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestSplitTextBasic: + """基础分段功能.""" + + def test_empty_text_returns_empty_list(self): + assert split_text("", max_chars=500) == [] + + def test_whitespace_only_returns_empty_list(self): + assert split_text(" \n\n ", max_chars=500) == [] + + def test_short_text_returns_single_segment(self): + text = "你好世界" + result = split_text(text, max_chars=500) + assert len(result) == 1 + assert result[0] == text + + def test_text_equal_to_max_chars_single_segment(self): + text = "a" * 500 + result = split_text(text, max_chars=500) + assert len(result) == 1 + assert len(result[0]) == 500 + + def test_none_max_chars_uses_default(self): + """默认 max_chars=500.""" + text = "你好" + result = split_text(text) # 使用默认值 + assert len(result) == 1 + + +class TestSplitTextSentenceBoundary: + """按句子边界分段.""" + + def test_splits_at_period(self): + text = "第一句。第二句。第三句。" + result = split_text(text, max_chars=10) + # 每句都比较短,会在句子边界处合并 + assert len(result) >= 2 + assert "".join(result) == text.strip() + + def test_splits_at_question_mark(self): + text = "你是谁?我是AI。你好吗?很好。" + result = split_text(text, max_chars=15) + assert len(result) >= 2 + assert "".join(result) == text.strip() + + def test_splits_at_exclamation_mark(self): + text = "太棒了!真厉害!好厉害!" + result = split_text(text, max_chars=10) + assert len(result) >= 2 + assert "".join(result) == text.strip() + + def test_splits_at_newline(self): + text = "第一段\n第二段\n第三段" + result = split_text(text, max_chars=10) + assert len(result) >= 2 + + def test_splits_at_semicolon(self): + text = "第一部分;第二部分;第三部分。" + result = split_text(text, max_chars=15) + assert len(result) >= 1 + assert "".join(result) == text.strip() + + +class TestSplitTextLongSentence: + """长句子(超过 max_chars)强制切段.""" + + def test_very_long_sentence_hard_cut(self): + """单个超长句子会被强制切段.""" + text = "我" * 600 # 没有标点 + result = split_text(text, max_chars=500) + assert len(result) >= 2 + total = sum(len(seg) for seg in result) + assert total == len(text) + + def test_each_segment_leq_max_chars(self): + """每个分段都不超过 max_chars.""" + text = "测试句子。" * 100 + result = split_text(text, max_chars=100) + for seg in result: + assert len(seg) <= 100 + + def test_no_empty_segments(self): + """不产生空分段.""" + text = "测试。" * 50 + result = split_text(text, max_chars=50) + for seg in result: + assert len(seg) > 0 + + +class TestSplitTextMergeShortSegments: + """合并过短的分段.""" + + def test_short_segments_get_merged(self): + """< 50 字符的段会被合并(如果不超限).""" + # 多个短句子,应该会被合并 + text = "你好。我是。他是。她是。它是。" + result = split_text(text, max_chars=50) + # 每段6字符左右,应该被合并成一段 + assert len(result) < 5 + + def test_last_short_segment_merged_to_previous(self): + """最后一段如果很短,会合并到前一段.""" + text = "a" * 48 + "。" + "b" * 48 + "。" + "cc" + result = split_text(text, max_chars=100) + # 最后的 "cc" 很短,应该被合并 + assert result[-1] != "cc" + + +class TestSplitTextEdgeCases: + """边界情况.""" + + def test_single_character(self): + result = split_text("一", max_chars=500) + assert result == ["一"] + + def test_only_punctuation(self): + text = "。。。" + result = split_text(text, max_chars=500) + assert len(result) == 1 + + def test_mixed_chinese_english(self): + text = "Hello世界。Hello世界。" * 20 + result = split_text(text, max_chars=50) + assert len(result) >= 2 + assert "".join(result) == text.strip() + + def test_max_chars_one(self): + """极端情况:max_chars=1.""" + text = "abc" + result = split_text(text, max_chars=1) + assert len(result) == 3 + assert result == ["a", "b", "c"] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# TtsConfig 配置解析 + 边界钳制 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestTtsConfigDefaults: + """默认值.""" + + def test_default_config_disabled(self): + config = TtsConfig() + assert config.enabled is False + assert config.voice_id == "" + assert config.speed == 1.0 + assert config.pitch == 0.0 + assert config.volume == 0.8 + assert config.text == "" + assert config.align_mode == "full" + assert config.overlap_mode == "replace" + + def test_parse_none_returns_default(self): + config = TtsConfig.parse(None) + assert config.enabled is False + + def test_parse_empty_dict_returns_default(self): + config = TtsConfig.parse({}) + assert config.enabled is False + + def test_parse_non_dict_returns_default(self): + config = TtsConfig.parse("not a dict") + assert config.enabled is False + + +class TestTtsConfigParseEnabled: + """enabled 字段解析.""" + + def test_parse_enabled_true(self): + config = TtsConfig.parse({"enabled": True}) + assert config.enabled is True + + def test_parse_enabled_false(self): + config = TtsConfig.parse({"enabled": False}) + assert config.enabled is False + + def test_parse_enabled_invalid_type(self): + """enabled 不是 bool 时回退到 False.""" + config = TtsConfig.parse({"enabled": "true"}) + assert config.enabled is False + + def test_disabled_ignores_other_fields(self): + """enabled=False 时其他字段都用默认值.""" + config = TtsConfig.parse( + { + "enabled": False, + "voice_id": "test", + "speed": 2.0, + } + ) + assert config.enabled is False + assert config.voice_id == "" + assert config.speed == 1.0 + + +class TestTtsConfigParseFields: + """各字段解析.""" + + def test_parse_voice_id(self): + config = TtsConfig.parse({"enabled": True, "voice_id": "voice_001"}) + assert config.voice_id == "voice_001" + + def test_parse_voice_id_invalid_type(self): + config = TtsConfig.parse({"enabled": True, "voice_id": 123}) + assert config.voice_id == "" + + def test_parse_speed(self): + config = TtsConfig.parse({"enabled": True, "speed": 1.5}) + assert config.speed == 1.5 + + def test_parse_speed_int(self): + config = TtsConfig.parse({"enabled": True, "speed": 2}) + assert config.speed == 2.0 + + def test_parse_speed_invalid_type(self): + config = TtsConfig.parse({"enabled": True, "speed": "fast"}) + assert config.speed == 1.0 + + def test_parse_pitch(self): + config = TtsConfig.parse({"enabled": True, "pitch": 5}) + assert config.pitch == 5.0 + + def test_parse_pitch_invalid_type(self): + config = TtsConfig.parse({"enabled": True, "pitch": "high"}) + assert config.pitch == 0.0 + + def test_parse_volume(self): + config = TtsConfig.parse({"enabled": True, "volume": 0.5}) + assert config.volume == 0.5 + + def test_parse_volume_invalid_type(self): + config = TtsConfig.parse({"enabled": True, "volume": "loud"}) + assert config.volume == 0.8 + + def test_parse_text(self): + config = TtsConfig.parse({"enabled": True, "text": "你好世界"}) + assert config.text == "你好世界" + + def test_parse_text_invalid_type(self): + config = TtsConfig.parse({"enabled": True, "text": 12345}) + assert config.text == "" + + def test_parse_align_mode_valid(self): + config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"}) + assert config.align_mode == "subtitle" + + def test_parse_align_mode_invalid(self): + config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"}) + assert config.align_mode == "full" + + def test_parse_overlap_mode_mix(self): + config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"}) + assert config.overlap_mode == "mix" + + def test_parse_overlap_mode_invalid(self): + config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"}) + assert config.overlap_mode == "replace" + + +class TestTtsConfigClamp: + """边界钳制.""" + + def test_speed_below_minimum_clamped(self): + config = TtsConfig.parse({"enabled": True, "speed": 0.1}) + assert config.speed == 0.5 + + def test_speed_above_maximum_clamped(self): + config = TtsConfig.parse({"enabled": True, "speed": 3.0}) + assert config.speed == 2.0 + + def test_speed_at_minimum_ok(self): + config = TtsConfig.parse({"enabled": True, "speed": 0.5}) + assert config.speed == 0.5 + + def test_speed_at_maximum_ok(self): + config = TtsConfig.parse({"enabled": True, "speed": 2.0}) + assert config.speed == 2.0 + + def test_pitch_below_minimum_clamped(self): + config = TtsConfig.parse({"enabled": True, "pitch": -20}) + assert config.pitch == -12 + + def test_pitch_above_maximum_clamped(self): + config = TtsConfig.parse({"enabled": True, "pitch": 20}) + assert config.pitch == 12 + + def test_pitch_at_minimum_ok(self): + config = TtsConfig.parse({"enabled": True, "pitch": -12}) + assert config.pitch == -12 + + def test_pitch_at_maximum_ok(self): + config = TtsConfig.parse({"enabled": True, "pitch": 12}) + assert config.pitch == 12 + + def test_volume_below_minimum_clamped(self): + config = TtsConfig.parse({"enabled": True, "volume": -0.5}) + assert config.volume == 0.0 + + def test_volume_above_maximum_clamped(self): + config = TtsConfig.parse({"enabled": True, "volume": 2.0}) + assert config.volume == 1.0 + + def test_volume_at_minimum_ok(self): + config = TtsConfig.parse({"enabled": True, "volume": 0.0}) + assert config.volume == 0.0 + + def test_volume_at_maximum_ok(self): + config = TtsConfig.parse({"enabled": True, "volume": 1.0}) + assert config.volume == 1.0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# TTSJob 领域模型 — 状态机 +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestTTSJobCreate: + """创建任务.""" + + def test_create_basic(self): + job = TTSJob.create(user_id="user1", input_text="你好") + assert job.id is not None + assert len(job.id) > 0 + assert job.user_id == "user1" + assert job.input_text == "你好" + assert job.status == TTSJobStatus.PENDING + assert job.retry_count == 0 + assert job.max_retries == 3 + assert job.started_at is None + assert job.completed_at is None + + def test_create_with_voice_id(self): + job = TTSJob.create(user_id="user1", input_text="你好", voice_id="voice_001") + assert job.voice_id == "voice_001" + + def test_create_with_project_id(self): + job = TTSJob.create(user_id="user1", input_text="你好", project_id="proj_001") + assert job.project_id == "proj_001" + + def test_create_with_voice_clone_profile_id(self): + job = TTSJob.create( + user_id="user1", + input_text="你好", + voice_clone_profile_id="clone_001", + ) + assert job.voice_clone_profile_id == "clone_001" + + def test_create_with_custom_max_retries(self): + job = TTSJob.create(user_id="user1", input_text="你好", max_retries=5) + assert job.max_retries == 5 + + def test_create_with_format(self): + job = TTSJob.create(user_id="user1", input_text="你好", format="wav") + assert job.format == "wav" + + def test_create_with_sample_rate(self): + job = TTSJob.create(user_id="user1", input_text="你好", sample_rate=44100) + assert job.sample_rate == 44100 + + +class TestTTSJobStatusProperties: + """状态查询属性.""" + + def test_pending_not_terminal(self): + job = TTSJob.create(user_id="u1", input_text="hi") + assert job.is_terminal is False + + def test_processing_not_terminal(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + assert job.is_terminal is False + + def test_completed_is_terminal(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed(output_audio_url="url") + assert job.is_terminal is True + assert job.is_completed is True + + def test_failed_is_terminal(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_failed("error") + assert job.is_terminal is True + assert job.status == TTSJobStatus.FAILED + + def test_cancelled_is_terminal(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_cancelled() + assert job.is_terminal is True + + def test_terminal_statuses_contains_all_three(self): + assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES + assert TTSJobStatus.FAILED in TERMINAL_STATUSES + assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES + + +class TestTTSJobTransitions: + """状态转换.""" + + def test_pending_to_processing(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + assert job.status == TTSJobStatus.PROCESSING + assert job.started_at is not None + assert job.error_message == "" + + def test_pending_to_failed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_failed("网络错误") + assert job.status == TTSJobStatus.FAILED + assert job.error_message == "网络错误" + + def test_pending_to_cancelled(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_cancelled() + assert job.status == TTSJobStatus.CANCELLED + + def test_processing_to_completed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed(output_audio_url="https://example.com/audio.mp3") + assert job.status == TTSJobStatus.COMPLETED + assert job.output_audio_url == "https://example.com/audio.mp3" + assert job.completed_at is not None + + def test_processing_to_failed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_failed("API超时") + assert job.status == TTSJobStatus.FAILED + assert job.error_message == "API超时" + + def test_processing_to_cancelled(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_cancelled() + assert job.status == TTSJobStatus.CANCELLED + + def test_failed_to_pending_retry(self): + job = TTSJob.create(user_id="u1", input_text="hi") + 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 + + def test_completed_cannot_transition_back(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed(output_audio_url="url") + with pytest.raises(ValueError, match="非法状态转换"): + job.mark_failed("test") + + def test_cancelled_cannot_retry(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_cancelled() + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_mark_failed_sets_error_message(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_failed("服务端错误") + assert job.error_message == "服务端错误" + + def test_failed_status_after_mark_failed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_failed("API超时") + assert job.status == TTSJobStatus.FAILED + assert job.error_message == "API超时" + + +class TestTTSJobRetry: + """重试逻辑.""" + + def test_retry_increments_retry_count(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_failed("error1") + job.prepare_retry() + assert job.retry_count == 1 + + job.mark_processing() + job.mark_failed("error2") + job.prepare_retry() + assert job.retry_count == 2 + + def test_retry_clears_error_message(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_failed("error") + job.prepare_retry() + assert job.error_message == "" + + def test_retry_resets_timestamps(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_failed("error") + job.prepare_retry() + assert job.started_at is None + + def test_can_retry_while_below_max_retries(self): + job = TTSJob.create(user_id="u1", input_text="hi", max_retries=2) + # 第一次失败+重试 + job.mark_failed("e1") + job.prepare_retry() + assert job.retry_count == 1 + # 第二次失败+重试 + job.mark_processing() + job.mark_failed("e2") + job.prepare_retry() + assert job.retry_count == 2 + + def test_cannot_retry_when_exceeded_max_retries(self): + job = TTSJob.create(user_id="u1", input_text="hi", max_retries=1) + job.mark_failed("e1") + job.prepare_retry() + assert job.retry_count == 1 + # 再次失败就不能重试了(已经用完1次重试) + job.mark_processing() + job.mark_failed("e2") + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_is_retryable_true_when_failed_and_under_limit(self): + job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3) + job.mark_failed("error") + assert job.is_retryable is True + + def test_is_retryable_false_when_not_failed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + assert job.is_retryable is False + + def test_prepare_retry_fails_when_not_failed(self): + job = TTSJob.create(user_id="u1", input_text="hi") + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + +class TestTTSJobCompleted: + """完成时的字段.""" + + def test_mark_completed_sets_output_url(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed( + output_audio_url="https://cdn.example.com/audio.mp3", + output_audio_key="audio/xxx.mp3", + duration=10.5, + file_size=102400, + ) + assert job.output_audio_url == "https://cdn.example.com/audio.mp3" + assert job.output_audio_key == "audio/xxx.mp3" + assert job.duration == 10.5 + assert job.file_size == 102400 + assert job.completed_at is not None + + def test_mark_completed_default_values(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed(output_audio_url="url") + assert job.duration == 0.0 + assert job.file_size == 0 + + +class TestTTSJobMetadata: + """元数据.""" + + def test_create_with_metadata(self): + meta = {"source": "api", "priority": "high"} + job = TTSJob.create(user_id="u1", input_text="hi", metadata=meta) + assert job.metadata["source"] == "api" + assert job.metadata["priority"] == "high" + + def test_default_metadata_empty_dict(self): + job = TTSJob.create(user_id="u1", input_text="hi") + assert job.metadata == {} + + +class TestTTSJobCreateValidation: + """创建时的参数校验.""" + + def test_empty_user_id_raises(self): + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id="", input_text="hi") + + def test_whitespace_user_id_raises(self): + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id=" ", input_text="hi") + + def test_empty_input_text_raises(self): + with pytest.raises(ValueError, match="input_text"): + TTSJob.create(user_id="u1", input_text="") + + def test_input_text_too_long_raises(self): + long_text = "a" * 10001 + with pytest.raises(ValueError, match="10000"): + TTSJob.create(user_id="u1", input_text=long_text) + + def test_invalid_format_raises(self): + with pytest.raises(ValueError, match="不支持的输出格式"): + TTSJob.create(user_id="u1", input_text="hi", format="flac") + + def test_valid_format_wav(self): + job = TTSJob.create(user_id="u1", input_text="hi", format="wav") + assert job.format == "wav" + + def test_valid_format_pcm(self): + job = TTSJob.create(user_id="u1", input_text="hi", format="pcm") + assert job.format == "pcm" + + def test_input_text_stripped(self): + job = TTSJob.create(user_id="u1", input_text=" 你好 ") + assert job.input_text == "你好" + + +class TestTTSJobToDict: + """序列化.""" + + def test_to_dict_contains_key_fields(self): + job = TTSJob.create(user_id="u1", input_text="hi", voice_id="v1") + d = job.to_dict() + assert d["id"] == job.id + assert d["user_id"] == "u1" + assert d["input_text"] == "hi" + assert d["voice_id"] == "v1" + assert d["status"] == "pending" + assert d["retry_count"] == 0 + assert d["is_retryable"] is False + + def test_to_dict_completed_status(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + job.mark_completed(output_audio_url="https://example.com/a.mp3", duration=10.5, file_size=1024) + d = job.to_dict() + assert d["status"] == "completed" + assert d["output_audio_url"] == "https://example.com/a.mp3" + assert d["duration"] == 10.5 + assert d["file_size"] == 1024 + assert d["is_completed"] is True + assert d["started_at"] is not None + assert d["completed_at"] is not None + + def test_to_dict_failed_status(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_failed("error msg") + d = job.to_dict() + assert d["status"] == "failed" + assert d["error_message"] == "error msg" + assert d["is_retryable"] is True + + +class TestTTSJobCompletedValidation: + """完成时的校验.""" + + def test_mark_completed_empty_url_raises(self): + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + with pytest.raises(ValueError, match="output_audio_url"): + job.mark_completed(output_audio_url="") + + def test_is_completed_requires_url(self): + """is_completed 属性需要 output_audio_url.""" + job = TTSJob.create(user_id="u1", input_text="hi") + job.mark_processing() + # 直接设置状态为 completed 但不给 URL(模拟异常情况) + # 正常流程 mark_completed 会校验 URL,所以这里不会出现 + # 但确认属性逻辑:没有 URL 时 is_completed 为 False + job.output_audio_url = "" + # 直接绕过状态机 + from packages.domain.tts_job import _VALID_TRANSITIONS # noqa + + job.status = TTSJobStatus.COMPLETED + assert job.is_completed is False -- 2.54.0