Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 15ec868f2f test(unit): 第74波 - classification + bgm_utils + subtitle 纯逻辑 (+106)
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m22s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m54s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m8s
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 / PR Build Worker Image (pull_request) Successful in 1m1s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m40s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m12s
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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 9s
2026-07-25 20:53:55 +08:00
3 changed files with 535 additions and 347 deletions
+148 -89
View File
@@ -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
+151 -80
View File
@@ -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
+236 -178
View File
@@ -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 == "世界"