"""字幕领域模型单元测试 - 纯逻辑部分。""" from __future__ import annotations import pytest from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord class TestSubtitleWord: """单个词级别字幕单元。""" def test_basic_creation(self): word = SubtitleWord(text="你好", start=0.0, end=0.5) assert word.text == "你好" assert word.start == 0.0 assert word.end == 0.5 def test_duration_positive(self): word = SubtitleWord(text="test", start=1.0, end=2.5) assert word.duration == pytest.approx(1.5) def test_duration_zero(self): word = SubtitleWord(text="x", start=3.0, end=3.0) assert word.duration == 0.0 def test_duration_negative_returns_zero(self): """end < start 时 duration 返回 0,不抛异常。""" word = SubtitleWord(text="x", start=5.0, end=3.0) assert word.duration == 0.0 class TestSubtitleSegment: """字幕段(一句话)。""" def test_basic_creation(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.words == [] def test_duration_positive(self): seg = SubtitleSegment(text="test", start=1.0, end=3.5) assert seg.duration == pytest.approx(2.5) def test_duration_zero(self): seg = SubtitleSegment(text="x", start=5.0, end=5.0) assert seg.duration == 0.0 def test_duration_negative_returns_zero(self): seg = SubtitleSegment(text="x", start=10.0, end=5.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_char_count_mixed_languages(self): seg = SubtitleSegment(text="你好hello世界", start=0, end=1) assert seg.char_count == 9 # 2中 + 5英 + 2中 = 9 def test_with_words(self): words = [ SubtitleWord(text="你好", start=0.0, end=0.5), SubtitleWord(text="世界", start=0.5, end=1.0), ] seg = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words) assert len(seg.words) == 2 assert seg.words[0].text == "你好" class TestSubtitleTimelineBasics: """字幕时间轴基础属性。""" def test_empty_timeline(self): tl = SubtitleTimeline() assert tl.segments == [] assert tl.language == "zh" assert tl.total_duration == 0.0 assert tl.segment_count == 0 assert tl.total_chars == 0 def test_single_segment(self): tl = SubtitleTimeline( segments=[SubtitleSegment(text="你好", start=0, end=1)], language="zh", total_duration=1.0, ) assert tl.segment_count == 1 assert tl.total_chars == 2 def test_multiple_segments(self): segments = [ SubtitleSegment(text="第一句", start=0, end=1), SubtitleSegment(text="第二句更长一点", start=1, end=3), SubtitleSegment(text="第三句", start=3, end=4), ] tl = SubtitleTimeline(segments=segments, total_duration=4.0) assert tl.segment_count == 3 assert tl.total_chars == 3 + 7 + 3 # 13 class TestMergeShortSegments: """合并过短字幕片段。""" def test_empty_timeline_unchanged(self): tl = SubtitleTimeline() result = tl.merge_short_segments() assert result.segment_count == 0 def test_single_segment_unchanged(self): tl = SubtitleTimeline(segments=[SubtitleSegment(text="短", start=0, end=0.5)]) result = tl.merge_short_segments() assert result.segment_count == 1 assert result.segments[0].text == "短" def test_all_short_merged_into_one(self): """多个短片段合并成一个。""" segments = [ SubtitleSegment(text="一", start=0, end=0.2), SubtitleSegment(text="二", start=0.2, end=0.4), SubtitleSegment(text="三", start=0.4, end=0.6), ] tl = SubtitleTimeline(segments=segments) result = tl.merge_short_segments(min_chars=3) assert result.segment_count == 1 assert result.segments[0].text == "一二三" assert result.segments[0].start == 0 assert result.segments[0].end == 0.6 def test_mixed_lengths(self): """长短混合,中间短的会合并成一段。""" segments = [ SubtitleSegment(text="这是比较长的第一句", start=0, end=2), # 10字 SubtitleSegment(text="第一小段", start=2, end=2.4), # 4字 SubtitleSegment(text="第二小段", start=2.4, end=2.8), # 4字 SubtitleSegment(text="这是比较长的第四句", start=2.8, end=5), # 10字 ] tl = SubtitleTimeline(segments=segments) result = tl.merge_short_segments(min_chars=8) # 第一句10字够长单独保留;短1+短2=8字刚好够一段;第四句10字够长单独保留 assert result.segment_count == 3 assert result.segments[0].text == "这是比较长的第一句" assert result.segments[1].text == "第一小段第二小段" assert result.segments[2].text == "这是比较长的第四句" def test_preserves_language_and_duration(self): segments = [ SubtitleSegment(text="a", start=0, end=0.1), SubtitleSegment(text="b", start=0.1, end=0.2), ] tl = SubtitleTimeline(segments=segments, language="en", total_duration=10.0) result = tl.merge_short_segments(min_chars=5) assert result.language == "en" assert result.total_duration == 10.0 def test_last_short_merged_to_previous(self): """最后剩余的短片段且不够min_chars,合并到上一段。""" segments = [ SubtitleSegment(text="这是一句比较长的话", start=0, end=1.5), # 10字 SubtitleSegment(text="尾", start=1.5, end=1.6), # 1字 ] tl = SubtitleTimeline(segments=segments) result = tl.merge_short_segments(min_chars=8) # 第一句够长(10>=8),但尾只有1字不够,合并到上一句 assert result.segment_count == 1 assert result.segments[0].text == "这是一句比较长的话尾" def test_original_not_modified(self): segments = [SubtitleSegment(text="a", start=0, end=0.1)] tl = SubtitleTimeline(segments=segments) tl.merge_short_segments(min_chars=5) assert len(tl.segments) == 1 # 原对象不变 class TestSplitLongSegments: """拆分过长字幕片段。""" def test_short_segments_unchanged(self): segments = [ SubtitleSegment(text="短句", start=0, end=1), SubtitleSegment(text="另一句", start=1, end=2), ] tl = SubtitleTimeline(segments=segments) result = tl.split_long_segments(max_chars=20) assert result.segment_count == 2 assert result.segments[0].text == "短句" def test_split_by_sentence_punctuation(self): """按句末标点拆分。""" text = "这是第一句话。这是第二句话!这是第三句话?" seg = SubtitleSegment(text=text, start=0, end=3.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=8) assert result.segment_count >= 2 # 合并起来应该等于原文 merged_text = "".join(s.text for s in result.segments) assert merged_text == text def test_split_time_proportional(self): """拆分后的时间按字数比例分配。""" text = "一二三四五六七八。二二三四五六七八。" seg = SubtitleSegment(text=text, start=0, end=10.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=8) assert len(result.segments) >= 2 # 总时长不超过原时长 assert result.segments[-1].end <= seg.end # 第一个片段的开始时间正确 assert result.segments[0].start == 0.0 def test_no_punctuation_hard_split(self): """没有标点时硬切。""" text = "一二三四五六七八九十十一十二十三十四十五十六十七十八十九二十" seg = SubtitleSegment(text=text, start=0, end=5.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=10) assert len(result.segments) >= 2 merged = "".join(s.text for s in result.segments) assert merged == text def test_preserves_language_and_duration(self): seg = SubtitleSegment(text="a" * 30, start=0, end=5) tl = SubtitleTimeline(segments=[seg], language="en", total_duration=100.0) result = tl.split_long_segments(max_chars=10) assert result.language == "en" assert result.total_duration == 100.0 def test_empty_timeline_unchanged(self): tl = SubtitleTimeline() result = tl.split_long_segments() assert result.segment_count == 0 class TestSplitTextByPunctuation: """_split_text_by_punctuation 静态方法。""" def test_short_text_unchanged(self): result = SubtitleTimeline._split_text_by_punctuation("你好", 10) assert result == ["你好"] def test_split_by_period(self): text = "这是第一句话。这是第二句话。" result = SubtitleTimeline._split_text_by_punctuation(text, 8) assert len(result) == 2 assert result[0] == "这是第一句话。" assert result[1] == "这是第二句话。" def test_split_by_exclamation(self): text = "你好世界大家好!再见世界朋友们!" result = SubtitleTimeline._split_text_by_punctuation(text, 8) assert len(result) >= 2 def test_split_by_question(self): text = "今天天气好不好呢?今天天气很好呀。" result = SubtitleTimeline._split_text_by_punctuation(text, 8) assert len(result) >= 2 def test_split_by_comma_when_long(self): """超过max_chars时,遇到逗号也会断开。""" text = "这是很长的一句话,中间有个逗号,后面还有内容继续。" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert len(result) >= 2 def test_empty_text(self): result = SubtitleTimeline._split_text_by_punctuation("", 10) assert result == [] def test_no_punctuation_hard_cut(self): text = "一二三四五六七八九十一二三四五六七八九十" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert len(result) == 2 assert len(result[0]) == 10 assert len(result[1]) == 10 def test_english_punctuation(self): result = SubtitleTimeline._split_text_by_punctuation("Hello, world! How are you?", 15) assert len(result) >= 2 class TestMergeSegments: """_merge_segments 静态方法。""" def test_merge_two_segments(self): segs = [ SubtitleSegment(text="你好", start=0.0, end=0.5), SubtitleSegment(text="世界", start=0.5, end=1.0), ] result = SubtitleTimeline._merge_segments(segs) assert result.text == "你好世界" assert result.start == 0.0 assert result.end == 1.0 def test_merge_empty_list(self): result = SubtitleTimeline._merge_segments([]) assert result.text == "" assert result.start == 0 assert result.end == 0 def test_merge_single_segment(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): segs = [ SubtitleSegment( text="你好", start=0.0, end=0.5, words=[SubtitleWord(text="你好", start=0.0, end=0.5)], ), SubtitleSegment( text="世界", start=0.5, end=1.0, words=[SubtitleWord(text="世界", start=0.5, end=1.0)], ), ] result = SubtitleTimeline._merge_segments(segs) assert len(result.words) == 2 assert result.words[0].text == "你好" assert result.words[1].text == "世界" class TestMergeShortSegmentsEdgeCases: """merge_short_segments 边界情况深度测试.""" def test_all_segments_too_short_merge_into_one(self): """所有片段都很短,全部合并成一段.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="一", start=0.0, end=0.5), SubtitleSegment(text="二", start=0.5, end=1.0), SubtitleSegment(text="三", start=1.0, end=1.5), ], total_duration=1.5, ) result = timeline.merge_short_segments(min_chars=8) assert result.segment_count == 1 assert result.segments[0].text == "一二三" assert result.segments[0].start == 0.0 assert result.segments[0].end == 1.5 def test_exactly_min_chars_no_merge(self): """刚好等于 min_chars,不合并.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0), SubtitleSegment(text="一二三四五六七八", start=1.0, end=2.0), ], total_duration=2.0, ) result = timeline.merge_short_segments(min_chars=8) assert result.segment_count == 2 def test_min_chars_one(self): """min_chars=1 时每个都够,不合并.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="一", start=0.0, end=0.5), SubtitleSegment(text="二", start=0.5, end=1.0), ], total_duration=1.0, ) result = timeline.merge_short_segments(min_chars=1) assert result.segment_count == 2 def test_merge_preserves_words_order(self): """合并后词的顺序保持正确.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment( text="你好", start=0.0, end=0.5, words=[ SubtitleWord(text="你", start=0.0, end=0.25), SubtitleWord(text="好", start=0.25, end=0.5), ], ), SubtitleSegment( text="世界", start=0.5, end=1.0, words=[ SubtitleWord(text="世", start=0.5, end=0.75), SubtitleWord(text="界", start=0.75, end=1.0), ], ), ], total_duration=1.0, ) result = timeline.merge_short_segments(min_chars=8) assert result.segment_count == 1 words = result.segments[0].words assert len(words) == 4 assert [w.text for w in words] == ["你", "好", "世", "界"] def test_last_segment_short_merges_with_previous(self): """最后一段太短,合并到前一段.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0), SubtitleSegment(text="九", start=1.0, end=1.2), ], total_duration=1.2, ) result = timeline.merge_short_segments(min_chars=8) assert result.segment_count == 1 assert result.segments[0].text == "一二三四五六七八九" def test_min_chars_one_no_merge(self): """min_chars=1 时每个都够,不合并.""" tl = SubtitleTimeline( segments=[ SubtitleSegment(text="一", start=0.0, end=0.5), SubtitleSegment(text="二", start=0.5, end=1.0), SubtitleSegment(text="三", start=1.0, end=1.5), ], ) result = tl.merge_short_segments(min_chars=1) assert result.segment_count == 3 def test_min_chars_very_large_all_merged(self): """min_chars 极大,全部合并成一段.""" tl = SubtitleTimeline( segments=[ SubtitleSegment(text="第一句", start=0.0, end=1.0), SubtitleSegment(text="第二句", start=1.0, end=2.0), SubtitleSegment(text="第三句", start=2.0, end=3.0), ], ) result = tl.merge_short_segments(min_chars=100) assert result.segment_count == 1 assert result.total_chars == tl.total_chars def test_merge_preserves_word_level_info(self): """合并后词级信息完整保留,顺序正确.""" w1 = [SubtitleWord(text="你", start=0.0, end=0.3)] w2 = [SubtitleWord(text="好", start=0.3, end=0.6)] tl = SubtitleTimeline( segments=[ SubtitleSegment(text="你", start=0.0, end=0.3, words=w1), SubtitleSegment(text="好", start=0.3, end=0.6, words=w2), ], ) result = tl.merge_short_segments(min_chars=4) assert result.segment_count == 1 assert len(result.segments[0].words) == 2 assert result.segments[0].words[0].text == "你" assert result.segments[0].words[1].text == "好" class TestSplitLongSegmentsEdgeCases: """split_long_segments 边界情况深度测试.""" def test_mixed_long_and_short(self): """长短片段混合,只拆分长的.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="短", start=0.0, end=0.5), SubtitleSegment( text="这是一段非常长的字幕内容需要被拆分", start=0.5, end=3.0, ), SubtitleSegment(text="短", start=3.0, end=3.5), ], total_duration=3.5, ) result = timeline.split_long_segments(max_chars=10) assert result.segment_count > 3 # 中间那段被拆分了 assert result.segments[0].text == "短" assert result.segments[-1].text == "短" def test_split_total_duration_preserved(self): """拆分后总时长不变.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment( text="一二三四五六七八九十一二三四五六七八九十", start=0.0, end=10.0, ), ], total_duration=10.0, ) result = timeline.split_long_segments(max_chars=10) assert result.segment_count > 1 assert result.segments[0].start == 0.0 assert abs(result.segments[-1].end - 10.0) < 0.01 def test_max_chars_very_small(self): """max_chars 很小,每个字都要拆.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="一二三", start=0.0, end=3.0), ], total_duration=3.0, ) result = timeline.split_long_segments(max_chars=1) # 没有标点,硬切 assert result.segment_count >= 3 def test_empty_segments_list(self): """空片段列表不报错.""" timeline = SubtitleTimeline(segments=[], total_duration=0.0) result = timeline.split_long_segments(max_chars=10) assert result.segment_count == 0 def test_exactly_max_chars_no_split(self): """恰好等于 max_chars 不拆分.""" text = "一二三四五六七八九十" # 10字 seg = SubtitleSegment(text=text, start=0.0, end=5.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=10) assert result.segment_count == 1 def test_one_char_over_triggers_split(self): """超过1个字符就触发拆分.""" text = "一二三四五六七八九十1" # 11字 seg = SubtitleSegment(text=text, start=0.0, end=5.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=10) assert result.segment_count >= 2 def test_mixed_short_and_long_segments(self): """长短片段混合,只拆分超长的.""" tl = SubtitleTimeline( segments=[ SubtitleSegment(text="短", start=0.0, end=0.5), SubtitleSegment( text="这是一段很长很长需要拆分的字幕内容", start=0.5, end=3.0, ), SubtitleSegment(text="短", start=3.0, end=3.5), ], ) 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_total_chars_preserved_after_split(self): """拆分后总字数保持不变.""" text = "一二三四五六七八九十一二三四五六七八九十一二三四五" seg = SubtitleSegment(text=text, start=0.0, end=10.0) tl = SubtitleTimeline(segments=[seg]) result = tl.split_long_segments(max_chars=10) assert result.total_chars == len(text) class TestSplitTextByPunctuationDeep: """_split_text_by_punctuation 深度测试.""" def test_multiple_punctuation_types(self): """多种标点符号混合.""" text = "你好!世界?测试,哈哈。" result = SubtitleTimeline._split_text_by_punctuation(text, 5) assert len(result) >= 2 def test_consecutive_punctuation(self): """连续标点符号.""" text = "你好!!!测试。。。" result = SubtitleTimeline._split_text_by_punctuation(text, 3) assert len(result) >= 1 # 确保所有字符都保留 assert "".join(result) == text def test_no_punctuation_long_text(self): """长文本没有标点,硬切.""" text = "一二三四五六七八九十一二三四五六七八九十" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert len(result) >= 2 assert "".join(result) == text # 每段不超过 max_chars for part in result[:-1]: # 最后一段可能短一些 assert len(part) <= 10 def test_punctuation_at_start(self): """标点在开头.""" text = ",你好世界" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert "".join(result) == text def test_punctuation_at_end(self): """标点在结尾.""" text = "你好世界!" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert "".join(result) == text assert result[-1].endswith("!") class TestSplitTextByPunctuationEdgeCases: """_split_text_by_punctuation 边界场景补充.""" def test_colon_semicolon_splits(self): """冒号分号也能触发拆分.""" text = "第一段:第二段;第三段" result = SubtitleTimeline._split_text_by_punctuation(text, 5) assert len(result) >= 2 def test_punctuation_at_start(self): """标点在开头不崩溃,字符完整保留.""" text = ",你好世界" result = SubtitleTimeline._split_text_by_punctuation(text, 10) assert "".join(result) == text def test_consecutive_punctuation(self): """连续标点符号,字符完整保留.""" text = "你好!!!测试。。。" result = SubtitleTimeline._split_text_by_punctuation(text, 3) assert "".join(result) == text def test_single_character_text(self): """单字符文本不拆分.""" result = SubtitleTimeline._split_text_by_punctuation("你", 10) assert len(result) == 1 assert result[0] == "你" def test_only_punctuation(self): """纯标点符号文本不崩溃.""" result = SubtitleTimeline._split_text_by_punctuation("。。。", 10) assert isinstance(result, list) assert "".join(result) == "。。。" def test_mixed_fullwidth_halfwidth_punctuation(self): """全角半角标点混合.""" text = "你好,世界!测试?完成" result = SubtitleTimeline._split_text_by_punctuation(text, 5) assert "".join(result) == text assert len(result) >= 2 class TestSubtitleTimelineProperties: """SubtitleTimeline 属性计算深度测试.""" def test_total_chars_empty(self): """空时间轴 total_chars 为 0.""" timeline = SubtitleTimeline(segments=[]) assert timeline.total_chars == 0 def test_total_chars_sum(self): """total_chars 等于所有片段字数之和.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="你好", start=0, end=1), SubtitleSegment(text="世界", start=1, end=2), SubtitleSegment(text="123", start=2, end=3), ], ) assert timeline.total_chars == 2 + 2 + 3 def test_segment_count(self): """segment_count 正确.""" timeline = SubtitleTimeline( segments=[ SubtitleSegment(text="a", start=0, end=1), SubtitleSegment(text="b", start=1, end=2), ], ) assert timeline.segment_count == 2 def test_empty_segment_char_count(self): """空片段 char_count 为 0.""" seg = SubtitleSegment(text="", start=0.0, end=1.0) assert seg.char_count == 0