Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 9603ce2b7e style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m36s
AI Code Review / AI Code Review (pull_request) Successful in 1m28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m6s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m14s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 22s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
2026-07-29 10:49:51 +00:00
xiaoxia 57c59cb112 test(wave186): subtitle 字幕时间轴模型 +52测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m35s
AI Code Review / AI Code Review (pull_request) Successful in 1m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
2026-07-29 18:40:34 +08:00
+497
View File
@@ -0,0 +1,497 @@
"""subtitle 字幕时间轴领域模型单测."""
import pytest
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
# ── SubtitleWord ─────────────────────────────────────────────────────────────
class TestSubtitleWord:
"""SubtitleWord 词级字幕单元"""
def test_basic(self):
w = SubtitleWord(text="你好", start=1.0, end=1.5)
assert w.text == "你好"
assert w.start == 1.0
assert w.end == 1.5
def test_duration(self):
w = SubtitleWord(text="test", start=0.0, end=2.5)
assert w.duration == 2.5
def test_duration_zero(self):
w = SubtitleWord(text="x", start=5.0, end=5.0)
assert w.duration == 0.0
def test_duration_negative_becomes_zero(self):
w = SubtitleWord(text="x", start=3.0, end=2.0)
assert w.duration == 0.0
# ── SubtitleSegment ──────────────────────────────────────────────────────────
class TestSubtitleSegment:
"""SubtitleSegment 字幕片段"""
def test_basic(self):
s = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
assert s.text == "你好世界"
assert s.start == 0.0
assert s.end == 2.0
assert s.words == []
def test_with_words(self):
words = [
SubtitleWord("你好", 0.0, 0.5),
SubtitleWord("世界", 0.5, 1.0),
]
s = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
assert len(s.words) == 2
assert s.words[0].text == "你好"
def test_duration(self):
s = SubtitleSegment(text="test", start=1.5, end=3.5)
assert s.duration == 2.0
def test_duration_negative_becomes_zero(self):
s = SubtitleSegment(text="test", start=5.0, end=3.0)
assert s.duration == 0.0
def test_char_count(self):
s = SubtitleSegment(text="你好世界", start=0, end=1)
assert s.char_count == 4
def test_char_count_empty(self):
s = SubtitleSegment(text="", start=0, end=1)
assert s.char_count == 0
def test_char_count_mixed(self):
s = SubtitleSegment(text="Hello 世界", start=0, end=1)
assert s.char_count == 8 # H-e-l-l-o- -世-界
# ── SubtitleTimeline 基础 ────────────────────────────────────────────────────
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性"""
def test_defaults(self):
tl = SubtitleTimeline()
assert tl.segments == []
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
def test_segment_count(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
assert tl.segment_count == 2
def test_segment_count_empty(self):
tl = SubtitleTimeline()
assert tl.segment_count == 0
def test_total_chars(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1),
SubtitleSegment("世界", 1, 2),
]
)
assert tl.total_chars == 4
def test_total_chars_empty(self):
tl = SubtitleTimeline()
assert tl.total_chars == 0
# ── merge_short_segments ─────────────────────────────────────────────────────
class TestMergeShortSegments:
"""merge_short_segments 合并过短片段"""
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments()
assert result.segment_count == 0
def test_single_segment(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == ""
def test_two_short_segments_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1), # 2
SubtitleSegment("世界", 1, 2), # 2
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 2.0
def test_multiple_short_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 0.5), # 1
SubtitleSegment("", 0.5, 1.0), # 1
SubtitleSegment("", 1.0, 1.5), # 1
SubtitleSegment("", 1.5, 2.0), # 1
SubtitleSegment("", 2.0, 2.5), # 1
SubtitleSegment("六七八", 2.5, 3.5), # 3
SubtitleSegment("八九十", 3.5, 4.5), # 3
]
)
result = tl.merge_short_segments(min_chars=5)
# 一二三四五 5个=5 → 合并为1段
# 六七八+八九十 3+3=6 → 合并为1段
assert result.segment_count == 2
assert result.segments[0].text == "一二三四五"
assert result.segments[1].text == "六七八八九十"
def test_long_segment_stays_alone(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("这是一段很长的字幕内容", 0, 2), # 11
SubtitleSegment("", 2, 2.5), # 1
SubtitleSegment("", 2.5, 3.0), # 1
]
)
result = tl.merge_short_segments(min_chars=8)
# 第一段11字>=8,单独输出;后两段加起来2字<8,合并到上一段
assert result.segment_count == 1
assert result.segments[0].text == "这是一段很长的字幕内容短语"
def test_tail_short_merged_with_previous(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五六七八", 0, 2), # 8
SubtitleSegment("", 2, 2.5), # 1,太短了
]
)
result = tl.merge_short_segments(min_chars=5)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八尾"
def test_preserves_language_and_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a", 0, 1)],
language="en",
total_duration=10.0,
)
result = tl.merge_short_segments()
assert result.language == "en"
assert result.total_duration == 10.0
def test_default_min_chars_is_8(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五", 0, 1), # 5 < 8
SubtitleSegment("六七八", 1, 2), # 3 → 5+3=8
]
)
result = tl.merge_short_segments()
assert result.segment_count == 1
def test_merges_words(self):
words1 = [SubtitleWord("", 0.0, 0.3), SubtitleWord("", 0.3, 0.6)]
words2 = [SubtitleWord("", 1.0, 1.3), SubtitleWord("", 1.3, 1.6)]
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0.0, 0.6, words=words1),
SubtitleSegment("世界", 1.0, 1.6, words=words2),
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
assert result.segments[0].words[0].text == ""
assert result.segments[0].words[3].text == ""
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
original_count = tl.segment_count
tl.merge_short_segments(min_chars=5)
assert tl.segment_count == original_count
# ── split_long_segments ──────────────────────────────────────────────────────
class TestSplitLongSegments:
"""split_long_segments 拆分过长片段"""
def test_short_segment_no_split(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("短文本", 0, 1),
]
)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 1
assert result.segments[0].text == "短文本"
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.split_long_segments()
assert result.segment_count == 0
def test_split_by_sentence_end(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(
"这是第一句话。这是第二句话。这是第三句话。",
start=0.0,
end=9.0,
),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 第一句应该是完整的
assert result.segments[0].text.endswith("")
def test_split_preserves_total_text(self):
original = "这是第一句话。这是第二句话。这是第三句话,很长的一句话。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(original, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=8)
# 拆分后所有片段拼起来应该等于原文
combined = "".join(s.text for s in result.segments)
assert combined == original
def test_split_time_proportional(self):
text = "一二三四五六七八九十。一二三四五六七八九十。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=12)
assert result.segment_count >= 2
# 第一段结束时间应该早于总时长
assert result.segments[0].end < 10.0
# 最后一段结束应该等于原结束时间
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_no_punctuation_hard_split(self):
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3
combined = "".join(s.text for s in result.segments)
assert combined == text
def test_multiple_mixed_segments(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
SubtitleSegment("这是一段非常非常长的字幕文本内容需要拆分", 1, 5),
SubtitleSegment("短的", 5, 6),
]
)
result = tl.split_long_segments(max_chars=10)
# 第一个和第三个保持不变,中间被拆分
assert result.segment_count > 3
assert result.segments[0].text == ""
assert result.segments[-1].text == "短的"
def test_preserves_language_and_total_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a" * 30, 0, 10)],
language="ja",
total_duration=20.0,
)
result = tl.split_long_segments(max_chars=10)
assert result.language == "ja"
assert result.total_duration == 20.0
def test_default_max_chars_is_20(self):
text = "" * 25
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=5),
]
)
result = tl.split_long_segments()
assert result.segment_count >= 2
def test_split_with_words(self):
words = [SubtitleWord(f"w{i}", i * 0.5, i * 0.5 + 0.4) for i in range(20)]
text = "".join(w.text for w in words)
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0, words=words),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 所有片段的词数之和应该等于原词数
total_words = sum(len(s.words) for s in result.segments)
assert total_words <= len(words) + 1 # 可能有边界误差
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a" * 30, 0, 10),
]
)
original_count = tl.segment_count
tl.split_long_segments(max_chars=10)
assert tl.segment_count == original_count
# ── _split_text_by_punctuation 静态方法 ─────────────────────────────────────
class TestSplitTextByPunctuation:
"""_split_text_by_punctuation 静态方法"""
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", max_chars=20)
assert len(result) == 1
assert result[0] == "短文本"
def test_sentence_end_punctuation_split(self):
result = SubtitleTimeline._split_text_by_punctuation(
"第一句。第二句。第三句。",
max_chars=5,
)
assert len(result) >= 2
assert result[0] == "第一句。"
def test_clause_pause_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"今天天气很好,阳光明媚,适合出去玩。",
max_chars=8,
)
assert len(result) >= 2
def test_exclamation_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"太精彩了!真的很棒!",
max_chars=5,
)
assert len(result) >= 2
def test_question_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"你是谁?从哪里来?",
max_chars=5,
)
assert len(result) >= 2
def test_english_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"Hello, world! How are you?",
max_chars=10,
)
assert len(result) >= 2
def test_no_punctuation_hard_split(self):
text = "" * 25
result = SubtitleTimeline._split_text_by_punctuation(text, max_chars=10)
assert len(result) >= 3
assert "".join(result) == text
def test_empty_string(self):
result = SubtitleTimeline._split_text_by_punctuation("", max_chars=10)
assert len(result) == 0 or (len(result) == 1 and result[0] == "")
def test_semicolon_colon(self):
result = SubtitleTimeline._split_text_by_punctuation(
"注意事项:第一,要认真;第二,要仔细。",
max_chars=8,
)
assert len(result) >= 2
# ── _merge_segments 静态方法 ────────────────────────────────────────────────
class TestMergeSegmentsStatic:
"""_merge_segments 静态方法"""
def test_empty_list(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_single_segment(self):
seg = SubtitleSegment("hello", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "hello"
assert result.start == 1.0
assert result.end == 2.0
def test_two_segments(self):
s1 = SubtitleSegment("你好", 0.0, 1.0)
s2 = SubtitleSegment("世界", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([s1, s2])
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 2.0
def test_merges_words(self):
w1 = [SubtitleWord("", 0, 0.5)]
w2 = [SubtitleWord("", 0.5, 1.0)]
s1 = SubtitleSegment("", 0, 0.5, words=w1)
s2 = SubtitleSegment("", 0.5, 1.0, words=w2)
result = SubtitleTimeline._merge_segments([s1, s2])
assert len(result.words) == 2
assert result.words[0].text == ""
assert result.words[1].text == ""
# ── 端到端:先合并再拆分 ────────────────────────────────────────────────────
class TestMergeAndSplit:
"""合并和拆分组合使用"""
def test_merge_then_split_roundtrip(self):
# 很多短句先合并,再按合理长度拆分
segments = [
SubtitleSegment("你好", 0, 0.5),
SubtitleSegment("我是小明", 0.5, 1.5),
SubtitleSegment("今天天气真好。", 1.5, 3.0),
SubtitleSegment("我们出去玩吧。", 3.0, 5.0),
]
tl = SubtitleTimeline(segments=segments)
merged = tl.merge_short_segments(min_chars=5)
split = merged.split_long_segments(max_chars=15)
# 结果应该合理(不保证完全一样,但文本应该完整)
original_text = "".join(s.text for s in segments)
result_text = "".join(s.text for s in split.segments)
assert original_text == result_text