test(unit): P3-1 第三波 新增3个领域模块单元测试(94个用例) #680

Merged
auto-approve-bot merged 4 commits from test/p3-1-wave3-domain-tests into develop 2026-07-21 19:00:12 +08:00
4 changed files with 556 additions and 547 deletions
+2 -1
View File
@@ -1 +1,2 @@
trigger: 1784009947
CI trigger file - safe to delete
updated!
+237
View File
@@ -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
View File
@@ -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
+135 -133
View File
@@ -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