Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 0801a98cc1 test(P3-1): 第85波 exceptions+bgm_utils+subtitle深度补充(+59)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 13s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
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 / Validate - Code Quality (pull_request) Failing after 2m9s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m27s
CI/CD Pipeline / Frontend Unit Tests (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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m33s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 40s
- 新增 test_exceptions_domain.py:22个测试,覆盖4个异常类的继承关系、属性、消息格式
- 新增 test_bgm_utils_domain.py:19个测试,覆盖merge_bgm_config各种场景(空值、覆盖、enabled特殊处理、不变性)
- test_subtitle_domain.py 补充18个深度测试:合并边界、拆分边界、标点深度、属性计算
2026-07-25 23:54:28 +08:00
3 changed files with 548 additions and 0 deletions
+160
View File
@@ -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
+166
View File
@@ -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)
+222
View File
@@ -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