From 5210d1f396a702de973fab77775da26d06710be5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 24 Jul 2026 18:10:30 +0800 Subject: [PATCH] =?UTF-8?q?test:=20P3-1=20=E7=AC=AC49=E6=B3=A2=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88generated=5Fvideo/duplicati?= =?UTF-8?q?on/tts=5Fconfig/classification=EF=BC=8C+88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增4个领域模块单测: - test_generated_video.py: GeneratedVideo生成视频实体(14个) - test_duplication.py: 查重记录领域模型(36个)- DuplicateSegment校验 + DuplicationRecord状态流转/重试 - test_tts_config.py: TTS配音配置解析与边界钳制(31个) - test_classification.py: 分类任务枚举与实体(7个) 合计 +88 个测试用例,全部通过。 --- tests/unit/test_classification.py | 115 +++++++++++ tests/unit/test_duplication.py | 298 +++++++++++++++++++++++++++++ tests/unit/test_generated_video.py | 139 ++++++++++++++ tests/unit/test_tts_config.py | 151 +++++++++++++++ 4 files changed, 703 insertions(+) create mode 100755 tests/unit/test_classification.py create mode 100755 tests/unit/test_duplication.py create mode 100755 tests/unit/test_generated_video.py create mode 100755 tests/unit/test_tts_config.py diff --git a/tests/unit/test_classification.py b/tests/unit/test_classification.py new file mode 100755 index 000000000..e5cb0f18e --- /dev/null +++ b/tests/unit/test_classification.py @@ -0,0 +1,115 @@ +"""classification 分类领域模型单测.""" + +import pytest + +from packages.domain.classification import ( + AssetClassification, + AssetLibraryKind, + ClassificationJob, + ClassificationJobStatus, + ClassificationStatus, + IngestJobStatus, +) + + +class TestAssetLibraryKind: + def test_values(self): + assert AssetLibraryKind.VIDEO.value == "video" + assert AssetLibraryKind.VOICE.value == "voice" + assert AssetLibraryKind.IMAGE.value == "image" + + def test_is_str(self): + assert isinstance(AssetLibraryKind.VIDEO, str) + + +class TestIngestJobStatus: + def test_values(self): + assert IngestJobStatus.PENDING.value == "pending" + assert IngestJobStatus.PROCESSING.value == "processing" + assert IngestJobStatus.COMPLETED.value == "completed" + assert IngestJobStatus.FAILED.value == "failed" + + +class TestClassificationJobStatusMissing: + """ClassificationJobStatus._missing_ 兼容性测试.""" + + def test_normal_values(self): + assert ClassificationJobStatus("pending") == ClassificationJobStatus.PENDING + assert ClassificationJobStatus("processing") == ClassificationJobStatus.PROCESSING + assert ClassificationJobStatus("completed") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("failed") == ClassificationJobStatus.FAILED + + @pytest.mark.parametrize("value", ["done", "success", "finished", "complete"]) + def test_completed_aliases(self, value): + assert ClassificationJobStatus(value) == ClassificationJobStatus.COMPLETED + + @pytest.mark.parametrize("value", ["fail", "error", "err"]) + def test_failed_aliases(self, value): + assert ClassificationJobStatus(value) == ClassificationJobStatus.FAILED + + @pytest.mark.parametrize("value", ["process", "processing", "running", "run"]) + def test_processing_aliases(self, value): + assert ClassificationJobStatus(value) == ClassificationJobStatus.PROCESSING + + @pytest.mark.parametrize("value", ["unknown", "foobar", ""]) + def test_unknown_fallback_to_pending(self, value): + assert ClassificationJobStatus(value) == ClassificationJobStatus.PENDING + + def test_none_fallback_to_pending(self): + assert ClassificationJobStatus(None) == ClassificationJobStatus.PENDING # type: ignore[arg-type] + + def test_case_insensitive_with_strip(self): + assert ClassificationJobStatus(" DONE ") == ClassificationJobStatus.COMPLETED + assert ClassificationJobStatus("ERROR") == ClassificationJobStatus.FAILED + + def test_backward_compat_alias(self): + """ClassificationStatus 是 ClassificationJobStatus 的别名.""" + assert ClassificationStatus is ClassificationJobStatus + assert ClassificationStatus("done") == ClassificationJobStatus.COMPLETED + + +class TestAssetClassification: + def test_values(self): + assert AssetClassification.SCENIC.value == "scenic" + assert AssetClassification.PRODUCT.value == "product" + assert AssetClassification.PERSON.value == "person" + assert AssetClassification.ANIMAL.value == "animal" + assert AssetClassification.FOOD.value == "food" + assert AssetClassification.TECH.value == "tech" + assert AssetClassification.SPORT.value == "sport" + assert AssetClassification.MUSIC.value == "music" + assert AssetClassification.OTHER.value == "other" + + +class TestClassificationJobCreate: + def test_create_normal(self): + job = ClassificationJob.create(project_id="proj1", asset_id="asset1") + assert job.id + assert job.project_id == "proj1" + assert job.asset_id == "asset1" + assert job.status == ClassificationJobStatus.PENDING + assert job.classification == "" + assert job.confidence == 0.0 + assert job.error_message == "" + + def test_create_strips_whitespace(self): + job = ClassificationJob.create(project_id=" proj1 ", asset_id=" asset1 ") + assert job.project_id == "proj1" + assert job.asset_id == "asset1" + + def test_create_empty_project_id_raises(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + ClassificationJob.create(project_id="", asset_id="a1") + + def test_create_empty_asset_id_raises(self): + with pytest.raises(ValueError, match="asset_id 不能为空"): + ClassificationJob.create(project_id="p1", asset_id="") + + def test_create_whitespace_project_id_raises(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + ClassificationJob.create(project_id=" ", asset_id="a1") + + def test_create_unique_ids(self): + job1 = ClassificationJob.create(project_id="p1", asset_id="a1") + job2 = ClassificationJob.create(project_id="p1", asset_id="a2") + assert job1.id != job2.id diff --git a/tests/unit/test_duplication.py b/tests/unit/test_duplication.py new file mode 100755 index 000000000..a24c7e5b0 --- /dev/null +++ b/tests/unit/test_duplication.py @@ -0,0 +1,298 @@ +"""Duplication 查重记录领域实体单测.""" + +import pytest + +from packages.domain.duplication import DuplicateSegment, DuplicationRecord + + +class TestDuplicateSegmentCreate: + def test_create_normal(self): + seg = DuplicateSegment.create( + source_start=10.0, + source_end=20.0, + matched_video_id="vid123", + matched_video_name="测试视频", + matched_start=5.0, + matched_end=15.0, + similarity=85.5, + ) + assert seg.id + assert seg.source_start == 10.0 + assert seg.source_end == 20.0 + assert seg.matched_video_id == "vid123" + assert seg.matched_video_name == "测试视频" + assert seg.matched_start == 5.0 + assert seg.matched_end == 15.0 + assert seg.similarity == 85.5 + + def test_create_negative_source_start_raises(self): + with pytest.raises(ValueError, match="invalid source segment range"): + DuplicateSegment.create( + source_start=-1.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=0.0, + matched_end=10.0, + similarity=50.0, + ) + + def test_create_zero_duration_source_raises(self): + with pytest.raises(ValueError, match="invalid source segment range"): + DuplicateSegment.create( + source_start=10.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=0.0, + matched_end=10.0, + similarity=50.0, + ) + + def test_create_reversed_source_range_raises(self): + with pytest.raises(ValueError, match="invalid source segment range"): + DuplicateSegment.create( + source_start=20.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=0.0, + matched_end=10.0, + similarity=50.0, + ) + + def test_create_negative_matched_start_raises(self): + with pytest.raises(ValueError, match="invalid matched segment range"): + DuplicateSegment.create( + source_start=0.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=-1.0, + matched_end=10.0, + similarity=50.0, + ) + + def test_create_zero_duration_matched_raises(self): + with pytest.raises(ValueError, match="invalid matched segment range"): + DuplicateSegment.create( + source_start=0.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=5.0, + matched_end=5.0, + similarity=50.0, + ) + + def test_create_similarity_negative_raises(self): + with pytest.raises(ValueError, match="similarity must be between 0 and 100"): + DuplicateSegment.create( + source_start=0.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=0.0, + matched_end=10.0, + similarity=-1.0, + ) + + def test_create_similarity_over_100_raises(self): + with pytest.raises(ValueError, match="similarity must be between 0 and 100"): + DuplicateSegment.create( + source_start=0.0, + source_end=10.0, + matched_video_id="v1", + matched_video_name="v", + matched_start=0.0, + matched_end=10.0, + similarity=101.0, + ) + + def test_create_similarity_boundary_values(self): + # 0 和 100 都是合法的 + seg0 = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=0.0, + ) + assert seg0.similarity == 0.0 + + seg100 = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=100.0, + ) + assert seg100.similarity == 100.0 + + def test_create_unique_ids(self): + seg1 = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=50.0, + ) + seg2 = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=50.0, + ) + assert seg1.id != seg2.id + + +class TestDuplicationRecordCreate: + def test_create_normal(self): + record = DuplicationRecord.create( + user_id="user1", + filename="test.mp4", + file_size=1024000, + storage_key="videos/test.mp4", + duration_seconds=30.5, + ) + assert record.id + assert record.user_id == "user1" + assert record.filename == "test.mp4" + assert record.file_size == 1024000 + assert record.storage_key == "videos/test.mp4" + assert record.duration_seconds == 30.5 + assert record.status == "pending" + assert record.duplicate_rate is None + assert record.duplicate_count == 0 + assert record.segments == [] + assert record.error_message == "" + + def test_create_strips_whitespace(self): + record = DuplicationRecord.create( + user_id=" user1 ", + filename=" test.mp4 ", + file_size=100, + storage_key="key1", + ) + assert record.user_id == "user1" + assert record.filename == "test.mp4" + + def test_create_empty_user_id_raises(self): + with pytest.raises(ValueError, match="user_id cannot be empty"): + DuplicationRecord.create( + user_id="", + filename="test.mp4", + file_size=100, + storage_key="key1", + ) + + def test_create_empty_filename_raises(self): + with pytest.raises(ValueError, match="filename cannot be empty"): + DuplicationRecord.create( + user_id="u1", + filename="", + file_size=100, + storage_key="key1", + ) + + def test_create_zero_file_size_raises(self): + with pytest.raises(ValueError, match="file_size must be positive"): + DuplicationRecord.create( + user_id="u1", + filename="test.mp4", + file_size=0, + storage_key="key1", + ) + + def test_create_negative_file_size_raises(self): + with pytest.raises(ValueError, match="file_size must be positive"): + DuplicationRecord.create( + user_id="u1", + filename="test.mp4", + file_size=-100, + storage_key="key1", + ) + + +class TestDuplicationRecordStatus: + def test_mark_processing(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + record.mark_processing() + assert record.status == "processing" + + def test_mark_completed(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + seg = DuplicateSegment.create( + source_start=0.0, source_end=5.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=5.0, + similarity=90.0, + ) + record.mark_completed(duplicate_rate=25.5, duplicate_count=3, segments=[seg]) + assert record.status == "completed" + assert record.duplicate_rate == 25.5 + assert record.duplicate_count == 3 + assert len(record.segments) == 1 + + def test_mark_completed_invalid_rate_raises(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"): + record.mark_completed(duplicate_rate=-1, duplicate_count=0, segments=[]) + with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"): + record.mark_completed(duplicate_rate=101, duplicate_count=0, segments=[]) + + def test_mark_failed(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + record.mark_failed("网络超时") + assert record.status == "failed" + assert record.error_message == "网络超时" + + def test_can_retry_only_failed(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + assert record.can_retry() is False # pending + + record.mark_processing() + assert record.can_retry() is False # processing + + record.mark_failed("error") + assert record.can_retry() is True # failed + + seg = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=50.0, + ) + record2 = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + record2.mark_completed(10.0, 1, [seg]) + assert record2.can_retry() is False # completed + + def test_reset_for_retry(self): + record = DuplicationRecord.create( + user_id="u1", filename="t.mp4", file_size=100, storage_key="k1" + ) + seg = DuplicateSegment.create( + source_start=0.0, source_end=1.0, + matched_video_id="v1", matched_video_name="v", + matched_start=0.0, matched_end=1.0, + similarity=50.0, + ) + record.mark_completed(50.0, 2, [seg]) + record.video_fingerprint = {"hash": "abc"} + + record.reset_for_retry() + assert record.status == "pending" + assert record.duplicate_rate is None + assert record.duplicate_count == 0 + assert record.error_message == "" + assert record.segments == [] + assert record.video_fingerprint is None diff --git a/tests/unit/test_generated_video.py b/tests/unit/test_generated_video.py new file mode 100755 index 000000000..cd0ad68fe --- /dev/null +++ b/tests/unit/test_generated_video.py @@ -0,0 +1,139 @@ +"""GeneratedVideo 生成视频领域实体单测.""" + +import pytest + +from packages.domain.generated_video import GeneratedVideo + + +class TestGeneratedVideoCreate: + def test_create_normal(self): + video = GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="我的视频", + file_url="https://example.com/video.mp4", + file_size=1024000, + duration=30.5, + width=1920, + height=1080, + fps=30.0, + ) + assert video.id + assert video.project_id == "proj1" + assert video.generation_task_id == "task1" + assert video.name == "我的视频" + assert video.file_url == "https://example.com/video.mp4" + assert video.file_size == 1024000 + assert video.duration == 30.5 + assert video.width == 1920 + assert video.height == 1080 + assert video.fps == 30.0 + assert video.status == "completed" + assert video.review_status == "pending_review" + assert video.is_duplicate is False + assert video.generation_params == {} + + def test_create_strips_whitespace(self): + video = GeneratedVideo.create( + project_id=" proj1 ", + generation_task_id=" task1 ", + name=" 我的视频 ", + file_url=" https://example.com/video.mp4 ", + user_id=" user1 ", + ) + assert video.project_id == "proj1" + assert video.generation_task_id == "task1" + assert video.name == "我的视频" + assert video.file_url == "https://example.com/video.mp4" + assert video.user_id == "user1" + + def test_create_empty_project_id_raises(self): + with pytest.raises(ValueError, match="project_id cannot be empty"): + GeneratedVideo.create( + project_id="", + generation_task_id="task1", + name="视频", + file_url="https://example.com/v.mp4", + ) + + def test_create_whitespace_project_id_raises(self): + with pytest.raises(ValueError, match="project_id cannot be empty"): + GeneratedVideo.create( + project_id=" ", + generation_task_id="task1", + name="视频", + file_url="https://example.com/v.mp4", + ) + + def test_create_empty_generation_task_id_raises(self): + with pytest.raises(ValueError, match="generation_task_id cannot be empty"): + GeneratedVideo.create( + project_id="proj1", + generation_task_id="", + name="视频", + file_url="https://example.com/v.mp4", + ) + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="name cannot be empty"): + GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="", + file_url="https://example.com/v.mp4", + ) + + def test_create_empty_file_url_raises(self): + with pytest.raises(ValueError, match="file_url cannot be empty"): + GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="视频", + file_url="", + ) + + def test_create_default_values(self): + video = GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="视频", + file_url="https://example.com/v.mp4", + ) + assert video.file_size == 0 + assert video.duration == 0.0 + assert video.width == 0 + assert video.height == 0 + assert video.fps == 0.0 + assert video.thumbnail_url is None + assert video.user_id == "" + assert video.generation_params == {} + + def test_create_with_generation_params(self): + params = {"mode": "pip", "resolution": "1080p"} + video = GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="视频", + file_url="https://example.com/v.mp4", + generation_params=params, + ) + assert video.generation_params == params + + def test_create_none_generation_params(self): + video = GeneratedVideo.create( + project_id="proj1", + generation_task_id="task1", + name="视频", + file_url="https://example.com/v.mp4", + generation_params=None, + ) + assert video.generation_params == {} + + def test_create_unique_ids(self): + v1 = GeneratedVideo.create( + project_id="proj1", generation_task_id="t1", name="v1", file_url="https://a.com/1.mp4" + ) + v2 = GeneratedVideo.create( + project_id="proj1", generation_task_id="t2", name="v2", file_url="https://a.com/2.mp4" + ) + assert v1.id != v2.id diff --git a/tests/unit/test_tts_config.py b/tests/unit/test_tts_config.py new file mode 100755 index 000000000..962531ac8 --- /dev/null +++ b/tests/unit/test_tts_config.py @@ -0,0 +1,151 @@ +"""TtsConfig 配音配置模型单测.""" + +import pytest + +from packages.domain.tts_config import TtsConfig + + +class TestTtsConfigDefaults: + def test_default_values(self): + config = TtsConfig() + assert config.enabled is False + assert config.voice_id == "" + assert config.speed == 1.0 + assert config.pitch == 0.0 + assert config.volume == 0.8 + assert config.text == "" + assert config.align_mode == "full" + assert config.overlap_mode == "replace" + + +class TestTtsConfigParse: + def test_parse_none(self): + config = TtsConfig.parse(None) + assert config.enabled is False + assert isinstance(config, TtsConfig) + + def test_parse_empty_dict(self): + config = TtsConfig.parse({}) + assert config.enabled is False + + def test_parse_not_dict(self): + config = TtsConfig.parse("not a dict") + assert config.enabled is False + + def test_parse_enabled_false_returns_disabled(self): + # 即使传了其他参数,enabled=False 就直接返回禁用 + config = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5}) + assert config.enabled is False + assert config.voice_id == "" + assert config.speed == 1.0 + + def test_parse_enabled_true_with_all_fields(self): + config = TtsConfig.parse({ + "enabled": True, + "voice_id": "female_warm", + "speed": 1.5, + "pitch": 2.0, + "volume": 0.9, + "text": "你好世界", + "align_mode": "subtitle", + "overlap_mode": "mix", + }) + assert config.enabled is True + assert config.voice_id == "female_warm" + assert config.speed == 1.5 + assert config.pitch == 2.0 + assert config.volume == 0.9 + assert config.text == "你好世界" + assert config.align_mode == "subtitle" + assert config.overlap_mode == "mix" + + def test_parse_enabled_not_bool(self): + config = TtsConfig.parse({"enabled": "true", "voice_id": "v1"}) + assert config.enabled is False # 非 bool 值视为 False + + def test_parse_voice_id_not_string(self): + config = TtsConfig.parse({"enabled": True, "voice_id": 123}) + assert config.voice_id == "" + + def test_parse_speed_not_number(self): + config = TtsConfig.parse({"enabled": True, "speed": "fast"}) + assert config.speed == 1.0 + + def test_parse_pitch_not_number(self): + config = TtsConfig.parse({"enabled": True, "pitch": "high"}) + assert config.pitch == 0.0 + + def test_parse_volume_not_number(self): + config = TtsConfig.parse({"enabled": True, "volume": "loud"}) + assert config.volume == 0.8 + + def test_parse_text_not_string(self): + config = TtsConfig.parse({"enabled": True, "text": 12345}) + assert config.text == "" + + def test_parse_invalid_align_mode(self): + config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"}) + assert config.align_mode == "full" + + def test_parse_invalid_overlap_mode(self): + config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"}) + assert config.overlap_mode == "replace" + + +class TestTtsConfigClamp: + def test_speed_below_min(self): + config = TtsConfig.parse({"enabled": True, "speed": 0.1}) + assert config.speed == 0.5 + + def test_speed_above_max(self): + config = TtsConfig.parse({"enabled": True, "speed": 3.0}) + assert config.speed == 2.0 + + def test_speed_within_range(self): + config = TtsConfig.parse({"enabled": True, "speed": 1.2}) + assert config.speed == 1.2 + + def test_speed_boundary_values(self): + config_low = TtsConfig.parse({"enabled": True, "speed": 0.5}) + assert config_low.speed == 0.5 + config_high = TtsConfig.parse({"enabled": True, "speed": 2.0}) + assert config_high.speed == 2.0 + + def test_pitch_below_min(self): + config = TtsConfig.parse({"enabled": True, "pitch": -20}) + assert config.pitch == -12 + + def test_pitch_above_max(self): + config = TtsConfig.parse({"enabled": True, "pitch": 20}) + assert config.pitch == 12 + + def test_pitch_within_range(self): + config = TtsConfig.parse({"enabled": True, "pitch": -3.5}) + assert config.pitch == -3.5 + + def test_volume_below_min(self): + config = TtsConfig.parse({"enabled": True, "volume": -0.5}) + assert config.volume == 0.0 + + def test_volume_above_max(self): + config = TtsConfig.parse({"enabled": True, "volume": 2.0}) + assert config.volume == 1.0 + + def test_volume_within_range(self): + config = TtsConfig.parse({"enabled": True, "volume": 0.5}) + assert config.volume == 0.5 + + def test_int_speed_converted_to_float(self): + config = TtsConfig.parse({"enabled": True, "speed": 1}) + assert isinstance(config.speed, float) + assert config.speed == 1.0 + + def test_int_pitch_converted_to_float(self): + config = TtsConfig.parse({"enabled": True, "pitch": 2}) + assert isinstance(config.pitch, float) + assert config.pitch == 2.0 + + def test_int_volume_converted_to_float(self): + config = TtsConfig.parse({"enabled": True, "volume": 1}) + assert isinstance(config.volume, float) + assert config.volume == 1.0 -- 2.54.0