"""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