From 0801a98cc1271905b979dbb1db3ed64d0f756583 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 25 Jul 2026 21:15:13 +0800 Subject: [PATCH] =?UTF-8?q?test(P3-1):=20=E7=AC=AC85=E6=B3=A2=20exceptions?= =?UTF-8?q?+bgm=5Futils+subtitle=E6=B7=B1=E5=BA=A6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=EF=BC=88+59=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 test_exceptions_domain.py:22个测试,覆盖4个异常类的继承关系、属性、消息格式 - 新增 test_bgm_utils_domain.py:19个测试,覆盖merge_bgm_config各种场景(空值、覆盖、enabled特殊处理、不变性) - test_subtitle_domain.py 补充18个深度测试:合并边界、拆分边界、标点深度、属性计算 --- tests/unit/test_bgm_utils_domain.py | 160 +++++++++++++++++++ tests/unit/test_exceptions_domain.py | 166 ++++++++++++++++++++ tests/unit/test_subtitle_domain.py | 222 +++++++++++++++++++++++++++ 3 files changed, 548 insertions(+) create mode 100755 tests/unit/test_bgm_utils_domain.py create mode 100755 tests/unit/test_exceptions_domain.py diff --git a/tests/unit/test_bgm_utils_domain.py b/tests/unit/test_bgm_utils_domain.py new file mode 100755 index 000000000..93c88b5d3 --- /dev/null +++ b/tests/unit/test_bgm_utils_domain.py @@ -0,0 +1,160 @@ +"""BGM工具函数领域层单元测试.""" + +import pytest + +from packages.domain.bgm_utils import merge_bgm_config + + +class TestMergeBgmConfig: + """merge_bgm_config 函数测试.""" + + def test_both_empty(self): + """两个都是空字典.""" + result = merge_bgm_config({}, {}) + assert result == {} + + def test_user_empty_returns_template_copy(self): + """用户配置为空,返回模板配置的拷贝.""" + template = {"enabled": True, "volume": 0.5} + result = merge_bgm_config(template, {}) + assert result == template + # 确保是副本不是引用 + result["volume"] = 0.9 + assert template["volume"] == 0.5 + + def test_template_empty_returns_user_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_user_overrides_template(self): + """用户配置覆盖模板配置.""" + template = {"enabled": True, "volume": 0.5, "track": "default"} + user = {"volume": 0.8} + result = merge_bgm_config(template, user) + assert result["volume"] == 0.8 + assert result["track"] == "default" + + def test_enabled_special_handling_user_not_set(self): + """enabled 特殊处理:用户没传就保留模板的.""" + template = {"enabled": True, "volume": 0.5} + user = {"volume": 0.8} # 没传 enabled + result = merge_bgm_config(template, user) + # 用户没传 enabled,保留模板的 True + assert result["enabled"] is True + assert result["volume"] == 0.8 + + def test_enabled_user_explicit_false(self): + """用户显式传 enabled=False,应该覆盖模板.""" + template = {"enabled": True, "volume": 0.5} + user = {"enabled": False} + result = merge_bgm_config(template, user) + assert result["enabled"] is False + + def test_enabled_user_explicit_true(self): + """用户显式传 enabled=True,覆盖模板的 False.""" + template = {"enabled": False, "volume": 0.5} + user = {"enabled": True} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + + def test_full_override(self): + """用户完全覆盖模板.""" + template = {"enabled": True, "volume": 0.3, "track": "piano"} + user = {"enabled": False, "volume": 0.9, "track": "guitar"} + result = merge_bgm_config(template, user) + assert result == user + + def test_partial_override_keep_rest(self): + """部分覆盖,其余保留模板值.""" + template = { + "enabled": True, + "volume": 0.5, + "fade_in": 1.0, + "fade_out": 1.0, + "track": "default", + } + user = {"volume": 0.7, "fade_in": 2.0} + result = merge_bgm_config(template, user) + assert result["volume"] == 0.7 + assert result["fade_in"] == 2.0 + assert result["fade_out"] == 1.0 + assert result["track"] == "default" + assert result["enabled"] is True # 用户没传,保留模板 + + def test_user_none(self): + """user_bgm 为 None 的情况.""" + template = {"enabled": True, "volume": 0.5} + result = merge_bgm_config(template, None) # type: ignore + assert result == template + + def test_template_none(self): + """template_bgm 为 None 的情况.""" + user = {"enabled": False, "volume": 0.8} + result = merge_bgm_config(None, user) # type: ignore + assert result == user + + def test_preserves_extra_fields(self): + """保留模板中的额外字段(用户没覆盖的).""" + template = {"enabled": True, "volume": 0.5, "custom_field": "value"} + user = {"volume": 0.6} + result = merge_bgm_config(template, user) + assert result["custom_field"] == "value" + + def test_user_adds_new_fields(self): + """用户可以添加模板中没有的新字段.""" + template = {"enabled": True, "volume": 0.5} + user = {"loop": True, "start_time": 5.0} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + assert result["volume"] == 0.5 + assert result["loop"] is True + assert result["start_time"] == 5.0 + + def test_nested_dict_behavior(self): + """嵌套字典的合并行为(简单替换,不深度合并).""" + template = {"enabled": True, "effects": {"fade": True, "reverb": False}} + user = {"effects": {"reverb": True}} + result = merge_bgm_config(template, user) + # 简单合并,用户的 effects 整体覆盖模板的 + assert result["effects"] == {"reverb": True} + + def test_enabled_in_template_only(self): + """只有模板有 enabled,用户没有.""" + template = {"enabled": False, "volume": 0.5} + user = {"volume": 0.7} + result = merge_bgm_config(template, user) + # 用户没传 enabled,保留模板的 False + assert result["enabled"] is False + + def test_both_have_enabled_false(self): + """两边都有 enabled 且都是 False.""" + template = {"enabled": False, "volume": 0.5} + user = {"enabled": False} + result = merge_bgm_config(template, user) + assert result["enabled"] is False + + def test_return_type_is_dict(self): + """返回类型是 dict.""" + result = merge_bgm_config({"a": 1}, {"b": 2}) + assert isinstance(result, dict) + + def test_does_not_mutate_template(self): + """不修改原始模板字典.""" + template = {"enabled": True, "volume": 0.5} + template_copy = template.copy() + user = {"volume": 0.9} + merge_bgm_config(template, user) + assert template == template_copy + + def test_does_not_mutate_user(self): + """不修改原始用户字典.""" + template = {"enabled": True, "volume": 0.5} + user = {"volume": 0.9} + user_copy = user.copy() + merge_bgm_config(template, user) + assert user == user_copy diff --git a/tests/unit/test_exceptions_domain.py b/tests/unit/test_exceptions_domain.py new file mode 100755 index 000000000..3067fd1c3 --- /dev/null +++ b/tests/unit/test_exceptions_domain.py @@ -0,0 +1,166 @@ +"""领域层异常类单元测试.""" + +import pytest + +from packages.domain.exceptions import ( + DomainError, + NotFoundError, + ValidationError, + QuotaExceededError, +) + + +class TestDomainError: + """领域异常基类测试.""" + + def test_is_exception(self): + """DomainError 继承自 Exception.""" + err = DomainError("test") + assert isinstance(err, Exception) + + def test_message(self): + """可以设置错误消息.""" + err = DomainError("something wrong") + assert str(err) == "something wrong" + + def test_empty_message(self): + """支持空消息.""" + err = DomainError() + assert str(err) == "" + + def test_can_be_raised(self): + """可以被 raise 和 catch.""" + with pytest.raises(DomainError) as exc_info: + raise DomainError("oops") + assert str(exc_info.value) == "oops" + + +class TestNotFoundError: + """资源不存在异常测试.""" + + def test_inherits_domain_error(self): + """NotFoundError 继承自 DomainError.""" + err = NotFoundError("user not found") + assert isinstance(err, DomainError) + assert isinstance(err, Exception) + + def test_message(self): + """错误消息正确.""" + err = NotFoundError("project 123 not found") + assert str(err) == "project 123 not found" + assert "123" in str(err) + + def test_can_catch_as_domain_error(self): + """可以用 DomainError 捕获.""" + with pytest.raises(DomainError): + raise NotFoundError("not found") + + +class TestValidationError: + """校验失败异常测试.""" + + def test_inherits_domain_error(self): + """ValidationError 继承自 DomainError.""" + err = ValidationError("invalid input") + assert isinstance(err, DomainError) + + def test_message(self): + """错误消息正确.""" + msg = "name must not be empty" + err = ValidationError(msg) + assert str(err) == msg + + def test_not_not_found(self): + """ValidationError 不是 NotFoundError.""" + err = ValidationError("bad") + assert not isinstance(err, NotFoundError) + + +class TestQuotaExceededError: + """配额超限异常测试.""" + + def test_inherits_domain_error(self): + """QuotaExceededError 继承自 DomainError.""" + err = QuotaExceededError("storage", 100.0, 150.0) + assert isinstance(err, DomainError) + + def test_dimension_attribute(self): + """保存 dimension 属性.""" + err = QuotaExceededError("storage", 100.0, 150.0) + assert err.dimension == "storage" + + def test_limit_attribute(self): + """保存 limit 属性.""" + err = QuotaExceededError("storage", 100.0, 150.0) + assert err.limit == 100.0 + + def test_used_attribute(self): + """保存 used 属性.""" + err = QuotaExceededError("storage", 100.0, 150.0) + assert err.used == 150.0 + + def test_message_format(self): + """错误消息格式正确.""" + err = QuotaExceededError("credits", 50.0, 75.0) + msg = str(err) + assert "credits" in msg + assert "50" in msg + assert "75" in msg + assert "Quota exceeded" in msg + + def test_zero_limit(self): + """limit 为 0 的情况.""" + err = QuotaExceededError("test", 0.0, 1.0) + assert err.limit == 0.0 + assert err.used == 1.0 + assert "0" in str(err) + + def test_equal_limit_and_used(self): + """used 刚好等于 limit(边界情况).""" + err = QuotaExceededError("test", 100.0, 100.0) + assert err.used == 100.0 + assert err.limit == 100.0 + + def test_integer_values(self): + """整数值也能正常工作.""" + err = QuotaExceededError("count", 10, 20) + assert err.dimension == "count" + assert err.limit == 10 + assert err.used == 20 + + def test_can_catch_as_domain_error(self): + """可以用 DomainError 捕获.""" + with pytest.raises(DomainError): + raise QuotaExceededError("x", 1.0, 2.0) + + +class TestExceptionHierarchy: + """异常继承关系验证.""" + + def test_all_are_domain_errors(self): + """所有领域异常都是 DomainError.""" + errors = [ + NotFoundError("test"), + ValidationError("test"), + QuotaExceededError("test", 1, 2), + ] + for err in errors: + assert isinstance(err, DomainError) + + def test_all_are_exceptions(self): + """所有领域异常都是 Exception.""" + errors = [ + DomainError("test"), + NotFoundError("test"), + ValidationError("test"), + QuotaExceededError("test", 1, 2), + ] + for err in errors: + assert isinstance(err, Exception) + + def test_not_found_is_not_validation(self): + """不同异常类型不能互相混淆.""" + assert not isinstance(NotFoundError("x"), ValidationError) + assert not isinstance(ValidationError("x"), NotFoundError) + assert not isinstance(QuotaExceededError("x", 1, 2), NotFoundError) + assert not isinstance(QuotaExceededError("x", 1, 2), ValidationError) diff --git a/tests/unit/test_subtitle_domain.py b/tests/unit/test_subtitle_domain.py index ce19d61bf..c477f12ae 100755 --- a/tests/unit/test_subtitle_domain.py +++ b/tests/unit/test_subtitle_domain.py @@ -327,3 +327,225 @@ class TestMergeSegments: 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 == "一二三四五六七八九" + + +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 + + +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 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 -- 2.54.0