test(unit): P3-1 第三波 新增3个领域模块单元测试(94个用例) #680
+2
-1
@@ -1 +1,2 @@
|
||||
trigger: 1784009947
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
"""剪辑计划领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
class TestEditPlanStatus:
|
||||
"""EditPlanStatus 枚举测试."""
|
||||
|
||||
def test_status_values(self):
|
||||
assert EditPlanStatus.DRAFT.value == "draft"
|
||||
assert EditPlanStatus.EDITING.value == "editing"
|
||||
assert EditPlanStatus.RENDERING.value == "rendering"
|
||||
assert EditPlanStatus.COMPLETED.value == "completed"
|
||||
assert EditPlanStatus.FAILED.value == "failed"
|
||||
|
||||
def test_status_is_str(self):
|
||||
assert isinstance(EditPlanStatus.DRAFT, str)
|
||||
assert EditPlanStatus.DRAFT == "draft"
|
||||
|
||||
|
||||
class TestEditPlanCreate:
|
||||
"""创建剪辑计划测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试计划")
|
||||
assert plan.id
|
||||
assert len(plan.id) == 32 # uuid4 hex
|
||||
assert plan.template_id == "tpl_001"
|
||||
assert plan.name == "测试计划"
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
assert plan.total_duration == 0.0
|
||||
assert plan.config == {}
|
||||
assert plan.source_edit_plan_id == ""
|
||||
assert plan.project_id == ""
|
||||
assert plan.created_by_user_id == ""
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl_001",
|
||||
name="完整测试计划",
|
||||
config={"key": "value"},
|
||||
total_duration=60.5,
|
||||
source_edit_plan_id="src_001",
|
||||
project_id="proj_001",
|
||||
created_by_user_id="user_001",
|
||||
)
|
||||
assert plan.name == "完整测试计划"
|
||||
assert plan.total_duration == 60.5
|
||||
assert plan.config == {"key": "value"}
|
||||
assert plan.source_edit_plan_id == "src_001"
|
||||
assert plan.project_id == "proj_001"
|
||||
assert plan.created_by_user_id == "user_001"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称不能为空"):
|
||||
EditPlan.create(template_id="tpl_001", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称不能为空"):
|
||||
EditPlan.create(template_id="tpl_001", name=" ")
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id="", name="测试")
|
||||
|
||||
def test_create_whitespace_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id=" ", name="测试")
|
||||
|
||||
def test_create_name_stripped(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name=" 我的计划 ")
|
||||
assert plan.name == "我的计划"
|
||||
|
||||
def test_create_template_id_stripped(self):
|
||||
plan = EditPlan.create(template_id=" tpl_001 ", name="测试")
|
||||
assert plan.template_id == "tpl_001"
|
||||
|
||||
def test_create_timestamps_set(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试")
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= plan.created_at <= after
|
||||
assert before <= plan.updated_at <= after
|
||||
|
||||
def test_create_config_none_defaults_to_empty(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试", config=None)
|
||||
assert plan.config == {}
|
||||
|
||||
|
||||
class TestEditPlanStateMachine:
|
||||
"""状态机流转测试."""
|
||||
|
||||
def _make_plan(self, status: EditPlanStatus) -> EditPlan:
|
||||
return EditPlan(
|
||||
id="test_id",
|
||||
template_id="tpl_001",
|
||||
name="测试计划",
|
||||
status=status,
|
||||
)
|
||||
|
||||
def test_draft_to_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
plan.start_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
assert plan.updated_at > plan.created_at
|
||||
|
||||
def test_editing_to_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
plan.start_rendering()
|
||||
assert plan.status == EditPlanStatus.RENDERING
|
||||
|
||||
def test_rendering_to_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
|
||||
def test_rendering_to_failed(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
plan.mark_failed()
|
||||
assert plan.status == EditPlanStatus.FAILED
|
||||
|
||||
def test_completed_to_editing_resume(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
plan.resume_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_failed_to_editing_resume(self):
|
||||
plan = self._make_plan(EditPlanStatus.FAILED)
|
||||
plan.resume_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_failed_to_draft_reset(self):
|
||||
plan = self._make_plan(EditPlanStatus.FAILED)
|
||||
plan.reset_to_draft()
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
def test_invalid_start_editing_from_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
with pytest.raises(ValueError, match="只有 draft 状态"):
|
||||
plan.start_editing()
|
||||
|
||||
def test_invalid_start_editing_from_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.start_editing()
|
||||
|
||||
def test_invalid_start_rendering_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 editing 状态"):
|
||||
plan.start_rendering()
|
||||
|
||||
def test_invalid_start_rendering_from_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
plan.start_rendering()
|
||||
|
||||
def test_invalid_mark_completed_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 rendering 状态"):
|
||||
plan.mark_completed()
|
||||
|
||||
def test_invalid_mark_failed_from_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.mark_failed()
|
||||
|
||||
def test_invalid_resume_editing_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 completed/failed 状态"):
|
||||
plan.resume_editing()
|
||||
|
||||
def test_invalid_resume_editing_from_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.resume_editing()
|
||||
|
||||
def test_invalid_reset_to_draft_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 failed 状态"):
|
||||
plan.reset_to_draft()
|
||||
|
||||
def test_invalid_reset_to_draft_from_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
plan.reset_to_draft()
|
||||
|
||||
def test_state_transition_updates_updated_at(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
old_updated = plan.updated_at
|
||||
plan.start_editing()
|
||||
assert plan.updated_at >= old_updated
|
||||
|
||||
|
||||
class TestEditPlanDataclass:
|
||||
"""数据类属性测试."""
|
||||
|
||||
def test_slots_prevents_dynamic_attributes(self):
|
||||
plan = EditPlan(id="1", template_id="t1", name="test")
|
||||
with pytest.raises(AttributeError):
|
||||
plan.new_field = "value"
|
||||
|
||||
def test_full_flow_draft_editing_rendering_completed(self):
|
||||
"""完整流程:草稿 → 编辑 → 渲染 → 完成."""
|
||||
plan = EditPlan.create(template_id="tpl_001", name="完整流程")
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
plan.start_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
plan.start_rendering()
|
||||
assert plan.status == EditPlanStatus.RENDERING
|
||||
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
|
||||
def test_full_flow_draft_editing_rendering_failed_reset(self):
|
||||
"""完整流程:草稿 → 编辑 → 渲染 → 失败 → 重置 → 编辑 → 渲染 → 完成."""
|
||||
plan = EditPlan.create(template_id="tpl_001", name="失败重试流程")
|
||||
|
||||
plan.start_editing()
|
||||
plan.start_rendering()
|
||||
plan.mark_failed()
|
||||
assert plan.status == EditPlanStatus.FAILED
|
||||
|
||||
plan.reset_to_draft()
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
plan.start_editing()
|
||||
plan.start_rendering()
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
+182
-413
@@ -1,73 +1,59 @@
|
||||
"""
|
||||
Subtitle 字幕领域模型单元测试
|
||||
"""
|
||||
"""字幕领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.subtitle import (
|
||||
SubtitleSegment,
|
||||
SubtitleTimeline,
|
||||
SubtitleWord,
|
||||
)
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
"""SubtitleWord 测试"""
|
||||
"""SubtitleWord 测试."""
|
||||
|
||||
def test_duration_positive(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
def test_basic_properties(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=1.5)
|
||||
assert word.text == "你好"
|
||||
assert word.start == 1.0
|
||||
assert word.end == 1.5
|
||||
assert word.duration == 0.5
|
||||
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="a", start=5.0, end=5.0)
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
word = SubtitleWord(text="test", start=2.0, end=1.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
"""测试结束时间小于开始时间时返回 0"""
|
||||
word = SubtitleWord(text="a", start=3.0, end=1.0)
|
||||
def test_duration_zero_when_same_time(self):
|
||||
word = SubtitleWord(text="test", start=1.0, end=1.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
"""SubtitleSegment 测试"""
|
||||
"""SubtitleSegment 测试."""
|
||||
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=3.0)
|
||||
assert seg.duration == pytest.approx(3.0)
|
||||
|
||||
def test_duration_zero(self):
|
||||
seg = SubtitleSegment(text="test", start=5.0, end=5.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
seg = SubtitleSegment(text="test", start=5.0, end=2.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0, end=1)
|
||||
assert seg.char_count == 4
|
||||
|
||||
def test_char_count_empty(self):
|
||||
seg = SubtitleSegment(text="", start=0, end=1)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_default_words_empty(self):
|
||||
seg = SubtitleSegment(text="test", start=0, end=1)
|
||||
def test_basic_properties(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
|
||||
assert seg.text == "大家好"
|
||||
assert seg.start == 0.0
|
||||
assert seg.end == 2.0
|
||||
assert seg.duration == 2.0
|
||||
assert seg.char_count == 3
|
||||
assert seg.words == []
|
||||
|
||||
def test_with_words(self):
|
||||
def test_duration_with_words(self):
|
||||
words = [
|
||||
SubtitleWord(text="你好", start=0.0, end=1.0),
|
||||
SubtitleWord(text="世界", start=1.0, end=2.0),
|
||||
SubtitleWord(text="大", start=0.0, end=0.5),
|
||||
SubtitleWord(text="家", start=0.5, end=1.0),
|
||||
SubtitleWord(text="好", start=1.0, end=1.5),
|
||||
]
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0, words=words)
|
||||
assert len(seg.words) == 2
|
||||
assert seg.words[0].text == "你好"
|
||||
assert seg.words[1].text == "世界"
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
|
||||
assert seg.duration == 1.5
|
||||
assert seg.char_count == 3
|
||||
assert len(seg.words) == 3
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleTimelineBasics:
|
||||
"""SubtitleTimeline 基础属性测试"""
|
||||
"""SubtitleTimeline 基础属性测试."""
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
@@ -76,428 +62,211 @@ class TestSubtitleTimelineBasics:
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
SubtitleSegment(text="b", start=1, end=2),
|
||||
SubtitleSegment(text="c", start=2, end=3),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 3
|
||||
def test_single_segment(self):
|
||||
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
assert tl.segment_count == 1
|
||||
assert tl.total_chars == 2
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0, end=1),
|
||||
SubtitleSegment(text="世界", start=1, end=2),
|
||||
SubtitleSegment(text="abcde", start=2, end=3),
|
||||
]
|
||||
)
|
||||
def test_multiple_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="第一句", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二句", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="第三句", start=2.0, end=3.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
|
||||
assert tl.segment_count == 3
|
||||
assert tl.total_chars == 9
|
||||
assert tl.total_duration == 3.0
|
||||
|
||||
def test_custom_language(self):
|
||||
tl = SubtitleTimeline(language="en")
|
||||
assert tl.language == "en"
|
||||
|
||||
def test_custom_total_duration(self):
|
||||
tl = SubtitleTimeline(total_duration=60.0)
|
||||
assert tl.total_duration == 60.0
|
||||
|
||||
class TestSubtitleTimelineMergeShort:
|
||||
"""合并短字幕片段测试."""
|
||||
|
||||
class TestMergeShortSegments:
|
||||
"""merge_short_segments 测试"""
|
||||
|
||||
def test_single_segment_no_merge(self):
|
||||
"""单个片段不需要合并"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "a"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
"""空时间轴"""
|
||||
def test_empty_or_single_no_change(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_all_short_segments_merge_into_one(self):
|
||||
"""所有短片段合并成一个"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你", start=0, end=0.5),
|
||||
SubtitleSegment(text="好", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="世", start=1.0, end=1.5),
|
||||
SubtitleSegment(text="界", start=1.5, end=2.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0
|
||||
assert result.segments[0].end == 2.0
|
||||
seg = SubtitleSegment(text="短", start=0.0, end=0.5)
|
||||
tl2 = SubtitleTimeline(segments=[seg])
|
||||
result2 = tl2.merge_short_segments()
|
||||
assert result2.segment_count == 1
|
||||
|
||||
def test_merge_short_segments_preserves_timing(self):
|
||||
"""合并后时间轴正确"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="世界", start=2.0, end=3.5),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].start == 1.0
|
||||
assert result.segments[0].end == 3.5
|
||||
|
||||
def test_merge_short_segments_with_words(self):
|
||||
"""合并后词级信息保留"""
|
||||
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
|
||||
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert len(result.segments[0].words) == 2
|
||||
assert result.segments[0].words[0].text == "你好"
|
||||
assert result.segments[0].words[1].text == "世界"
|
||||
|
||||
def test_multiple_merged_groups(self):
|
||||
"""多个合并组 — 短段会和后续段累积到够数才提交"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字,够数,提交
|
||||
SubtitleSegment(text="九", start=2, end=2.5), # 1字,入buffer
|
||||
SubtitleSegment(text="十", start=2.5, end=3), # 1字,入buffer(共2字)
|
||||
SubtitleSegment(text="一二三四五六七八九十", start=3, end=5), # 10字,入buffer后共12字,够数提交
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第1段:"一二三四五六七八"(8字直接提交)
|
||||
# 第2段:"九十" + "一二三四五六七八九十" 累积到12字一起提交
|
||||
def test_merge_short_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "一二三四五六七八"
|
||||
assert result.segments[1].text == "九十一二三四五六七八九十"
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 1.0
|
||||
assert result.segments[1].text == "今天天气很好"
|
||||
|
||||
def test_remaining_short_merged_with_last(self):
|
||||
"""剩余短片段合并到最后一段"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字
|
||||
SubtitleSegment(text="一二三", start=2, end=3), # 3字,不够
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 最后的3字会合并到上一段(因为 < min_chars)
|
||||
def test_merge_trailing_short_to_last(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="短", start=1.0, end=1.2),
|
||||
SubtitleSegment(text="尾", start=1.2, end=1.4),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "一二三四五六七八"=8字 → 保留
|
||||
# "短"+"尾"=2字 < 4 → 合并到上一段
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八一二三"
|
||||
assert result.segments[0].text == "一二三四五六七八短尾"
|
||||
|
||||
def test_custom_min_chars(self):
|
||||
"""自定义最小字数 — 累积到够数就提交,剩余短的合并到最后"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二", start=0, end=1),
|
||||
SubtitleSegment(text="三四", start=1, end=2),
|
||||
SubtitleSegment(text="五六", start=2, end=3),
|
||||
]
|
||||
)
|
||||
# min_chars=3:
|
||||
# "一二"(2字) → 不够
|
||||
# +"三四"(共4字) → 够了,提交"一二三四",buffer清空
|
||||
# "五六"(2字) → 循环结束,剩余<min_chars且merged非空 → 合并到最后一段
|
||||
# 结果:1段 "一二三四五六"
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
def test_merge_with_words(self):
|
||||
words1 = [SubtitleWord(text="你", start=0.0, end=0.25), SubtitleWord(text="好", start=0.25, end=0.5)]
|
||||
words2 = [SubtitleWord(text="世", start=0.5, end=0.75), SubtitleWord(text="界", start=0.75, end=1.0)]
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六"
|
||||
assert len(result.segments[0].words) == 4
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
"""合并后保留语言和总时长"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="a", start=0, end=1)],
|
||||
language="en",
|
||||
total_duration=60.0,
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 60.0
|
||||
|
||||
def test_does_not_modify_original(self):
|
||||
"""不修改原时间轴"""
|
||||
segments = [
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
SubtitleSegment(text="b", start=1, end=2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
# 原时间轴不变
|
||||
assert tl.segment_count == 2
|
||||
assert result is not tl
|
||||
segs = [SubtitleSegment(text="短", start=0.0, end=0.5)]
|
||||
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 0.5
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
"""split_long_segments 测试"""
|
||||
class TestSubtitleTimelineSplitLong:
|
||||
"""拆分长字幕片段测试."""
|
||||
|
||||
def test_short_segments_no_split(self):
|
||||
"""短片段不需要拆分"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0, end=1),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好"
|
||||
|
||||
def test_single_long_segment_split_by_punctuation(self):
|
||||
"""长片段按标点拆分"""
|
||||
text = "你好世界。今天天气真好,我们出去玩吧!"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
def test_short_segments_no_change(self):
|
||||
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 应该被拆成多段
|
||||
assert result.segment_count > 1
|
||||
# 每段都不超过 max_chars(除了硬切的情况)
|
||||
for seg in result.segments:
|
||||
assert seg.char_count <= len(text) # 至少比原文短
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_split_preserves_total_text(self):
|
||||
"""拆分后总文本不变"""
|
||||
text = "你好世界。今天天气真好,我们出去玩吧!明天再见。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
def test_split_by_sentence_punctuation(self):
|
||||
text = "今天天气很好。我们出去散步吧!"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
merged_text = "".join(s.text for s in result.segments)
|
||||
assert merged_text == text
|
||||
assert result.segment_count >= 2
|
||||
assert result.segments[0].text.endswith("。")
|
||||
assert result.total_chars == len(text)
|
||||
|
||||
def test_split_long_text_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert result.segment_count > 1
|
||||
# 所有片段都不超过 max_chars
|
||||
for s in result.segments:
|
||||
assert s.char_count <= 8
|
||||
|
||||
def test_split_time_proportional(self):
|
||||
"""拆分后时间按字数比例分配"""
|
||||
text = "一二三四五六七八九十。" # 11字
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=5)
|
||||
# 总时长不变
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[-1].end == pytest.approx(10.0)
|
||||
# 各段首尾相接
|
||||
for i in range(len(result.segments) - 1):
|
||||
assert result.segments[i].end == pytest.approx(result.segments[i + 1].start)
|
||||
text = "一二三四。五六七八。"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
assert result.segment_count >= 2
|
||||
# 总时长保持一致
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
|
||||
def test_split_with_words(self):
|
||||
"""拆分时词级信息正确分配"""
|
||||
words = [
|
||||
SubtitleWord(text="你好", start=0.0, end=1.0),
|
||||
SubtitleWord(text="世界", start=1.0, end=2.0),
|
||||
SubtitleWord(text="你好吗", start=2.0, end=3.5),
|
||||
SubtitleWord(text="一", start=0.0, end=0.5),
|
||||
SubtitleWord(text="二", start=0.5, end=1.0),
|
||||
SubtitleWord(text="三", start=1.0, end=1.5),
|
||||
SubtitleWord(text="四", start=1.5, end=2.0),
|
||||
]
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好世界。你好吗?", start=0.0, end=3.5, words=words),
|
||||
]
|
||||
)
|
||||
text = "一二三四五六七八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
# 第一段应该有前几个词
|
||||
assert len(result.segments) >= 2
|
||||
assert result.segment_count >= 2
|
||||
# 词的总数应该不变
|
||||
total_words = sum(len(s.words) for s in result.segments)
|
||||
assert total_words == 3 # 词的总数不变
|
||||
assert total_words == 4
|
||||
|
||||
def test_multiple_mixed_segments(self):
|
||||
"""混合长短片段"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短", start=0, end=1), # 短
|
||||
SubtitleSegment(text="一二三四五六七八九十一二三四五六七八九十", start=1, end=5), # 长
|
||||
SubtitleSegment(text="也短", start=5, end=6), # 短
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3 # 至少3段(中间被拆成多段)
|
||||
# 第一段还是原来的短的
|
||||
assert result.segments[0].text == "短"
|
||||
# 最后一段还是原来的短的
|
||||
assert result.segments[-1].text == "也短"
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
"""没有标点时硬切"""
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3
|
||||
for seg in result.segments:
|
||||
# 硬切的每段应该 <= max_chars
|
||||
assert seg.char_count <= 10
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
"""拆分后保留语言和总时长"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="a", start=0, end=1)],
|
||||
language="ja",
|
||||
total_duration=30.0,
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 30.0
|
||||
|
||||
def test_does_not_modify_original(self):
|
||||
"""不修改原时间轴"""
|
||||
original_text = "一二三四五六七八九十一二三四五六七八九十"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=original_text, start=0, end=5),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert tl.segment_count == 1
|
||||
assert tl.segments[0].text == original_text
|
||||
assert result is not tl
|
||||
def test_split_preserves_language(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg], language="en")
|
||||
result = tl.split_long_segments(max_chars=2)
|
||||
assert result.language == "en"
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
"""_split_text_by_punctuation 静态方法测试"""
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 10)
|
||||
assert result == ["你好世界"]
|
||||
|
||||
def test_split_at_sentence_end(self):
|
||||
"""在句末标点处断开"""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "你好。"
|
||||
assert result[1] == "世界。"
|
||||
|
||||
def test_split_at_comma(self):
|
||||
"""在逗号处断开(超过最大长度时)"""
|
||||
text = "一二三四五六七八,二二三四五六七八。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
"""没有标点时硬切"""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("一二三四五六七八九十", 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "一二三四五"
|
||||
assert result[1] == "六七八九十"
|
||||
"""标点拆分静态方法测试."""
|
||||
|
||||
def test_empty_text(self):
|
||||
# 空字符串循环不执行,current为空不append,返回空列表
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
def test_mixed_punctuation(self):
|
||||
"""混合标点"""
|
||||
text = "你好!吃饭了吗?是的,我吃过了。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 6)
|
||||
# 验证所有段加起来等于原文
|
||||
assert "".join(result) == text
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_sentence_end_with_min_length(self):
|
||||
"""句末标点断句的「半长门槛」只在未超max_chars时生效;
|
||||
超过max_chars回溯找标点时,即使首段很短也会断开。"""
|
||||
# "你好。" 3字 < max_chars//2(5),未超max_chars时不会主动断开
|
||||
# 但加上后面的"世界很大很美好"后超过10字,回溯找标点找到"。",强制断开
|
||||
text = "你好。世界很大很美好。"
|
||||
def test_split_by_period(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
|
||||
assert len(result) >= 2
|
||||
assert "。" in result[0]
|
||||
|
||||
def test_split_by_exclamation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_split_by_comma_when_long(self):
|
||||
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) > 1
|
||||
for part in result:
|
||||
assert len(part) <= 8
|
||||
|
||||
def test_sentence_end_triggers_split_when_half_max(self):
|
||||
# 句末标点在 max_chars//2 以上就拆分
|
||||
text = "你好世界。abcdefghij"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
# 超过max_chars时回溯断开,首段可能很短
|
||||
assert len(result) == 2
|
||||
assert result[0] == "你好。"
|
||||
assert result[1] == "世界很大很美好。"
|
||||
# 总文本不变
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_exclamation_and_question_marks(self):
|
||||
"""感叹号和问号也算句末标点"""
|
||||
text = "你好吗!我很好!你呢?"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 4)
|
||||
assert len(result) >= 3
|
||||
# "你好世界。"=5字 < 10但>=5(half),应该拆分
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
"""_merge_segments 静态方法测试"""
|
||||
"""_merge_segments 静态方法测试."""
|
||||
|
||||
def test_merge_two_segments(self):
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
def test_merge_empty(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
|
||||
def test_merge_single_segment(self):
|
||||
def test_merge_single(self):
|
||||
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
|
||||
result = SubtitleTimeline._merge_segments([seg])
|
||||
assert result.text == "test"
|
||||
assert result.start == 1.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_preserves_words(self):
|
||||
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
|
||||
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
|
||||
]
|
||||
)
|
||||
assert len(result.words) == 2
|
||||
assert result.words[0].text == "你好"
|
||||
assert result.words[1].text == "世界"
|
||||
|
||||
def test_merge_non_contiguous_segments(self):
|
||||
"""合并非连续片段(有间隙)"""
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="a", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="b", start=3.0, end=4.0),
|
||||
]
|
||||
)
|
||||
def test_merge_multiple(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 == 4.0
|
||||
assert result.text == "ab"
|
||||
|
||||
|
||||
class TestMergeAndSplitRoundtrip:
|
||||
"""合并和拆分的组合测试"""
|
||||
|
||||
def test_split_then_merge_approximate(self):
|
||||
"""拆分后再合并,总字数和总时长基本一致"""
|
||||
original_text = "你好世界。今天天气真好,我们出去玩吧!明天见。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=original_text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
split = tl.split_long_segments(max_chars=5)
|
||||
merged = split.merge_short_segments(min_chars=50) # 足够大的min_chars让它们都合并
|
||||
assert merged.segment_count == 1
|
||||
assert merged.segments[0].text == original_text
|
||||
assert merged.segments[0].start == 0.0
|
||||
assert merged.segments[0].end == pytest.approx(10.0)
|
||||
assert result.end == 2.0
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
TTS 配音配置模型单元测试
|
||||
"""
|
||||
"""TTS 配音配置领域模型单元测试."""
|
||||
|
||||
import pytest
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
"""默认值测试"""
|
||||
"""默认值测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
@@ -23,180 +21,184 @@ class TestTtsConfigDefaults:
|
||||
|
||||
|
||||
class TestTtsConfigParse:
|
||||
"""parse 方法测试"""
|
||||
"""parse 方法测试."""
|
||||
|
||||
def test_parse_none(self):
|
||||
def test_parse_none_returns_default(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config.enabled is False
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
def test_parse_empty_dict_returns_default(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_not_dict(self):
|
||||
config = TtsConfig.parse("not a dict")
|
||||
def test_parse_not_dict_returns_default(self):
|
||||
config = TtsConfig.parse("invalid")
|
||||
assert config.enabled is False
|
||||
config2 = TtsConfig.parse(123)
|
||||
assert config2.enabled is False
|
||||
config3 = TtsConfig.parse([])
|
||||
assert config3.enabled is False
|
||||
|
||||
def test_parse_disabled_returns_minimal(self):
|
||||
"""disabled 时直接返回 enabled=False,忽略其他字段"""
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": False,
|
||||
"voice_id": "v123",
|
||||
"speed": 1.5,
|
||||
}
|
||||
)
|
||||
def test_parse_enabled_false_ignores_other_fields(self):
|
||||
data = {
|
||||
"enabled": False,
|
||||
"voice_id": "test_voice",
|
||||
"speed": 2.0,
|
||||
"text": "hello",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == "" # 不保留
|
||||
|
||||
def test_parse_enabled_true(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.2,
|
||||
"pitch": 2.5,
|
||||
"volume": 0.5,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.2
|
||||
assert config.pitch == 2.5
|
||||
assert config.volume == 0.5
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_enabled_not_bool_false(self):
|
||||
"""enabled 不是 bool 时视为 False"""
|
||||
config = TtsConfig.parse({"enabled": "true"})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_enabled_not_bool_zero(self):
|
||||
config = TtsConfig.parse({"enabled": 0})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_voice_id_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
def test_parse_basic_enabled(self):
|
||||
data = {"enabled": True, "voice_id": "voice_001"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_align_mode_invalid(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_align_mode_subtitle(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
|
||||
def test_parse_full_config(self):
|
||||
data = {
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.0,
|
||||
"volume": 0.9,
|
||||
"text": "测试配音文本",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "测试配音文本"
|
||||
assert config.align_mode == "subtitle"
|
||||
|
||||
def test_parse_align_mode_full(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_overlap_mode_invalid(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_replace(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_mix(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_integer_speed(self):
|
||||
"""int 类型的 speed 应该被转成 float"""
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2})
|
||||
assert config.speed == 2.0
|
||||
assert isinstance(config.speed, float)
|
||||
def test_parse_enabled_non_bool_fallback(self):
|
||||
data = {"enabled": "true", "voice_id": "v1"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_integer_pitch(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -5})
|
||||
assert config.pitch == -5.0
|
||||
assert isinstance(config.pitch, float)
|
||||
def test_parse_voice_id_non_string_fallback(self):
|
||||
data = {"enabled": True, "voice_id": 123}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_integer_volume(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert config.volume == 1.0
|
||||
assert isinstance(config.volume, float)
|
||||
def test_parse_speed_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "speed": "fast"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "pitch": "high"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "volume": "loud"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_non_string_fallback(self):
|
||||
data = {"enabled": True, "text": 12345}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_align_mode_invalid_fallback(self):
|
||||
data = {"enabled": True, "align_mode": "invalid"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_overlap_mode_invalid_fallback(self):
|
||||
data = {"enabled": True, "overlap_mode": "invalid"}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
"""边界钳制测试"""
|
||||
"""边界钳制测试."""
|
||||
|
||||
def test_speed_too_low(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
def test_speed_below_min_clamped(self):
|
||||
data = {"enabled": True, "speed": 0.1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_too_high(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
def test_speed_above_max_clamped(self):
|
||||
data = {"enabled": True, "speed": 3.0}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_speed_lower_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
def test_speed_at_min_ok(self):
|
||||
data = {"enabled": True, "speed": 0.5}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_upper_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
def test_speed_at_max_ok(self):
|
||||
data = {"enabled": True, "speed": 2.0}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_pitch_too_low(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
def test_pitch_below_min_clamped(self):
|
||||
data = {"enabled": True, "pitch": -20}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_too_high(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
def test_pitch_above_max_clamped(self):
|
||||
data = {"enabled": True, "pitch": 20}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_pitch_lower_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -12})
|
||||
def test_pitch_at_min_ok(self):
|
||||
data = {"enabled": True, "pitch": -12}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_upper_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 12})
|
||||
def test_pitch_at_max_ok(self):
|
||||
data = {"enabled": True, "pitch": 12}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_volume_negative(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
def test_volume_below_min_clamped(self):
|
||||
data = {"enabled": True, "volume": -0.5}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_over_one(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
|
||||
def test_volume_above_max_clamped(self):
|
||||
data = {"enabled": True, "volume": 2.0}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_volume_zero(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
|
||||
def test_volume_at_min_ok(self):
|
||||
data = {"enabled": True, "volume": 0.0}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_one(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
|
||||
def test_volume_at_max_ok(self):
|
||||
data = {"enabled": True, "volume": 1.0}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_clamp_via_direct_construction(self):
|
||||
"""直接构造也应该钳制(通过 _clamp 方法)"""
|
||||
config = TtsConfig(enabled=True, speed=5.0, pitch=100, volume=-1)
|
||||
config._clamp()
|
||||
assert config.speed == 2.0
|
||||
assert config.pitch == 12
|
||||
assert config.volume == 0.0
|
||||
def test_int_speed_converted_to_float(self):
|
||||
data = {"enabled": True, "speed": 1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_int_pitch_converted_to_float(self):
|
||||
data = {"enabled": True, "pitch": 5}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == 5.0
|
||||
|
||||
def test_int_volume_converted_to_float(self):
|
||||
data = {"enabled": True, "volume": 1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
|
||||
Reference in New Issue
Block a user