diff --git a/tests/unit/test_bgm_utils.py b/tests/unit/test_bgm_utils.py index 523a8331a..c1cd83ea5 100755 --- a/tests/unit/test_bgm_utils.py +++ b/tests/unit/test_bgm_utils.py @@ -1,105 +1,164 @@ -"""BGM 配置工具函数单元测试.""" +"""BGM工具函数单元测试。""" -import pytest +from __future__ import annotations -from packages.domain.bgm_utils import merge_bgm_config +from domain.bgm_utils import merge_bgm_config -class TestMergeBgmConfig: - """merge_bgm_config 测试""" +class TestMergeBgmConfigBothEmpty: + """两边都为空的情况。""" - def test_user_bgm_empty_returns_template_copy(self): - """用户配置为空时,返回模板配置的拷贝""" - template = {"enabled": True, "volume": 0.5, "asset_id": "tpl_123"} - result = merge_bgm_config(template, {}) - assert result == template - assert result is not template - - def test_user_bgm_none_returns_template_copy(self): - """用户配置为 None 时,返回模板配置的拷贝""" - template = {"enabled": True, "volume": 0.5} - result = merge_bgm_config(template, None) # type: ignore - assert result == template - - def test_template_bgm_empty_returns_user_copy(self): - """模板配置为空时,返回用户配置的拷贝""" - user = {"enabled": False, "volume": 0.8, "asset_id": "user_456"} - result = merge_bgm_config({}, user) - assert result == user - assert result is not user - - def test_template_bgm_none_returns_user_copy(self): - """模板配置为 None 时,返回用户配置的拷贝""" - user = {"enabled": False, "volume": 0.8} - result = merge_bgm_config(None, user) # type: ignore - assert result == user - - def test_user_fields_override_template(self): - """用户显式指定的字段覆盖模板对应字段""" - template = { - "enabled": True, - "volume": 0.5, - "asset_id": "tpl_123", - "fade_in": 1.0, - } - user = { - "volume": 0.8, - "asset_id": "user_456", - } - result = merge_bgm_config(template, user) - assert result["volume"] == 0.8 - assert result["asset_id"] == "user_456" - assert result["fade_in"] == 1.0 # 模板值保留 - - def test_enabled_not_in_user_preserves_template_enabled(self): - """enabled 特殊处理:用户没传 enabled 时保留模板的 enabled 值""" - template = {"enabled": True, "volume": 0.5} - user = {"volume": 0.8} # 没传 enabled - result = merge_bgm_config(template, user) - assert result["enabled"] is True # 保留模板的 - assert result["volume"] == 0.8 # 用户指定的覆盖 - - def test_enabled_in_user_overrides_template(self): - """用户传了 enabled 时覆盖模板的 enabled""" - template = {"enabled": True, "volume": 0.5} - user = {"enabled": False, "volume": 0.8} - result = merge_bgm_config(template, user) - assert result["enabled"] is False - assert result["volume"] == 0.8 - - def test_user_adds_new_fields(self): - """用户配置中的新字段会被添加到结果中""" - template = {"enabled": True, "volume": 0.5} - user = {"sidechain_enabled": True, "sidechain_ratio": 0.6} - result = merge_bgm_config(template, user) - assert result["enabled"] is True - assert result["volume"] == 0.5 - assert result["sidechain_enabled"] is True - assert result["sidechain_ratio"] == 0.6 - - def test_both_empty_returns_empty_dict(self): - """两者都为空时返回空字典""" + def test_both_empty(self): result = merge_bgm_config({}, {}) assert result == {} + # 确保返回的是新字典,不是同一个引用 + assert result is not {} - def test_nested_dict_shallow_merge(self): - """嵌套字典是浅合并(当前设计)""" - template = {"enabled": True, "config": {"eq": True, "compression": False}} - user = {"config": {"compression": True, "reverb": 0.5}} + def test_user_none_returns_template_copy(self): + """用户传 None 视为空配置,返回模板副本。""" + result = merge_bgm_config({}, None) + assert result == {} + + +class TestMergeBgmConfigOnlyTemplate: + """只有模板配置。""" + + def test_only_template_returns_copy(self): + template = {"enabled": True, "volume": 0.5, "track": "default.mp3"} + result = merge_bgm_config(template, {}) + assert result == template + # 确保是副本,不是同一引用 + result["volume"] = 0.9 + assert template["volume"] == 0.5 + + def test_only_template_with_none_user(self): + template = {"enabled": True, "volume": 0.5} + result = merge_bgm_config(template, None) + assert result == template + + +class TestMergeBgmConfigOnlyUser: + """只有用户配置。""" + + def test_only_user_returns_copy(self): + user = {"enabled": False, "volume": 0.8} + result = merge_bgm_config({}, user) + assert result == user + # 确保是副本 + result["volume"] = 0.1 + assert user["volume"] == 0.8 + + def test_only_user_with_none_template(self): + user = {"enabled": False} + result = merge_bgm_config(None, user) + assert result == user + + +class TestMergeBgmConfigBasicOverride: + """用户配置覆盖模板配置。""" + + def test_volume_override(self): + template = {"enabled": True, "volume": 0.5} + user = {"volume": 0.8} result = merge_bgm_config(template, user) - # 浅合并:整个 config 被用户值覆盖 - assert result["config"] == {"compression": True, "reverb": 0.5} + assert result["volume"] == 0.8 + assert result["enabled"] is True # 用户没传,保留模板 - def test_does_not_mutate_template(self): - """不修改原始模板配置""" + def test_track_override(self): + template = {"track": "default.mp3", "volume": 0.5} + user = {"track": "custom.mp3"} + result = merge_bgm_config(template, user) + assert result["track"] == "custom.mp3" + assert result["volume"] == 0.5 + + def test_multiple_fields_override(self): + template = {"enabled": True, "volume": 0.5, "track": "a.mp3", "fade_in": 2} + user = {"volume": 0.9, "track": "b.mp3"} + result = merge_bgm_config(template, user) + assert result["volume"] == 0.9 + assert result["track"] == "b.mp3" + assert result["fade_in"] == 2 + assert result["enabled"] is True + + +class TestMergeBgmConfigEnabledSpecialHandling: + """enabled 字段的特殊处理:用户没传就保留模板的。""" + + def test_user_does_not_pass_enabled_keeps_template_true(self): + template = {"enabled": True, "volume": 0.5} + user = {"volume": 0.8} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + + def test_user_does_not_pass_enabled_keeps_template_false(self): + template = {"enabled": False, "volume": 0.5} + user = {"volume": 0.8} + result = merge_bgm_config(template, user) + assert result["enabled"] is False + + def test_user_explicitly_sets_enabled_true(self): + template = {"enabled": False, "volume": 0.5} + user = {"enabled": True} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + + def test_user_explicitly_sets_enabled_false(self): + template = {"enabled": True, "volume": 0.5} + user = {"enabled": False} + result = merge_bgm_config(template, user) + assert result["enabled"] is False + + def test_template_no_enabled_user_no_enabled(self): + template = {"volume": 0.5} + user = {"track": "a.mp3"} + result = merge_bgm_config(template, user) + assert "enabled" not in result + + def test_template_no_enabled_user_has_enabled(self): + template = {"volume": 0.5} + user = {"enabled": True} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + + def test_user_sets_enabled_none_explicitly(self): + """用户显式传 None 也视为传了,会覆盖模板。""" + template = {"enabled": True, "volume": 0.5} + user = {"enabled": None} + result = merge_bgm_config(template, user) + assert result["enabled"] is None + + +class TestMergeBgmConfigNewFields: + """用户配置新增模板没有的字段。""" + + def test_user_adds_new_field(self): + template = {"volume": 0.5} + user = {"fade_out": 3} + result = merge_bgm_config(template, user) + assert result["volume"] == 0.5 + assert result["fade_out"] == 3 + + def test_user_adds_multiple_new_fields(self): + template = {"enabled": True} + user = {"volume": 0.7, "track": "x.mp3", "loop": True} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + assert result["volume"] == 0.7 + assert result["track"] == "x.mp3" + assert result["loop"] is True + + +class TestMergeBgmConfigImmutableInput: + """确保输入字典不被修改。""" + + def test_template_not_modified(self): template = {"enabled": True, "volume": 0.5} original = dict(template) - merge_bgm_config(template, {"volume": 0.8}) + merge_bgm_config(template, {"volume": 0.9}) assert template == original - def test_does_not_mutate_user(self): - """不修改原始用户配置""" - user = {"volume": 0.8} + def test_user_not_modified(self): + user = {"enabled": False, "track": "x.mp3"} original = dict(user) - merge_bgm_config({"enabled": True}, user) + merge_bgm_config({"volume": 0.5}, user) assert user == original diff --git a/tests/unit/test_classification_domain.py b/tests/unit/test_classification_domain.py index 9b1edaa19..efa6aeae9 100755 --- a/tests/unit/test_classification_domain.py +++ b/tests/unit/test_classification_domain.py @@ -1,139 +1,210 @@ -"""classification 模块单元测试.""" +"""分类领域模型单元测试 - 纯逻辑部分。""" + +from __future__ import annotations import pytest + from domain.classification import ( AssetClassification, AssetLibraryKind, ClassificationJob, ClassificationJobStatus, + ClassificationStatus, IngestJobStatus, ) class TestAssetLibraryKind: - """AssetLibraryKind 枚举测试.""" + """素材库类型枚举。""" - def test_values(self): + def test_video_value(self): assert AssetLibraryKind.VIDEO == "video" + + def test_voice_value(self): assert AssetLibraryKind.VOICE == "voice" + def test_image_value(self): + assert AssetLibraryKind.IMAGE == "image" + + def test_is_str_enum(self): + assert isinstance(AssetLibraryKind.VIDEO, str) + assert AssetLibraryKind.VIDEO + "_test" == "video_test" + + def test_members_count(self): + assert len(AssetLibraryKind) == 3 + class TestIngestJobStatus: - """IngestJobStatus 枚举测试.""" + """导入任务状态枚举。""" - def test_values(self): + def test_pending_value(self): assert IngestJobStatus.PENDING == "pending" + + def test_processing_value(self): assert IngestJobStatus.PROCESSING == "processing" + + def test_completed_value(self): assert IngestJobStatus.COMPLETED == "completed" + + def test_failed_value(self): assert IngestJobStatus.FAILED == "failed" + def test_members_count(self): + assert len(IngestJobStatus) == 4 -class TestClassificationJobStatus: - """ClassificationJobStatus 枚举测试.""" - def test_values(self): - assert ClassificationJobStatus.PENDING == "pending" - assert ClassificationJobStatus.PROCESSING == "processing" - assert ClassificationJobStatus.COMPLETED == "completed" - assert ClassificationJobStatus.FAILED == "failed" +class TestClassificationJobStatusMissing: + """ClassificationJobStatus._missing_ 兼容性测试。""" + + def test_standard_values(self): + """标准值正常解析。""" + assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING + assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING + assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED + + def test_done_maps_to_completed(self): + """历史值 done 映射到 COMPLETED。""" + assert ClassificationJobStatus("done") == ClassificationJobStatus.COMPLETED + + def test_success_maps_to_completed(self): + assert ClassificationJobStatus("success") == ClassificationJobStatus.COMPLETED + + def test_finished_maps_to_completed(self): + assert ClassificationJobStatus("finished") == ClassificationJobStatus.COMPLETED + + def test_complete_maps_to_completed(self): + assert ClassificationJobStatus("complete") == ClassificationJobStatus.COMPLETED + + def test_fail_maps_to_failed(self): + assert ClassificationJobStatus("fail") == ClassificationJobStatus.FAILED + + def test_error_maps_to_failed(self): + assert ClassificationJobStatus("error") == ClassificationJobStatus.FAILED + + def test_err_maps_to_failed(self): + assert ClassificationJobStatus("err") == ClassificationJobStatus.FAILED + + def test_process_maps_to_processing(self): + assert ClassificationJobStatus("process") == ClassificationJobStatus.PROCESSING + + def test_running_maps_to_processing(self): + assert ClassificationJobStatus("running") == ClassificationJobStatus.PROCESSING + + def test_run_maps_to_processing(self): + assert ClassificationJobStatus("run") == ClassificationJobStatus.PROCESSING + + def test_unknown_value_defaults_to_pending(self): + """未知值兜底为 PENDING。""" + assert ClassificationJobStatus("unknown") == ClassificationJobStatus.PENDING + assert ClassificationJobStatus("whatever") == ClassificationJobStatus.PENDING + assert ClassificationJobStatus("") == ClassificationJobStatus.PENDING + + def test_case_insensitive(self): + """大小写不敏感。""" + assert ClassificationJobStatus("DONE") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("Done") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("FAIL") == ClassificationJobStatus.FAILED + assert ClassificationJobStatus("Error") == ClassificationJobStatus.FAILED + + def test_stripped(self): + """前后空白字符被忽略。""" + assert ClassificationJobStatus(" done ") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("\tfail\n") == ClassificationJobStatus.FAILED + + def test_none_returns_pending(self): + """None 值也返回 PENDING(不报错)。""" + assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING + + def test_integer_returns_pending(self): + """非字符串值返回 PENDING。""" + assert ClassificationJobStatus(123) == ClassificationJobStatus.PENDING + + +class TestClassificationStatusAlias: + """向后兼容别名。""" + + def test_alias_same_class(self): + assert ClassificationStatus is ClassificationJobStatus + + def test_alias_values_same(self): + assert ClassificationStatus.PENDING == ClassificationJobStatus.PENDING + assert ClassificationStatus.COMPLETED == ClassificationJobStatus.COMPLETED class TestAssetClassification: - """AssetClassification 枚举测试.""" + """素材分类枚举。""" - def test_values(self): + def test_scenic(self): assert AssetClassification.SCENIC == "scenic" + + def test_product(self): assert AssetClassification.PRODUCT == "product" + + def test_person(self): assert AssetClassification.PERSON == "person" + + def test_animal(self): assert AssetClassification.ANIMAL == "animal" + + def test_food(self): assert AssetClassification.FOOD == "food" + + def test_tech(self): assert AssetClassification.TECH == "tech" + + def test_sport(self): assert AssetClassification.SPORT == "sport" + + def test_music(self): assert AssetClassification.MUSIC == "music" + + def test_other(self): assert AssetClassification.OTHER == "other" + def test_members_count(self): + assert len(AssetClassification) == 9 + class TestClassificationJobCreate: - """ClassificationJob.create 工厂方法测试.""" + """ClassificationJob.create 工厂方法。""" - def test_create_with_valid_params(self): - job = ClassificationJob.create(project_id="proj_001", asset_id="asset_001") - assert job.id - assert len(job.id) == 32 - assert job.project_id == "proj_001" - assert job.asset_id == "asset_001" + def test_create_basic(self): + job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") + assert job.project_id == "proj-1" + assert job.asset_id == "asset-1" assert job.status == ClassificationJobStatus.PENDING assert job.classification == "" assert job.confidence == 0.0 assert job.error_message == "" - assert job.created_at is not None - assert job.updated_at is not None + assert job.id # 自动生成的 ID 非空 - def test_create_strips_strings(self): - job = ClassificationJob.create( - project_id=" proj_002 ", - asset_id=" asset_002 ", - ) - assert job.project_id == "proj_002" - assert job.asset_id == "asset_002" + def test_create_strips_whitespace(self): + job = ClassificationJob.create(project_id=" proj-1 ", asset_id="\tasset-1\n") + assert job.project_id == "proj-1" + assert job.asset_id == "asset-1" def test_create_empty_project_id_raises(self): - with pytest.raises(ValueError, match="project_id"): - ClassificationJob.create(project_id="", asset_id="a") + with pytest.raises(ValueError, match="project_id 不能为空"): + ClassificationJob.create(project_id="", asset_id="asset-1") def test_create_whitespace_project_id_raises(self): - with pytest.raises(ValueError, match="project_id"): - ClassificationJob.create(project_id=" ", asset_id="a") + with pytest.raises(ValueError, match="project_id 不能为空"): + ClassificationJob.create(project_id=" ", asset_id="asset-1") def test_create_empty_asset_id_raises(self): - with pytest.raises(ValueError, match="asset_id"): - ClassificationJob.create(project_id="p", asset_id="") + with pytest.raises(ValueError, match="asset_id 不能为空"): + ClassificationJob.create(project_id="proj-1", asset_id="") def test_create_whitespace_asset_id_raises(self): - with pytest.raises(ValueError, match="asset_id"): - ClassificationJob.create(project_id="p", asset_id=" ") + with pytest.raises(ValueError, match="asset_id 不能为空"): + ClassificationJob.create(project_id="proj-1", asset_id=" \t ") - def test_create_ids_are_unique(self): - j1 = ClassificationJob.create(project_id="p", asset_id="a") - j2 = ClassificationJob.create(project_id="p", asset_id="b") - assert j1.id != j2.id + def test_create_generates_unique_ids(self): + job1 = ClassificationJob.create(project_id="p", asset_id="a") + job2 = ClassificationJob.create(project_id="p", asset_id="a") + assert job1.id != job2.id - def test_create_timestamps_are_utc(self): + def test_create_id_is_hex(self): job = ClassificationJob.create(project_id="p", asset_id="a") - assert job.created_at.tzinfo is not None - assert job.updated_at.tzinfo is not None - - -class TestClassificationJobState: - """ClassificationJob 状态操作测试""" - - def test_set_processing(self): - job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") - job.status = ClassificationJobStatus.PROCESSING - assert job.status == ClassificationJobStatus.PROCESSING - - def test_set_completed_with_result(self): - job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") - job.status = ClassificationJobStatus.COMPLETED - job.classification = AssetClassification.SCENIC - job.confidence = 0.95 - assert job.status == ClassificationJobStatus.COMPLETED - assert job.classification == "scenic" - assert job.confidence == pytest.approx(0.95) - - def test_set_failed_with_error(self): - job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") - job.status = ClassificationJobStatus.FAILED - job.error_message = "model timeout" - assert job.status == ClassificationJobStatus.FAILED - assert job.error_message == "model timeout" - - def test_confidence_range_zero(self): - job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") - job.confidence = 0.0 - assert job.confidence == 0.0 - - def test_confidence_range_one(self): - job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1") - job.confidence = 1.0 - assert job.confidence == 1.0 + int(job.id, 16) # 不抛异常就是合法 hex diff --git a/tests/unit/test_subtitle_domain.py b/tests/unit/test_subtitle_domain.py index 734c3c038..21123b883 100755 --- a/tests/unit/test_subtitle_domain.py +++ b/tests/unit/test_subtitle_domain.py @@ -1,272 +1,330 @@ -"""字幕领域模型单元测试.""" +"""字幕领域模型单元测试 - 纯逻辑部分。""" from __future__ import annotations -from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord +import pytest + +from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord class TestSubtitleWord: - """SubtitleWord 测试.""" + """单个词级别字幕单元。""" - def test_basic_properties(self): - word = SubtitleWord(text="你好", start=1.0, end=1.5) + def test_basic_creation(self): + word = SubtitleWord(text="你好", start=0.0, end=0.5) assert word.text == "你好" - assert word.start == 1.0 - assert word.end == 1.5 - assert word.duration == 0.5 + assert word.start == 0.0 + assert word.end == 0.5 - def test_duration_zero_when_end_before_start(self): - word = SubtitleWord(text="test", start=2.0, end=1.0) + 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_zero_when_same_time(self): - word = SubtitleWord(text="test", start=1.0, end=1.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: - """SubtitleSegment 测试.""" + """字幕段(一句话)。""" - def test_basic_properties(self): - seg = SubtitleSegment(text="大家好", start=0.0, end=2.0) - assert seg.text == "大家好" + 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.duration == 2.0 - assert seg.char_count == 3 assert seg.words == [] - def test_duration_with_words(self): - words = [ - 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=1.5, words=words) - assert seg.duration == 1.5 - assert seg.char_count == 3 - assert len(seg.words) == 3 + 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_when_end_before_start(self): - seg = SubtitleSegment(text="test", start=3.0, end=1.0) + 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: - """SubtitleTimeline 基础属性测试.""" + """字幕时间轴基础属性。""" def test_empty_timeline(self): tl = SubtitleTimeline() - assert tl.segment_count == 0 - assert tl.total_chars == 0 + 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): - seg = SubtitleSegment(text="测试", start=0.0, end=1.0) - tl = SubtitleTimeline(segments=[seg]) + 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): - 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), + segments = [ + SubtitleSegment(text="第一句", start=0, end=1), + SubtitleSegment(text="第二句更长一点", start=1, end=3), + SubtitleSegment(text="第三句", start=3, end=4), ] - tl = SubtitleTimeline(segments=segs, total_duration=3.0) + tl = SubtitleTimeline(segments=segments, total_duration=4.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" + assert tl.total_chars == 3 + 7 + 3 # 13 -class TestSubtitleTimelineMergeShort: - """合并短字幕片段测试.""" +class TestMergeShortSegments: + """合并过短字幕片段。""" - def test_empty_or_single_no_change(self): + def test_empty_timeline_unchanged(self): tl = SubtitleTimeline() result = tl.merge_short_segments() assert result.segment_count == 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(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[0].start == 0.0 - assert result.segments[0].end == 1.0 - assert result.segments[1].text == "今天天气很好" - - 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 → 合并到上一段 + 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 == "一二三四五六七八短尾" + assert result.segments[0].text == "短" - 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), + 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=segs) - result = tl.merge_short_segments(min_chars=4) + tl = SubtitleTimeline(segments=segments) + result = tl.merge_short_segments(min_chars=3) assert result.segment_count == 1 - assert len(result.segments[0].words) == 4 + 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): - 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 + 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 - -class TestSubtitleTimelineSplitLong: - """拆分长字幕片段测试.""" - - 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) + 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.0, end=10.0) + """按句末标点拆分。""" + 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 - 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 + # 合并起来应该等于原文 + 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.0, end=10.0) + """拆分后的时间按字数比例分配。""" + text = "一二三四五六七八。二二三四五六七八。" + seg = SubtitleSegment(text=text, start=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 + 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_split_with_words(self): - words = [ - 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), - ] - text = "一二三四五六七八" - seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words) + 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=4) - assert result.segment_count >= 2 - # 词的总数应该不变 - total_words = sum(len(s.words) for s in result.segments) - assert total_words == 4 + 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_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) + 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_short_text_no_split(self): - result = SubtitleTimeline._split_text_by_punctuation("短文本", 10) - assert len(result) == 1 - - 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" + text = "一二三四五六七八九十一二三四五六七八九十" result = SubtitleTimeline._split_text_by_punctuation(text, 10) - # "你好世界。"=5字 < 10但>=5(half),应该拆分 + 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 静态方法测试.""" + """_merge_segments 静态方法。""" - def test_merge_empty(self): + 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(self): + 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_multiple(self): + def test_merge_preserves_words(self): segs = [ - SubtitleSegment(text="第一", start=0.0, end=1.0), - SubtitleSegment(text="第二", start=1.0, end=2.0), + 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 result.text == "第一第二" - assert result.start == 0.0 - assert result.end == 2.0 + assert len(result.words) == 2 + assert result.words[0].text == "你好" + assert result.words[1].text == "世界"