diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..cf0d7e0 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "smartaudit-backend" +version = "0.1.0" +description = "SmartAudit - AI 营销内容合规审核平台" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", + "-ra", + "--cov=app", + "--cov-report=xml", + "--cov-report=html", + "--cov-report=term-missing", + "--cov-fail-under=75", +] +asyncio_mode = "auto" +markers = [ + "slow: 标记慢速测试", + "integration: 集成测试", + "ai: AI 模型测试", + "unit: 单元测试", +] + +[tool.coverage.run] +branch = true +source = ["app"] +omit = [ + "*/migrations/*", + "*/tests/*", + "*/__init__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/backend/tests/ai/test_asr_service.py b/backend/tests/ai/test_asr_service.py new file mode 100644 index 0000000..b6c8d3b --- /dev/null +++ b/backend/tests/ai/test_asr_service.py @@ -0,0 +1,327 @@ +""" +ASR 服务单元测试 + +TDD 测试用例 - 基于 DevelopmentPlan.md 的验收标准 + +验收标准: +- 字错率 (WER) ≤ 10% +- 时间戳精度 ≤ 100ms +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.services.ai.asr import ASRService, ASRResult, ASRSegment + + +class TestASRService: + """ASR 服务测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_asr_service_initialization(self) -> None: + """测试 ASR 服务初始化""" + # TODO: 实现 ASR 服务 + # service = ASRService() + # assert service.is_ready() + # assert service.model_name is not None + pytest.skip("待实现:ASR 服务初始化") + + @pytest.mark.ai + @pytest.mark.unit + def test_asr_transcribe_audio_file(self) -> None: + """测试音频文件转写""" + # TODO: 实现音频转写 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/sample.wav") + # + # assert result.status == "success" + # assert result.text is not None + # assert len(result.text) > 0 + pytest.skip("待实现:音频转写") + + @pytest.mark.ai + @pytest.mark.unit + def test_asr_output_format(self) -> None: + """测试 ASR 输出格式""" + # TODO: 实现 ASR 服务 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/sample.wav") + # + # # 验证输出结构 + # assert hasattr(result, "text") + # assert hasattr(result, "segments") + # assert hasattr(result, "language") + # assert hasattr(result, "duration_ms") + # + # # 验证 segment 结构 + # for segment in result.segments: + # assert hasattr(segment, "text") + # assert hasattr(segment, "start_ms") + # assert hasattr(segment, "end_ms") + # assert hasattr(segment, "confidence") + # assert segment.end_ms >= segment.start_ms + pytest.skip("待实现:ASR 输出格式") + + +class TestASRAccuracy: + """ASR 准确率测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_word_error_rate_threshold(self) -> None: + """ + 测试字错率阈值 + + 验收标准:WER ≤ 10% + """ + # TODO: 使用标注测试集验证 + # service = ASRService() + # test_cases = load_asr_labeled_dataset() + # + # total_errors = 0 + # total_words = 0 + # + # for case in test_cases: + # result = service.transcribe(case["audio_path"]) + # wer = calculate_word_error_rate( + # result.text, + # case["ground_truth"] + # ) + # total_errors += wer * len(case["ground_truth"]) + # total_words += len(case["ground_truth"]) + # + # overall_wer = total_errors / total_words + # assert overall_wer <= 0.10, f"WER {overall_wer:.2%} 超过阈值 10%" + pytest.skip("待实现:WER 测试") + + @pytest.mark.ai + @pytest.mark.unit + @pytest.mark.parametrize("audio_type,expected_wer_threshold", [ + ("clean_speech", 0.05), # 清晰语音 WER < 5% + ("background_music", 0.10), # 背景音乐 WER < 10% + ("multiple_speakers", 0.15), # 多人对话 WER < 15% + ("noisy_environment", 0.20), # 嘈杂环境 WER < 20% + ]) + def test_wer_by_audio_type( + self, + audio_type: str, + expected_wer_threshold: float, + ) -> None: + """测试不同音频类型的 WER""" + # TODO: 实现分类型 WER 测试 + # service = ASRService() + # test_cases = load_asr_test_set_by_type(audio_type) + # + # wer = calculate_average_wer(service, test_cases) + # assert wer <= expected_wer_threshold + pytest.skip(f"待实现:{audio_type} WER 测试") + + +class TestASRTimestamp: + """ASR 时间戳测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_timestamp_monotonic_increase(self) -> None: + """测试时间戳单调递增""" + # TODO: 实现时间戳验证 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/sample.wav") + # + # prev_end = 0 + # for segment in result.segments: + # assert segment.start_ms >= prev_end, \ + # f"时间戳不是单调递增: {segment.start_ms} < {prev_end}" + # prev_end = segment.end_ms + pytest.skip("待实现:时间戳单调递增") + + @pytest.mark.ai + @pytest.mark.unit + def test_timestamp_precision(self) -> None: + """ + 测试时间戳精度 + + 验收标准:精度 ≤ 100ms + """ + # TODO: 使用标注测试集验证 + # service = ASRService() + # test_cases = load_timestamp_labeled_dataset() + # + # total_error = 0 + # total_segments = 0 + # + # for case in test_cases: + # result = service.transcribe(case["audio_path"]) + # for i, segment in enumerate(result.segments): + # if i < len(case["ground_truth_timestamps"]): + # gt = case["ground_truth_timestamps"][i] + # start_error = abs(segment.start_ms - gt["start_ms"]) + # end_error = abs(segment.end_ms - gt["end_ms"]) + # total_error += (start_error + end_error) / 2 + # total_segments += 1 + # + # avg_error = total_error / total_segments if total_segments > 0 else 0 + # assert avg_error <= 100, f"平均时间戳误差 {avg_error:.0f}ms 超过阈值 100ms" + pytest.skip("待实现:时间戳精度测试") + + @pytest.mark.ai + @pytest.mark.unit + def test_timestamp_within_audio_duration(self) -> None: + """测试时间戳在音频时长范围内""" + # TODO: 实现边界验证 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/sample.wav") + # + # for segment in result.segments: + # assert segment.start_ms >= 0 + # assert segment.end_ms <= result.duration_ms + pytest.skip("待实现:时间戳边界验证") + + +class TestASRLanguage: + """ASR 语言处理测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_chinese_mandarin_recognition(self) -> None: + """测试普通话识别""" + # TODO: 实现普通话测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/mandarin.wav") + # + # assert result.language == "zh-CN" + # assert "你好" in result.text or len(result.text) > 0 + pytest.skip("待实现:普通话识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_mixed_language_handling(self) -> None: + """测试中英混合语音处理""" + # TODO: 实现混合语言测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/mixed_cn_en.wav") + # + # # 应能识别中英文混合内容 + # assert result.status == "success" + pytest.skip("待实现:中英混合识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_dialect_handling(self) -> None: + """测试方言处理""" + # TODO: 实现方言测试 + # service = ASRService() + # + # # 方言可能降级处理或提示 + # result = service.transcribe("tests/fixtures/audio/cantonese.wav") + # + # if result.status == "success": + # assert result.language in ["zh-CN", "zh-HK", "yue"] + # else: + # assert result.warning == "dialect_detected" + pytest.skip("待实现:方言处理") + + +class TestASRSpecialCases: + """ASR 特殊情况测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_silent_audio(self) -> None: + """测试静音音频""" + # TODO: 实现静音测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/silent.wav") + # + # assert result.status == "success" + # assert result.text == "" or result.segments == [] + pytest.skip("待实现:静音音频处理") + + @pytest.mark.ai + @pytest.mark.unit + def test_very_short_audio(self) -> None: + """测试极短音频 (< 1秒)""" + # TODO: 实现极短音频测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/short_500ms.wav") + # + # assert result.status == "success" + pytest.skip("待实现:极短音频处理") + + @pytest.mark.ai + @pytest.mark.unit + def test_long_audio(self) -> None: + """测试长音频 (> 5分钟)""" + # TODO: 实现长音频测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/long_10min.wav") + # + # assert result.status == "success" + # assert result.duration_ms >= 600000 # 10分钟 + pytest.skip("待实现:长音频处理") + + @pytest.mark.ai + @pytest.mark.unit + def test_corrupted_audio_handling(self) -> None: + """测试损坏音频处理""" + # TODO: 实现错误处理测试 + # service = ASRService() + # result = service.transcribe("tests/fixtures/audio/corrupted.wav") + # + # assert result.status == "error" + # assert "corrupted" in result.error_message.lower() or \ + # "invalid" in result.error_message.lower() + pytest.skip("待实现:损坏音频处理") + + +class TestASRPerformance: + """ASR 性能测试""" + + @pytest.mark.ai + @pytest.mark.performance + def test_transcription_speed(self) -> None: + """ + 测试转写速度 + + 验收标准:实时率 ≤ 0.5 (转写时间 / 音频时长) + """ + # TODO: 实现性能测试 + # import time + # + # service = ASRService() + # + # # 60秒测试音频 + # start_time = time.time() + # result = service.transcribe("tests/fixtures/audio/60s_sample.wav") + # processing_time = time.time() - start_time + # + # audio_duration = result.duration_ms / 1000 + # real_time_factor = processing_time / audio_duration + # + # assert real_time_factor <= 0.5, \ + # f"实时率 {real_time_factor:.2f} 超过阈值 0.5" + pytest.skip("待实现:转写速度测试") + + @pytest.mark.ai + @pytest.mark.performance + def test_concurrent_transcription(self) -> None: + """测试并发转写""" + # TODO: 实现并发测试 + # import asyncio + # + # service = ASRService() + # + # async def transcribe_one(audio_path: str): + # return await service.transcribe_async(audio_path) + # + # # 并发处理 5 个音频 + # tasks = [ + # transcribe_one(f"tests/fixtures/audio/sample_{i}.wav") + # for i in range(5) + # ] + # results = await asyncio.gather(*tasks) + # + # assert all(r.status == "success" for r in results) + pytest.skip("待实现:并发转写测试") diff --git a/backend/tests/ai/test_logo_detector.py b/backend/tests/ai/test_logo_detector.py new file mode 100644 index 0000000..801320a --- /dev/null +++ b/backend/tests/ai/test_logo_detector.py @@ -0,0 +1,370 @@ +""" +竞品 Logo 检测服务单元测试 + +TDD 测试用例 - 基于 FeatureSummary.md F-12 的验收标准 + +验收标准: +- F1 ≥ 0.85(含遮挡 30% 场景) +- 新 Logo 上传即刻生效 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.services.ai.logo_detector import LogoDetector, LogoDetection + + +class TestLogoDetector: + """Logo 检测器测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_logo_detector_initialization(self) -> None: + """测试 Logo 检测器初始化""" + # TODO: 实现 Logo 检测器 + # detector = LogoDetector() + # assert detector.is_ready() + # assert detector.logo_count > 0 # 预加载的 Logo 数量 + pytest.skip("待实现:Logo 检测器初始化") + + @pytest.mark.ai + @pytest.mark.unit + def test_detect_logo_in_image(self) -> None: + """测试图片中的 Logo 检测""" + # TODO: 实现 Logo 检测 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/with_competitor_logo.jpg") + # + # assert result.status == "success" + # assert len(result.detections) > 0 + pytest.skip("待实现:Logo 检测") + + @pytest.mark.ai + @pytest.mark.unit + def test_logo_detection_output_format(self) -> None: + """测试 Logo 检测输出格式""" + # TODO: 实现 Logo 检测 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/with_competitor_logo.jpg") + # + # # 验证输出结构 + # assert hasattr(result, "detections") + # for detection in result.detections: + # assert hasattr(detection, "logo_id") + # assert hasattr(detection, "brand_name") + # assert hasattr(detection, "confidence") + # assert hasattr(detection, "bbox") + # assert 0 <= detection.confidence <= 1 + # assert len(detection.bbox) == 4 + pytest.skip("待实现:Logo 检测输出格式") + + +class TestLogoDetectionAccuracy: + """Logo 检测准确率测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_f1_score_threshold(self) -> None: + """ + 测试 Logo 检测 F1 值 + + 验收标准:F1 ≥ 0.85 + """ + # TODO: 使用标注测试集验证 + # detector = LogoDetector() + # test_set = load_logo_labeled_dataset() # ≥ 200 张图片 + # + # predictions = [] + # ground_truths = [] + # + # for sample in test_set: + # result = detector.detect(sample["image_path"]) + # predictions.append(result.detections) + # ground_truths.append(sample["ground_truth_logos"]) + # + # f1 = calculate_f1_score(predictions, ground_truths) + # assert f1 >= 0.85, f"F1 {f1:.2f} 低于阈值 0.85" + pytest.skip("待实现:Logo F1 测试") + + @pytest.mark.ai + @pytest.mark.unit + def test_precision_recall(self) -> None: + """测试查准率和查全率""" + # TODO: 使用标注测试集验证 + # detector = LogoDetector() + # test_set = load_logo_labeled_dataset() + # + # precision, recall = calculate_precision_recall(detector, test_set) + # + # # 查准率和查全率都应该较高 + # assert precision >= 0.80 + # assert recall >= 0.80 + pytest.skip("待实现:查准率查全率测试") + + +class TestLogoOcclusion: + """Logo 遮挡检测测试""" + + @pytest.mark.ai + @pytest.mark.unit + @pytest.mark.parametrize("occlusion_percent,should_detect", [ + (0, True), # 无遮挡 + (10, True), # 10% 遮挡 + (20, True), # 20% 遮挡 + (30, True), # 30% 遮挡 - 边界 + (40, False), # 40% 遮挡 - 可能检测失败 + (50, False), # 50% 遮挡 + ]) + def test_logo_detection_with_occlusion( + self, + occlusion_percent: int, + should_detect: bool, + ) -> None: + """ + 测试遮挡场景下的 Logo 检测 + + 验收标准:30% 遮挡仍可检测 + """ + # TODO: 实现遮挡测试 + # detector = LogoDetector() + # image_path = f"tests/fixtures/images/logo_occluded_{occlusion_percent}pct.jpg" + # result = detector.detect(image_path) + # + # if should_detect: + # assert len(result.detections) > 0, \ + # f"{occlusion_percent}% 遮挡应能检测到 Logo" + # # 置信度可能较低 + # assert result.detections[0].confidence >= 0.5 + pytest.skip(f"待实现:{occlusion_percent}% 遮挡 Logo 检测") + + @pytest.mark.ai + @pytest.mark.unit + def test_partial_logo_detection(self) -> None: + """测试部分可见 Logo 检测""" + # TODO: 实现部分可见测试 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/logo_partial.jpg") + # + # # 部分可见的 Logo 应标记 partial=True + # if len(result.detections) > 0: + # assert result.detections[0].is_partial + pytest.skip("待实现:部分可见 Logo 检测") + + +class TestLogoDynamicUpdate: + """Logo 动态更新测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_add_new_logo_instant_effect(self) -> None: + """ + 测试新 Logo 上传即刻生效 + + 验收标准:新增竞品 Logo 应立即可检测 + """ + # TODO: 实现动态添加测试 + # detector = LogoDetector() + # + # # 检测前应无法识别 + # result_before = detector.detect("tests/fixtures/images/with_new_logo.jpg") + # assert not any(d.brand_name == "NewBrand" for d in result_before.detections) + # + # # 添加新 Logo + # detector.add_logo( + # logo_image="tests/fixtures/logos/new_brand_logo.png", + # brand_name="NewBrand" + # ) + # + # # 检测后应能识别 + # result_after = detector.detect("tests/fixtures/images/with_new_logo.jpg") + # assert any(d.brand_name == "NewBrand" for d in result_after.detections) + pytest.skip("待实现:Logo 动态添加") + + @pytest.mark.ai + @pytest.mark.unit + def test_remove_logo(self) -> None: + """测试移除 Logo""" + # TODO: 实现 Logo 移除 + # detector = LogoDetector() + # + # # 移除前可检测 + # result_before = detector.detect("tests/fixtures/images/with_existing_logo.jpg") + # assert any(d.brand_name == "ExistingBrand" for d in result_before.detections) + # + # # 移除 Logo + # detector.remove_logo(brand_name="ExistingBrand") + # + # # 移除后不再检测 + # result_after = detector.detect("tests/fixtures/images/with_existing_logo.jpg") + # assert not any(d.brand_name == "ExistingBrand" for d in result_after.detections) + pytest.skip("待实现:Logo 移除") + + @pytest.mark.ai + @pytest.mark.unit + def test_update_logo_variants(self) -> None: + """测试更新 Logo 变体""" + # TODO: 实现 Logo 变体更新 + # detector = LogoDetector() + # + # # 添加多个变体 + # detector.add_logo_variant( + # brand_name="Brand", + # variant_image="tests/fixtures/logos/brand_variant_dark.png", + # variant_type="dark_mode" + # ) + # + # # 应能检测新变体 + # result = detector.detect("tests/fixtures/images/with_dark_logo.jpg") + # assert len(result.detections) > 0 + pytest.skip("待实现:Logo 变体更新") + + +class TestLogoVideoProcessing: + """视频 Logo 检测测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_detect_logo_in_video_frames(self) -> None: + """测试视频帧中的 Logo 检测""" + # TODO: 实现视频帧检测 + # detector = LogoDetector() + # frame_paths = [ + # f"tests/fixtures/images/video_frame_{i}.jpg" + # for i in range(30) + # ] + # + # results = detector.batch_detect(frame_paths) + # + # assert len(results) == 30 + # # 至少部分帧应检测到 Logo + # frames_with_logo = sum(1 for r in results if len(r.detections) > 0) + # assert frames_with_logo > 0 + pytest.skip("待实现:视频帧 Logo 检测") + + @pytest.mark.ai + @pytest.mark.unit + def test_logo_tracking_across_frames(self) -> None: + """测试跨帧 Logo 跟踪""" + # TODO: 实现跨帧跟踪 + # detector = LogoDetector() + # + # # 检测连续帧 + # frame_results = [] + # for i in range(10): + # result = detector.detect(f"tests/fixtures/images/tracking_frame_{i}.jpg") + # frame_results.append(result) + # + # # 跟踪应返回相同的 track_id + # track_ids = [ + # r.detections[0].track_id + # for r in frame_results + # if len(r.detections) > 0 + # ] + # assert len(set(track_ids)) == 1 # 同一个 Logo + pytest.skip("待实现:跨帧 Logo 跟踪") + + +class TestLogoSpecialCases: + """Logo 检测特殊情况测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_no_logo_image(self) -> None: + """测试无 Logo 图片""" + # TODO: 实现无 Logo 测试 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/no_logo.jpg") + # + # assert result.status == "success" + # assert len(result.detections) == 0 + pytest.skip("待实现:无 Logo 图片处理") + + @pytest.mark.ai + @pytest.mark.unit + def test_multiple_logos_detection(self) -> None: + """测试多 Logo 检测""" + # TODO: 实现多 Logo 测试 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/multiple_logos.jpg") + # + # assert len(result.detections) >= 2 + # # 每个检测应有唯一 ID + # logo_ids = [d.logo_id for d in result.detections] + # assert len(logo_ids) == len(set(logo_ids)) + pytest.skip("待实现:多 Logo 检测") + + @pytest.mark.ai + @pytest.mark.unit + def test_similar_logo_distinction(self) -> None: + """测试相似 Logo 区分""" + # TODO: 实现相似 Logo 区分 + # detector = LogoDetector() + # result = detector.detect("tests/fixtures/images/similar_logos.jpg") + # + # # 应能区分相似但不同的 Logo + # brand_names = [d.brand_name for d in result.detections] + # assert "BrandA" in brand_names + # assert "BrandB" in brand_names # 相似但不同 + pytest.skip("待实现:相似 Logo 区分") + + @pytest.mark.ai + @pytest.mark.unit + def test_distorted_logo_detection(self) -> None: + """测试变形 Logo 检测""" + # TODO: 实现变形 Logo 测试 + # detector = LogoDetector() + # + # # 测试不同变形 + # test_cases = [ + # "logo_stretched.jpg", + # "logo_rotated.jpg", + # "logo_skewed.jpg", + # ] + # + # for image_name in test_cases: + # result = detector.detect(f"tests/fixtures/images/{image_name}") + # assert len(result.detections) > 0, f"变形 Logo {image_name} 应被检测" + pytest.skip("待实现:变形 Logo 检测") + + +class TestLogoPerformance: + """Logo 检测性能测试""" + + @pytest.mark.ai + @pytest.mark.performance + def test_detection_speed(self) -> None: + """测试检测速度""" + # TODO: 实现性能测试 + # import time + # + # detector = LogoDetector() + # + # start_time = time.time() + # result = detector.detect("tests/fixtures/images/1080p_sample.jpg") + # processing_time = time.time() - start_time + # + # # 单张图片应 < 200ms + # assert processing_time < 0.2 + pytest.skip("待实现:Logo 检测速度测试") + + @pytest.mark.ai + @pytest.mark.performance + def test_batch_detection_speed(self) -> None: + """测试批量检测速度""" + # TODO: 实现批量性能测试 + # import time + # + # detector = LogoDetector() + # frame_paths = [ + # f"tests/fixtures/images/frame_{i}.jpg" + # for i in range(30) + # ] + # + # start_time = time.time() + # results = detector.batch_detect(frame_paths) + # processing_time = time.time() - start_time + # + # # 30 帧应在 2 秒内完成 + # assert processing_time < 2.0 + pytest.skip("待实现:批量 Logo 检测速度测试") diff --git a/backend/tests/ai/test_ocr_service.py b/backend/tests/ai/test_ocr_service.py new file mode 100644 index 0000000..b2a6417 --- /dev/null +++ b/backend/tests/ai/test_ocr_service.py @@ -0,0 +1,307 @@ +""" +OCR 服务单元测试 + +TDD 测试用例 - 基于 DevelopmentPlan.md 的验收标准 + +验收标准: +- 准确率 ≥ 95%(含复杂背景) +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.services.ai.ocr import OCRService, OCRResult, OCRDetection + + +class TestOCRService: + """OCR 服务测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_service_initialization(self) -> None: + """测试 OCR 服务初始化""" + # TODO: 实现 OCR 服务 + # service = OCRService() + # assert service.is_ready() + # assert service.model_name is not None + pytest.skip("待实现:OCR 服务初始化") + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_extract_text_from_image(self) -> None: + """测试从图片提取文字""" + # TODO: 实现文字提取 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/text_sample.jpg") + # + # assert result.status == "success" + # assert len(result.detections) > 0 + pytest.skip("待实现:图片文字提取") + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_output_format(self) -> None: + """测试 OCR 输出格式""" + # TODO: 实现 OCR 服务 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/text_sample.jpg") + # + # # 验证输出结构 + # assert hasattr(result, "detections") + # assert hasattr(result, "full_text") + # + # # 验证 detection 结构 + # for detection in result.detections: + # assert hasattr(detection, "text") + # assert hasattr(detection, "confidence") + # assert hasattr(detection, "bbox") + # assert len(detection.bbox) == 4 # [x1, y1, x2, y2] + pytest.skip("待实现:OCR 输出格式") + + +class TestOCRAccuracy: + """OCR 准确率测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_accuracy_threshold(self) -> None: + """ + 测试 OCR 准确率阈值 + + 验收标准:准确率 ≥ 95% + """ + # TODO: 使用标注测试集验证 + # service = OCRService() + # test_cases = load_ocr_labeled_dataset() + # + # correct = 0 + # for case in test_cases: + # result = service.extract_text(case["image_path"]) + # if normalize_text(result.full_text) == normalize_text(case["ground_truth"]): + # correct += 1 + # + # accuracy = correct / len(test_cases) + # assert accuracy >= 0.95, f"准确率 {accuracy:.2%} 低于阈值 95%" + pytest.skip("待实现:OCR 准确率测试") + + @pytest.mark.ai + @pytest.mark.unit + @pytest.mark.parametrize("background_type,expected_accuracy", [ + ("simple_white", 0.99), # 简单白底 + ("solid_color", 0.98), # 纯色背景 + ("gradient", 0.95), # 渐变背景 + ("complex_image", 0.90), # 复杂图片背景 + ("video_frame", 0.90), # 视频帧 + ]) + def test_ocr_accuracy_by_background( + self, + background_type: str, + expected_accuracy: float, + ) -> None: + """测试不同背景类型的 OCR 准确率""" + # TODO: 实现分背景类型测试 + # service = OCRService() + # test_cases = load_ocr_test_set_by_background(background_type) + # + # accuracy = calculate_ocr_accuracy(service, test_cases) + # assert accuracy >= expected_accuracy + pytest.skip(f"待实现:{background_type} OCR 准确率测试") + + +class TestOCRChinese: + """中文 OCR 测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_simplified_chinese_recognition(self) -> None: + """测试简体中文识别""" + # TODO: 实现简体中文测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/simplified_chinese.jpg") + # + # assert "测试" in result.full_text + pytest.skip("待实现:简体中文识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_traditional_chinese_recognition(self) -> None: + """测试繁体中文识别""" + # TODO: 实现繁体中文测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/traditional_chinese.jpg") + # + # assert result.status == "success" + pytest.skip("待实现:繁体中文识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_mixed_chinese_english(self) -> None: + """测试中英混合文字识别""" + # TODO: 实现中英混合测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/mixed_cn_en.jpg") + # + # # 应能同时识别中英文 + # assert result.status == "success" + pytest.skip("待实现:中英混合识别") + + +class TestOCRVideoFrame: + """视频帧 OCR 测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_video_subtitle(self) -> None: + """测试视频字幕识别""" + # TODO: 实现字幕识别 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/video_subtitle.jpg") + # + # assert len(result.detections) > 0 + # # 字幕通常在画面下方 + # subtitle_detection = result.detections[0] + # assert subtitle_detection.bbox[1] > 0.6 # y 坐标在下半部分 + pytest.skip("待实现:视频字幕识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_watermark_detection(self) -> None: + """测试水印文字识别""" + # TODO: 实现水印识别 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/with_watermark.jpg") + # + # # 应能检测到水印文字 + # watermark_found = any( + # d.is_watermark for d in result.detections + # ) + # assert watermark_found or len(result.detections) > 0 + pytest.skip("待实现:水印文字识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_ocr_batch_video_frames(self) -> None: + """测试批量视频帧 OCR""" + # TODO: 实现批量处理 + # service = OCRService() + # frame_paths = [ + # f"tests/fixtures/images/frame_{i}.jpg" + # for i in range(10) + # ] + # + # results = service.batch_extract(frame_paths) + # + # assert len(results) == 10 + # assert all(r.status == "success" for r in results) + pytest.skip("待实现:批量视频帧 OCR") + + +class TestOCRSpecialCases: + """OCR 特殊情况测试""" + + @pytest.mark.ai + @pytest.mark.unit + def test_rotated_text(self) -> None: + """测试旋转文字识别""" + # TODO: 实现旋转文字测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/rotated_text.jpg") + # + # assert result.status == "success" + # assert len(result.detections) > 0 + pytest.skip("待实现:旋转文字识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_vertical_text(self) -> None: + """测试竖排文字识别""" + # TODO: 实现竖排文字测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/vertical_text.jpg") + # + # assert result.status == "success" + pytest.skip("待实现:竖排文字识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_artistic_font(self) -> None: + """测试艺术字体识别""" + # TODO: 实现艺术字体测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/artistic_font.jpg") + # + # # 艺术字体准确率可能较低,但应能识别 + # assert result.status == "success" + pytest.skip("待实现:艺术字体识别") + + @pytest.mark.ai + @pytest.mark.unit + def test_no_text_image(self) -> None: + """测试无文字图片""" + # TODO: 实现无文字测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/no_text.jpg") + # + # assert result.status == "success" + # assert len(result.detections) == 0 + # assert result.full_text == "" + pytest.skip("待实现:无文字图片处理") + + @pytest.mark.ai + @pytest.mark.unit + def test_blurry_text(self) -> None: + """测试模糊文字识别""" + # TODO: 实现模糊文字测试 + # service = OCRService() + # result = service.extract_text("tests/fixtures/images/blurry_text.jpg") + # + # # 模糊文字可能识别失败或置信度低 + # if result.status == "success" and len(result.detections) > 0: + # avg_confidence = sum(d.confidence for d in result.detections) / len(result.detections) + # assert avg_confidence < 0.9 # 置信度应较低 + pytest.skip("待实现:模糊文字识别") + + +class TestOCRPerformance: + """OCR 性能测试""" + + @pytest.mark.ai + @pytest.mark.performance + def test_ocr_processing_speed(self) -> None: + """测试 OCR 处理速度""" + # TODO: 实现性能测试 + # import time + # + # service = OCRService() + # + # # 标准 1080p 图片 + # start_time = time.time() + # result = service.extract_text("tests/fixtures/images/1080p_sample.jpg") + # processing_time = time.time() - start_time + # + # # 单张图片处理应 < 1 秒 + # assert processing_time < 1.0, \ + # f"处理时间 {processing_time:.2f}s 超过阈值 1s" + pytest.skip("待实现:OCR 处理速度测试") + + @pytest.mark.ai + @pytest.mark.performance + def test_ocr_batch_processing_speed(self) -> None: + """测试批量 OCR 处理速度""" + # TODO: 实现批量性能测试 + # import time + # + # service = OCRService() + # frame_paths = [ + # f"tests/fixtures/images/frame_{i}.jpg" + # for i in range(30) # 30 帧 = 1 秒视频 @ 30fps + # ] + # + # start_time = time.time() + # results = service.batch_extract(frame_paths) + # processing_time = time.time() - start_time + # + # # 30 帧应在 5 秒内处理完成 + # assert processing_time < 5.0 + pytest.skip("待实现:批量 OCR 处理速度测试") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..43cbde7 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,271 @@ +""" +SmartAudit 测试全局配置 + +本文件定义所有测试共享的 fixtures 和配置。 +遵循 TDD 原则:先写测试,后写实现。 +""" + +import pytest +from typing import Any +from pathlib import Path + +# ============================================================================ +# 路径配置 +# ============================================================================ + +@pytest.fixture +def fixtures_path() -> Path: + """测试数据目录""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def sample_brief_pdf(fixtures_path: Path) -> Path: + """示例 Brief PDF 文件路径""" + return fixtures_path / "briefs" / "sample_brief.pdf" + + +@pytest.fixture +def sample_video_path(fixtures_path: Path) -> Path: + """示例视频文件路径""" + return fixtures_path / "videos" / "sample_video.mp4" + + +# ============================================================================ +# Brief 规则 Fixtures +# ============================================================================ + +@pytest.fixture +def sample_brief_rules() -> dict[str, Any]: + """标准 Brief 规则示例""" + return { + "selling_points": [ + {"text": "24小时持妆", "priority": "high"}, + {"text": "天然成分", "priority": "medium"}, + {"text": "敏感肌适用", "priority": "medium"}, + ], + "forbidden_words": [ + {"word": "最", "reason": "广告法极限词", "severity": "hard"}, + {"word": "第一", "reason": "广告法极限词", "severity": "hard"}, + {"word": "药用", "reason": "化妆品禁用", "severity": "hard"}, + {"word": "治疗", "reason": "化妆品禁用", "severity": "hard"}, + ], + "brand_tone": { + "style": "年轻活力", + "description": "面向 18-35 岁女性用户" + }, + "timing_requirements": [ + {"type": "product_visible", "min_duration_seconds": 5}, + {"type": "brand_mention", "min_frequency": 3}, + ], + "platform": "douyin", + "region": "mainland_china", + } + + +@pytest.fixture +def sample_platform_rules() -> dict[str, Any]: + """抖音平台规则示例""" + return { + "platform": "douyin", + "version": "2026.01", + "forbidden_words": [ + {"word": "最", "category": "ad_law"}, + {"word": "第一", "category": "ad_law"}, + {"word": "国家级", "category": "ad_law"}, + {"word": "绝对", "category": "ad_law"}, + ], + "content_rules": [ + {"rule": "不得含有虚假宣传", "category": "compliance"}, + {"rule": "不得使用竞品 Logo", "category": "brand_safety"}, + ], + } + + +# ============================================================================ +# 视频审核 Fixtures +# ============================================================================ + +@pytest.fixture +def sample_asr_result() -> dict[str, Any]: + """ASR 语音识别结果示例""" + return { + "text": "大家好,这款产品真的非常好用,24小时持妆效果特别棒", + "segments": [ + {"word": "大家好", "start_ms": 0, "end_ms": 800, "confidence": 0.98}, + {"word": "这款产品", "start_ms": 850, "end_ms": 1500, "confidence": 0.97}, + {"word": "真的非常好用", "start_ms": 1550, "end_ms": 2800, "confidence": 0.96}, + {"word": "24小时持妆", "start_ms": 2900, "end_ms": 4000, "confidence": 0.99}, + {"word": "效果特别棒", "start_ms": 4100, "end_ms": 5200, "confidence": 0.95}, + ], + } + + +@pytest.fixture +def sample_ocr_result() -> dict[str, Any]: + """OCR 字幕识别结果示例""" + return { + "frames": [ + {"timestamp_ms": 1000, "text": "产品名称", "confidence": 0.98, "bbox": [100, 450, 300, 480]}, + {"timestamp_ms": 3000, "text": "24小时持妆", "confidence": 0.97, "bbox": [150, 450, 350, 480]}, + {"timestamp_ms": 5000, "text": "立即购买", "confidence": 0.96, "bbox": [200, 500, 400, 530]}, + ], + } + + +@pytest.fixture +def sample_cv_result() -> dict[str, Any]: + """CV 视觉检测结果示例""" + return { + "detections": [ + { + "object_type": "product", + "start_frame": 30, + "end_frame": 180, + "fps": 30, + "confidence": 0.95, + "bbox": [200, 100, 400, 350], + }, + { + "object_type": "competitor_logo", + "start_frame": 200, + "end_frame": 230, + "fps": 30, + "confidence": 0.88, + "bbox": [50, 50, 100, 100], + "logo_id": "competitor_001", + }, + ], + } + + +# ============================================================================ +# 违禁词测试数据 +# ============================================================================ + +@pytest.fixture +def prohibited_word_test_cases() -> list[dict[str, Any]]: + """违禁词检测测试用例集""" + return [ + # 广告语境下应检出 + {"text": "这是全网销量第一的产品", "context": "advertisement", "expected": ["第一"], "should_detect": True}, + {"text": "我们是行业领导者", "context": "advertisement", "expected": ["领导者"], "should_detect": True}, + {"text": "史上最低价促销", "context": "advertisement", "expected": ["最", "史上"], "should_detect": True}, + {"text": "绝对有效,药用级别", "context": "advertisement", "expected": ["绝对", "药用"], "should_detect": True}, + + # 日常语境下不应检出(语境感知) + {"text": "今天是我最开心的一天", "context": "daily", "expected": [], "should_detect": False}, + {"text": "这是我第一次来这里", "context": "daily", "expected": [], "should_detect": False}, + {"text": "我们家排行第一", "context": "daily", "expected": [], "should_detect": False}, + + # 边界情况 + {"text": "", "context": "advertisement", "expected": [], "should_detect": False}, + {"text": "这是一个普通的产品介绍", "context": "advertisement", "expected": [], "should_detect": False}, + + # 组合违禁词 + {"text": "全网销量第一,史上最低价", "context": "advertisement", "expected": ["第一", "最", "史上"], "should_detect": True}, + ] + + +@pytest.fixture +def context_understanding_test_cases() -> list[dict[str, Any]]: + """语境理解测试用例集""" + return [ + {"text": "这款产品是最好的选择", "expected_context": "advertisement", "should_flag": True}, + {"text": "最近天气真好", "expected_context": "daily", "should_flag": False}, + {"text": "今天心情最棒了", "expected_context": "daily", "should_flag": False}, + {"text": "我们的产品效果最显著", "expected_context": "advertisement", "should_flag": True}, + {"text": "这是我见过最美的风景", "expected_context": "daily", "should_flag": False}, + ] + + +# ============================================================================ +# 时间戳对齐测试数据 +# ============================================================================ + +@pytest.fixture +def multimodal_alignment_test_cases() -> list[dict[str, Any]]: + """多模态时间戳对齐测试用例""" + return [ + # 完全对齐情况 + { + "asr_ts": 1000, + "ocr_ts": 1000, + "cv_ts": 1000, + "tolerance_ms": 500, + "expected_merged": True, + "expected_timestamp": 1000, + }, + # 容差范围内对齐 + { + "asr_ts": 1000, + "ocr_ts": 1200, + "cv_ts": 1100, + "tolerance_ms": 500, + "expected_merged": True, + "expected_timestamp": 1100, # 取中位数 + }, + # 超出容差 + { + "asr_ts": 1000, + "ocr_ts": 2000, + "cv_ts": 3000, + "tolerance_ms": 500, + "expected_merged": False, + "expected_timestamp": None, + }, + # 部分对齐 + { + "asr_ts": 1000, + "ocr_ts": 1300, + "cv_ts": 5000, + "tolerance_ms": 500, + "expected_merged": "partial", # ASR 和 OCR 对齐,CV 独立 + "expected_timestamp": 1150, + }, + ] + + +# ============================================================================ +# API 测试数据 +# ============================================================================ + +@pytest.fixture +def valid_brief_upload_request() -> dict[str, Any]: + """有效的 Brief 上传请求""" + return { + "task_id": "task_001", + "platform": "douyin", + "region": "mainland_china", + } + + +@pytest.fixture +def valid_video_submit_request() -> dict[str, Any]: + """有效的视频提交请求""" + return { + "task_id": "task_001", + "video_id": "video_001", + "brief_id": "brief_001", + } + + +@pytest.fixture +def valid_review_decision_request() -> dict[str, Any]: + """有效的审核决策请求""" + return { + "report_id": "report_001", + "decision": "passed", + "selected_violations": [], + } + + +@pytest.fixture +def force_pass_decision_request() -> dict[str, Any]: + """强制通过请求(需填写原因)""" + return { + "report_id": "report_001", + "decision": "force_passed", + "selected_violations": ["violation_001"], + "force_pass_reason": "达人玩的新梗,品牌方认可", + } diff --git a/backend/tests/integration/test_api_brief.py b/backend/tests/integration/test_api_brief.py new file mode 100644 index 0000000..897a3c6 --- /dev/null +++ b/backend/tests/integration/test_api_brief.py @@ -0,0 +1,177 @@ +""" +Brief API 集成测试 + +TDD 测试用例 - 测试 Brief 相关 API 接口 + +接口规范参考:DevelopmentPlan.md 第 7 章 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from httpx import AsyncClient +# from app.main import app + + +class TestBriefUploadAPI: + """Brief 上传 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_upload_brief_pdf_success(self) -> None: + """测试 Brief PDF 上传成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 登录获取 token + # login_response = await client.post("/api/v1/auth/login", json={ + # "email": "agency@test.com", + # "password": "password" + # }) + # token = login_response.json()["access_token"] + # headers = {"Authorization": f"Bearer {token}"} + # + # # 上传 Brief + # with open("tests/fixtures/briefs/sample_brief.pdf", "rb") as f: + # response = await client.post( + # "/api/v1/briefs/upload", + # files={"file": ("brief.pdf", f, "application/pdf")}, + # data={"task_id": "task_001", "platform": "douyin"}, + # headers=headers + # ) + # + # assert response.status_code == 202 + # data = response.json() + # assert "parsing_id" in data + # assert data["status"] == "processing" + pytest.skip("待实现:Brief 上传 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_upload_unsupported_format_returns_400(self) -> None: + """测试不支持的格式返回 400""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/briefs/upload", + # files={"file": ("test.exe", b"content", "application/octet-stream")}, + # data={"task_id": "task_001"}, + # headers=headers + # ) + # + # assert response.status_code == 400 + # assert "Unsupported file format" in response.json()["error"] + pytest.skip("待实现:不支持格式测试") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_upload_without_auth_returns_401(self) -> None: + """测试无认证返回 401""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/briefs/upload", + # files={"file": ("brief.pdf", b"content", "application/pdf")}, + # data={"task_id": "task_001"} + # ) + # + # assert response.status_code == 401 + pytest.skip("待实现:无认证测试") + + +class TestBriefParsingAPI: + """Brief 解析结果 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_parsing_result_success(self) -> None: + """测试获取解析结果成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/briefs/brief_001", + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert "selling_points" in data + # assert "forbidden_words" in data + # assert "brand_tone" in data + pytest.skip("待实现:获取解析结果 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_nonexistent_brief_returns_404(self) -> None: + """测试获取不存在的 Brief 返回 404""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/briefs/nonexistent_id", + # headers=headers + # ) + # + # assert response.status_code == 404 + pytest.skip("待实现:404 测试") + + +class TestOnlineDocumentImportAPI: + """在线文档导入 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_import_feishu_doc_success(self) -> None: + """测试飞书文档导入成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/briefs/import", + # json={ + # "url": "https://docs.feishu.cn/docs/valid_doc_id", + # "task_id": "task_001" + # }, + # headers=headers + # ) + # + # assert response.status_code == 202 + pytest.skip("待实现:飞书导入 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_import_unauthorized_link_returns_403(self) -> None: + """测试无权限链接返回 403""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/briefs/import", + # json={ + # "url": "https://docs.feishu.cn/docs/restricted_doc", + # "task_id": "task_001" + # }, + # headers=headers + # ) + # + # assert response.status_code == 403 + # assert "access" in response.json()["error"].lower() + pytest.skip("待实现:无权限链接测试") + + +class TestRuleConflictAPI: + """规则冲突检测 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_detect_rule_conflict(self) -> None: + """测试规则冲突检测""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/briefs/brief_001/check_conflicts", + # json={"platform": "douyin"}, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert "conflicts" in data + pytest.skip("待实现:规则冲突检测 API") diff --git a/backend/tests/integration/test_api_review.py b/backend/tests/integration/test_api_review.py new file mode 100644 index 0000000..4af580c --- /dev/null +++ b/backend/tests/integration/test_api_review.py @@ -0,0 +1,487 @@ +""" +审核决策 API 集成测试 + +TDD 测试用例 - 测试审核员操作相关 API 接口 + +接口规范参考:DevelopmentPlan.md 第 7 章 +用户角色参考:User_Role_Interfaces.md +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from httpx import AsyncClient +# from app.main import app + + +class TestReviewDecisionAPI: + """审核决策 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_submit_pass_decision(self) -> None: + """测试提交通过决策""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 以审核员身份登录 + # login_response = await client.post("/api/v1/auth/login", json={ + # "email": "reviewer@test.com", + # "password": "password" + # }) + # token = login_response.json()["access_token"] + # headers = {"Authorization": f"Bearer {token}"} + # + # # 提交通过决策 + # response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={ + # "decision": "passed", + # "comment": "内容符合要求" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "passed" + # assert "review_id" in data + pytest.skip("待实现:通过决策 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_submit_reject_decision_with_violations(self) -> None: + """测试提交驳回决策 - 必须选择违规项""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={ + # "decision": "rejected", + # "selected_violations": ["vio_001", "vio_002"], + # "comment": "存在违规内容" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "rejected" + # assert len(data["selected_violations"]) == 2 + pytest.skip("待实现:驳回决策 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_reject_without_violations_returns_400(self) -> None: + """测试驳回无违规项返回 400""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={ + # "decision": "rejected", + # "selected_violations": [], # 空违规列表 + # "comment": "驳回" + # }, + # headers=headers + # ) + # + # assert response.status_code == 400 + # assert "违规项" in response.json()["error"] + pytest.skip("待实现:驳回无违规项测试") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_submit_force_pass_with_reason(self) -> None: + """测试强制通过 - 必须填写原因""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={ + # "decision": "force_passed", + # "force_pass_reason": "达人玩的新梗,品牌方认可", + # "comment": "特殊情况强制通过" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "force_passed" + # assert data["force_pass_reason"] is not None + pytest.skip("待实现:强制通过 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_force_pass_without_reason_returns_400(self) -> None: + """测试强制通过无原因返回 400""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={ + # "decision": "force_passed", + # "force_pass_reason": "", # 空原因 + # }, + # headers=headers + # ) + # + # assert response.status_code == 400 + # assert "原因" in response.json()["error"] + pytest.skip("待实现:强制通过无原因测试") + + +class TestViolationEditAPI: + """违规项编辑 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_add_manual_violation(self) -> None: + """测试手动添加违规项""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/violations", + # json={ + # "type": "other", + # "content": "手动发现的问题", + # "timestamp_start": 10.5, + # "timestamp_end": 15.0, + # "severity": "medium" + # }, + # headers=headers + # ) + # + # assert response.status_code == 201 + # data = response.json() + # assert "violation_id" in data + # assert data["source"] == "manual" + pytest.skip("待实现:添加手动违规项") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_delete_ai_violation(self) -> None: + """测试删除 AI 检测的违规项""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.delete( + # "/api/v1/reviews/video_001/violations/vio_001", + # json={ + # "delete_reason": "误检" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "deleted" + pytest.skip("待实现:删除违规项") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_modify_violation_severity(self) -> None: + """测试修改违规项严重程度""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.patch( + # "/api/v1/reviews/video_001/violations/vio_001", + # json={ + # "severity": "low", + # "modify_reason": "风险较低" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["severity"] == "low" + pytest.skip("待实现:修改违规严重程度") + + +class TestAppealAPI: + """申诉 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_submit_appeal_success(self) -> None: + """测试提交申诉成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 以达人身份登录 + # login_response = await client.post("/api/v1/auth/login", json={ + # "email": "creator@test.com", + # "password": "password" + # }) + # token = login_response.json()["access_token"] + # headers = {"Authorization": f"Bearer {token}"} + # + # response = await client.post( + # "/api/v1/reviews/video_001/appeal", + # json={ + # "violation_ids": ["vio_001"], + # "reason": "这个词语在此语境下是正常使用,不应被判定为违规" + # }, + # headers=headers + # ) + # + # assert response.status_code == 201 + # data = response.json() + # assert "appeal_id" in data + # assert data["status"] == "pending" + pytest.skip("待实现:提交申诉 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_appeal_reason_too_short_returns_400(self) -> None: + """测试申诉理由过短返回 400 - 必须 ≥ 10 字""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_001/appeal", + # json={ + # "violation_ids": ["vio_001"], + # "reason": "太短了" # < 10 字 + # }, + # headers=creator_headers + # ) + # + # assert response.status_code == 400 + # assert "10" in response.json()["error"] + pytest.skip("待实现:申诉理由过短测试") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_appeal_token_deduction(self) -> None: + """测试申诉扣除令牌""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 获取当前令牌数 + # profile_response = await client.get( + # "/api/v1/users/me", + # headers=creator_headers + # ) + # initial_tokens = profile_response.json()["appeal_tokens"] + # + # # 提交申诉 + # await client.post( + # "/api/v1/reviews/video_001/appeal", + # json={ + # "violation_ids": ["vio_001"], + # "reason": "这个词语在此语境下是正常使用,不应被判定为违规" + # }, + # headers=creator_headers + # ) + # + # # 验证令牌扣除 + # profile_response = await client.get( + # "/api/v1/users/me", + # headers=creator_headers + # ) + # assert profile_response.json()["appeal_tokens"] == initial_tokens - 1 + pytest.skip("待实现:申诉令牌扣除") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_appeal_no_token_returns_403(self) -> None: + """测试无令牌申诉返回 403""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 使用无令牌的用户 + # response = await client.post( + # "/api/v1/reviews/video_001/appeal", + # json={ + # "violation_ids": ["vio_001"], + # "reason": "这个词语在此语境下是正常使用,不应被判定为违规" + # }, + # headers=no_token_user_headers + # ) + # + # assert response.status_code == 403 + # assert "令牌" in response.json()["error"] + pytest.skip("待实现:无令牌申诉测试") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_process_appeal_success(self) -> None: + """测试处理申诉 - 申诉成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/appeals/appeal_001/process", + # json={ + # "decision": "approved", + # "comment": "申诉理由成立" + # }, + # headers=reviewer_headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "approved" + pytest.skip("待实现:处理申诉 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_appeal_success_restores_token(self) -> None: + """测试申诉成功返还令牌""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 获取申诉前令牌数 + # profile_response = await client.get( + # "/api/v1/users/creator_001", + # headers=admin_headers + # ) + # tokens_before = profile_response.json()["appeal_tokens"] + # + # # 处理申诉为成功 + # await client.post( + # "/api/v1/reviews/appeals/appeal_001/process", + # json={"decision": "approved", "comment": "申诉成立"}, + # headers=reviewer_headers + # ) + # + # # 验证令牌返还 + # profile_response = await client.get( + # "/api/v1/users/creator_001", + # headers=admin_headers + # ) + # assert profile_response.json()["appeal_tokens"] == tokens_before + 1 + pytest.skip("待实现:申诉成功返还令牌") + + +class TestReviewHistoryAPI: + """审核历史 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_review_history(self) -> None: + """测试获取审核历史""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/reviews/video_001/history", + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # assert "history" in data + # for entry in data["history"]: + # assert "timestamp" in entry + # assert "action" in entry + # assert "actor" in entry + pytest.skip("待实现:审核历史 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_review_history_includes_all_actions(self) -> None: + """测试审核历史包含所有操作""" + # TODO: 实现 API 测试 + # 应包含:AI 审核、人工审核、申诉、重新提交等 + pytest.skip("待实现:审核历史完整性") + + +class TestBatchReviewAPI: + """批量审核 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_batch_pass_videos(self) -> None: + """测试批量通过视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/batch/decision", + # json={ + # "video_ids": ["video_001", "video_002", "video_003"], + # "decision": "passed", + # "comment": "批量通过" + # }, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["processed_count"] == 3 + # assert data["success_count"] == 3 + pytest.skip("待实现:批量通过 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_batch_review_partial_failure(self) -> None: + """测试批量审核部分失败""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/batch/decision", + # json={ + # "video_ids": ["video_001", "nonexistent_video"], + # "decision": "passed" + # }, + # headers=headers + # ) + # + # assert response.status_code == 207 # Multi-Status + # data = response.json() + # assert data["success_count"] == 1 + # assert data["failure_count"] == 1 + # assert "failures" in data + pytest.skip("待实现:批量审核部分失败") + + +class TestReviewPermissionAPI: + """审核权限 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_creator_cannot_review_own_video(self) -> None: + """测试达人不能审核自己的视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_own/decision", + # json={"decision": "passed"}, + # headers=creator_headers + # ) + # + # assert response.status_code == 403 + pytest.skip("待实现:达人审核权限限制") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_agency_can_review_assigned_videos(self) -> None: + """测试 Agency 可以审核分配的视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/reviews/video_assigned/decision", + # json={"decision": "passed"}, + # headers=agency_headers + # ) + # + # assert response.status_code == 200 + pytest.skip("待实现:Agency 审核权限") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_brand_can_view_but_not_decide(self) -> None: + """测试品牌方可以查看但不能决策""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 可以查看 + # view_response = await client.get( + # "/api/v1/reviews/video_001", + # headers=brand_headers + # ) + # assert view_response.status_code == 200 + # + # # 不能决策 + # decision_response = await client.post( + # "/api/v1/reviews/video_001/decision", + # json={"decision": "passed"}, + # headers=brand_headers + # ) + # assert decision_response.status_code == 403 + pytest.skip("待实现:品牌方权限限制") diff --git a/backend/tests/integration/test_api_video.py b/backend/tests/integration/test_api_video.py new file mode 100644 index 0000000..d3ae95f --- /dev/null +++ b/backend/tests/integration/test_api_video.py @@ -0,0 +1,379 @@ +""" +视频 API 集成测试 + +TDD 测试用例 - 测试视频上传、审核相关 API 接口 + +接口规范参考:DevelopmentPlan.md 第 7 章 +验收标准参考:FeatureSummary.md F-10~F-18 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from httpx import AsyncClient +# from app.main import app + + +class TestVideoUploadAPI: + """视频上传 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_upload_video_success(self) -> None: + """测试视频上传成功 - 返回 202 和 video_id""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 登录获取 token + # login_response = await client.post("/api/v1/auth/login", json={ + # "email": "creator@test.com", + # "password": "password" + # }) + # token = login_response.json()["access_token"] + # headers = {"Authorization": f"Bearer {token}"} + # + # # 上传视频 + # with open("tests/fixtures/videos/sample_video.mp4", "rb") as f: + # response = await client.post( + # "/api/v1/videos/upload", + # files={"file": ("test.mp4", f, "video/mp4")}, + # data={ + # "task_id": "task_001", + # "title": "测试视频" + # }, + # headers=headers + # ) + # + # assert response.status_code == 202 + # data = response.json() + # assert "video_id" in data + # assert data["status"] == "processing" + pytest.skip("待实现:视频上传 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_upload_oversized_video_returns_413(self) -> None: + """测试超大视频返回 413 - 最大 100MB""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 创建超过 100MB 的测试数据 + # oversized_content = b"x" * (101 * 1024 * 1024) + # + # response = await client.post( + # "/api/v1/videos/upload", + # files={"file": ("large.mp4", oversized_content, "video/mp4")}, + # data={"task_id": "task_001"}, + # headers=headers + # ) + # + # assert response.status_code == 413 + # assert "100MB" in response.json()["error"] + pytest.skip("待实现:超大视频测试") + + @pytest.mark.integration + @pytest.mark.asyncio + @pytest.mark.parametrize("mime_type,expected_status", [ + ("video/mp4", 202), + ("video/quicktime", 202), # MOV + ("video/x-msvideo", 400), # AVI - 不支持 + ("video/x-matroska", 400), # MKV - 不支持 + ("application/pdf", 400), + ]) + async def test_upload_video_format_validation( + self, + mime_type: str, + expected_status: int, + ) -> None: + """测试视频格式验证 - 仅支持 MP4/MOV""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/videos/upload", + # files={"file": ("test.video", b"content", mime_type)}, + # data={"task_id": "task_001"}, + # headers=headers + # ) + # + # assert response.status_code == expected_status + pytest.skip("待实现:视频格式验证") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_resumable_upload(self) -> None: + """测试断点续传功能""" + # TODO: 实现断点续传测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 初始化上传 + # init_response = await client.post( + # "/api/v1/videos/upload/init", + # json={ + # "filename": "large_video.mp4", + # "file_size": 50 * 1024 * 1024, + # "task_id": "task_001" + # }, + # headers=headers + # ) + # upload_id = init_response.json()["upload_id"] + # + # # 上传分片 + # chunk_response = await client.post( + # f"/api/v1/videos/upload/{upload_id}/chunk", + # files={"chunk": ("chunk_0", b"x" * 1024 * 1024)}, + # data={"chunk_index": 0}, + # headers=headers + # ) + # + # assert chunk_response.status_code == 200 + # assert chunk_response.json()["received_chunks"] == 1 + pytest.skip("待实现:断点续传") + + +class TestVideoAuditAPI: + """视频审核 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_audit_result_success(self) -> None: + """测试获取审核结果成功""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos/video_001/audit", + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # # 验证审核报告结构 + # assert "report_id" in data + # assert "video_id" in data + # assert "status" in data + # assert "violations" in data + # assert "brief_compliance" in data + # assert "processing_time_ms" in data + pytest.skip("待实现:获取审核结果 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_audit_result_processing(self) -> None: + """测试获取处理中的审核结果""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos/video_processing/audit", + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # assert data["status"] == "processing" + # assert "progress" in data + pytest.skip("待实现:处理中状态测试") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_nonexistent_video_returns_404(self) -> None: + """测试获取不存在的视频返回 404""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos/nonexistent_id/audit", + # headers=headers + # ) + # + # assert response.status_code == 404 + pytest.skip("待实现:404 测试") + + +class TestViolationEvidenceAPI: + """违规证据 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_violation_evidence(self) -> None: + """测试获取违规证据 - 包含截图和时间戳""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos/video_001/violations/vio_001/evidence", + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # assert "violation_id" in data + # assert "evidence_type" in data + # assert "screenshot_url" in data + # assert "timestamp_start" in data + # assert "timestamp_end" in data + # assert "content" in data + pytest.skip("待实现:违规证据 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_evidence_screenshot_accessible(self) -> None: + """测试证据截图可访问""" + # TODO: 实现截图访问测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 获取证据 + # evidence_response = await client.get( + # "/api/v1/videos/video_001/violations/vio_001/evidence", + # headers=headers + # ) + # screenshot_url = evidence_response.json()["screenshot_url"] + # + # # 访问截图 + # screenshot_response = await client.get(screenshot_url) + # assert screenshot_response.status_code == 200 + # assert "image" in screenshot_response.headers["content-type"] + pytest.skip("待实现:截图访问测试") + + +class TestVideoPreviewAPI: + """视频预览 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_get_video_preview_with_timestamp(self) -> None: + """测试带时间戳的视频预览""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos/video_001/preview", + # params={"start_ms": 5000, "end_ms": 10000}, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # assert "preview_url" in data + # assert "start_ms" in data + # assert "end_ms" in data + pytest.skip("待实现:视频预览 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_video_seek_to_violation(self) -> None: + """测试视频跳转到违规时间点""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # # 获取违规列表 + # violations_response = await client.get( + # "/api/v1/videos/video_001/violations", + # headers=headers + # ) + # violations = violations_response.json()["violations"] + # + # # 每个违规项应包含可跳转的时间戳 + # for violation in violations: + # assert "timestamp_start" in violation + # assert violation["timestamp_start"] >= 0 + pytest.skip("待实现:视频跳转") + + +class TestVideoResubmitAPI: + """视频重新提交 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_resubmit_video_success(self) -> None: + """测试重新提交视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/videos/video_001/resubmit", + # json={ + # "modification_note": "已修改违规内容", + # "modified_sections": ["00:05-00:10"] + # }, + # headers=headers + # ) + # + # assert response.status_code == 202 + # data = response.json() + # assert data["status"] == "processing" + # assert "new_video_id" in data + pytest.skip("待实现:重新提交 API") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_resubmit_without_modification_note(self) -> None: + """测试无修改说明的重新提交""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.post( + # "/api/v1/videos/video_001/resubmit", + # json={}, + # headers=headers + # ) + # + # # 应该允许不提供修改说明 + # assert response.status_code in [202, 400] + pytest.skip("待实现:无修改说明测试") + + +class TestVideoListAPI: + """视频列表 API 测试""" + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_list_videos_with_pagination(self) -> None: + """测试视频列表分页""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos", + # params={"page": 1, "page_size": 10}, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # assert "items" in data + # assert "total" in data + # assert "page" in data + # assert "page_size" in data + # assert len(data["items"]) <= 10 + pytest.skip("待实现:视频列表分页") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_list_videos_filter_by_status(self) -> None: + """测试按状态筛选视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos", + # params={"status": "pending_review"}, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # for item in data["items"]: + # assert item["status"] == "pending_review" + pytest.skip("待实现:状态筛选") + + @pytest.mark.integration + @pytest.mark.asyncio + async def test_list_videos_filter_by_task(self) -> None: + """测试按任务筛选视频""" + # TODO: 实现 API 测试 + # async with AsyncClient(app=app, base_url="http://test") as client: + # response = await client.get( + # "/api/v1/videos", + # params={"task_id": "task_001"}, + # headers=headers + # ) + # + # assert response.status_code == 200 + # data = response.json() + # + # for item in data["items"]: + # assert item["task_id"] == "task_001" + pytest.skip("待实现:任务筛选") diff --git a/backend/tests/unit/test_brief_parser.py b/backend/tests/unit/test_brief_parser.py new file mode 100644 index 0000000..457fadf --- /dev/null +++ b/backend/tests/unit/test_brief_parser.py @@ -0,0 +1,339 @@ +""" +Brief 解析模块单元测试 + +TDD 测试用例 - 基于 FeatureSummary.md (F-01, F-02) 的验收标准 + +验收标准: +- 图文混排解析准确率 > 90% +- 支持 PDF/Word/Excel/PPT/图片格式 +- 支持飞书/Notion 在线文档链接 +""" + +import pytest +from typing import Any +from pathlib import Path + +# 导入待实现的模块(TDD 红灯阶段) +# from app.services.brief_parser import BriefParser, BriefParsingResult + + +class TestBriefParser: + """ + Brief 解析器测试 + + 验收标准 (FeatureSummary.md F-01): + - 解析准确率 > 90% + """ + + @pytest.mark.unit + def test_extract_selling_points(self) -> None: + """测试卖点提取""" + brief_content = """ + 产品核心卖点: + 1. 24小时持妆 + 2. 天然成分 + 3. 敏感肌适用 + """ + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.extract_selling_points(brief_content) + # + # assert len(result.selling_points) >= 3 + # assert "24小时持妆" in [sp.text for sp in result.selling_points] + # assert "天然成分" in [sp.text for sp in result.selling_points] + # assert "敏感肌适用" in [sp.text for sp in result.selling_points] + pytest.skip("待实现:BriefParser.extract_selling_points") + + @pytest.mark.unit + def test_extract_forbidden_words(self) -> None: + """测试禁忌词提取""" + brief_content = """ + 禁止使用的词汇: + - 药用 + - 治疗 + - 根治 + - 最有效 + """ + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.extract_forbidden_words(brief_content) + # + # expected = {"药用", "治疗", "根治", "最有效"} + # assert set(w.word for w in result.forbidden_words) == expected + pytest.skip("待实现:BriefParser.extract_forbidden_words") + + @pytest.mark.unit + def test_extract_timing_requirements(self) -> None: + """测试时序要求提取""" + brief_content = """ + 拍摄要求: + - 产品同框时长 > 5秒 + - 品牌名提及次数 ≥ 3次 + - 产品使用演示 ≥ 10秒 + """ + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.extract_timing_requirements(brief_content) + # + # assert len(result.timing_requirements) >= 3 + # + # product_visible = next( + # (t for t in result.timing_requirements if t.type == "product_visible"), + # None + # ) + # assert product_visible is not None + # assert product_visible.min_duration_seconds == 5 + # + # brand_mention = next( + # (t for t in result.timing_requirements if t.type == "brand_mention"), + # None + # ) + # assert brand_mention is not None + # assert brand_mention.min_frequency == 3 + pytest.skip("待实现:BriefParser.extract_timing_requirements") + + @pytest.mark.unit + def test_extract_brand_tone(self) -> None: + """测试品牌调性提取""" + brief_content = """ + 品牌调性: + - 风格:年轻活力、专业可信 + - 目标人群:18-35岁女性 + - 表达方式:亲和、不做作 + """ + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.extract_brand_tone(brief_content) + # + # assert result.brand_tone is not None + # assert "年轻活力" in result.brand_tone.style + # assert "专业可信" in result.brand_tone.style + pytest.skip("待实现:BriefParser.extract_brand_tone") + + @pytest.mark.unit + def test_full_brief_parsing_accuracy(self) -> None: + """ + 测试完整 Brief 解析准确率 + + 验收标准:准确率 > 90% + """ + brief_content = """ + # 品牌 Brief - XX美妆产品 + + ## 产品卖点 + 1. 24小时持妆效果 + 2. 添加天然植物成分 + 3. 通过敏感肌测试 + + ## 禁用词汇 + - 药用、治疗、根治 + - 最好、第一、绝对 + + ## 拍摄要求 + - 产品正面展示 ≥ 5秒 + - 品牌名提及 ≥ 3次 + + ## 品牌调性 + 年轻、时尚、专业 + """ + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.parse(brief_content) + # + # # 验证解析完整性 + # assert len(result.selling_points) >= 3 + # assert len(result.forbidden_words) >= 4 + # assert len(result.timing_requirements) >= 2 + # assert result.brand_tone is not None + # + # # 验证准确率 + # assert result.accuracy_rate >= 0.90 + pytest.skip("待实现:BriefParser.parse") + + +class TestBriefFileFormats: + """ + Brief 文件格式支持测试 + + 验收标准 (FeatureSummary.md F-01): + - 支持 PDF/Word/Excel/PPT/图片 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("file_format,mime_type", [ + ("pdf", "application/pdf"), + ("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + ("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + ("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + ("png", "image/png"), + ("jpg", "image/jpeg"), + ]) + def test_supported_file_formats(self, file_format: str, mime_type: str) -> None: + """测试支持的文件格式""" + # TODO: 实现文件格式验证 + # validator = BriefFileValidator() + # assert validator.is_supported(file_format) + # assert validator.get_mime_type(file_format) == mime_type + pytest.skip("待实现:BriefFileValidator") + + @pytest.mark.unit + @pytest.mark.parametrize("file_format", [ + "exe", "zip", "rar", "mp4", "mp3", + ]) + def test_unsupported_file_formats(self, file_format: str) -> None: + """测试不支持的文件格式""" + # TODO: 实现文件格式验证 + # validator = BriefFileValidator() + # assert not validator.is_supported(file_format) + pytest.skip("待实现:不支持的格式验证") + + +class TestOnlineDocumentImport: + """ + 在线文档导入测试 + + 验收标准 (FeatureSummary.md F-02): + - 支持飞书/Notion 分享链接 + - 仅支持授权的分享链接 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("url,expected_valid", [ + # 飞书文档 + ("https://docs.feishu.cn/docs/abc123", True), + ("https://abc.feishu.cn/docx/xyz789", True), + + # Notion 文档 + ("https://www.notion.so/workspace/page-abc123", True), + ("https://notion.so/page-xyz789", True), + + # 不支持的链接 + ("https://google.com/doc/123", False), + ("https://docs.google.com/document/d/123", False), # Google Docs 暂不支持 + ("https://example.com/brief.pdf", False), + ]) + def test_online_document_url_validation(self, url: str, expected_valid: bool) -> None: + """测试在线文档 URL 验证""" + # TODO: 实现 URL 验证器 + # validator = OnlineDocumentValidator() + # assert validator.is_valid(url) == expected_valid + pytest.skip("待实现:OnlineDocumentValidator") + + @pytest.mark.unit + def test_unauthorized_link_returns_error(self) -> None: + """测试无权限链接返回明确错误""" + unauthorized_url = "https://docs.feishu.cn/docs/restricted-doc" + + # TODO: 实现在线文档导入 + # importer = OnlineDocumentImporter() + # result = importer.import_document(unauthorized_url) + # + # assert result.status == "failed" + # assert result.error_code == "ACCESS_DENIED" + # assert "权限" in result.error_message or "access" in result.error_message.lower() + pytest.skip("待实现:OnlineDocumentImporter") + + +class TestBriefParsingEdgeCases: + """ + Brief 解析边界情况测试 + """ + + @pytest.mark.unit + def test_encrypted_pdf_handling(self) -> None: + """测试加密 PDF 处理 - 应降级提示手动输入""" + # TODO: 实现加密 PDF 检测 + # parser = BriefParser() + # result = parser.parse_file("encrypted.pdf") + # + # assert result.status == "failed" + # assert result.error_code == "ENCRYPTED_FILE" + # assert "手动输入" in result.fallback_suggestion + pytest.skip("待实现:加密 PDF 处理") + + @pytest.mark.unit + def test_empty_brief_handling(self) -> None: + """测试空 Brief 处理""" + # TODO: 实现空内容处理 + # parser = BriefParser() + # result = parser.parse("") + # + # assert result.status == "failed" + # assert result.error_code == "EMPTY_CONTENT" + pytest.skip("待实现:空 Brief 处理") + + @pytest.mark.unit + def test_non_chinese_brief_handling(self) -> None: + """测试非中文 Brief 处理""" + english_brief = """ + Product Features: + 1. 24-hour long-lasting + 2. Natural ingredients + """ + + # TODO: 实现多语言检测 + # parser = BriefParser() + # result = parser.parse(english_brief) + # + # # 应该能处理英文,但提示语言 + # assert result.detected_language == "en" + pytest.skip("待实现:多语言 Brief 处理") + + @pytest.mark.unit + def test_image_brief_with_text_extraction(self) -> None: + """测试图片 Brief 的文字提取 (OCR)""" + # TODO: 实现图片 Brief OCR + # parser = BriefParser() + # result = parser.parse_image("brief_screenshot.png") + # + # assert result.status == "success" + # assert len(result.extracted_text) > 0 + pytest.skip("待实现:图片 Brief OCR") + + +class TestBriefParsingOutput: + """ + Brief 解析输出格式测试 + """ + + @pytest.mark.unit + def test_output_json_structure(self) -> None: + """测试输出 JSON 结构符合规范""" + brief_content = "测试 Brief 内容" + + # TODO: 实现 BriefParser + # parser = BriefParser() + # result = parser.parse(brief_content) + # output = result.to_json() + # + # # 验证必需字段 + # assert "selling_points" in output + # assert "forbidden_words" in output + # assert "brand_tone" in output + # assert "timing_requirements" in output + # assert "platform" in output + # assert "region" in output + # + # # 验证字段类型 + # assert isinstance(output["selling_points"], list) + # assert isinstance(output["forbidden_words"], list) + pytest.skip("待实现:输出 JSON 结构验证") + + @pytest.mark.unit + def test_selling_point_structure(self) -> None: + """测试卖点数据结构""" + # TODO: 实现卖点结构验证 + # expected_fields = ["text", "priority", "evidence_snippet"] + # + # parser = BriefParser() + # result = parser.parse("卖点测试") + # + # for sp in result.selling_points: + # for field in expected_fields: + # assert hasattr(sp, field) + pytest.skip("待实现:卖点结构验证") diff --git a/backend/tests/unit/test_rule_engine.py b/backend/tests/unit/test_rule_engine.py new file mode 100644 index 0000000..02c6450 --- /dev/null +++ b/backend/tests/unit/test_rule_engine.py @@ -0,0 +1,278 @@ +""" +规则引擎单元测试 + +TDD 测试用例 - 基于 FeatureSummary.md (F-03, F-04, F-05-A, F-06) 的验收标准 + +验收标准: +- 违禁词召回率 ≥ 95% +- 违禁词误报率 ≤ 5% +- 语境理解误报率 ≤ 5% +- 规则冲突提示清晰可追溯 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段 - 模块尚未实现) +# from app.services.rule_engine import RuleEngine, ProhibitedWordDetector, RuleConflictDetector + + +class TestProhibitedWordDetector: + """ + 违禁词检测器测试 + + 验收标准 (FeatureSummary.md): + - 召回率 ≥ 95% + - 误报率 ≤ 5% + """ + + @pytest.mark.unit + @pytest.mark.parametrize("text,context,expected_violations,should_detect", [ + # 广告语境 - 应检出 + ("这是全网销量第一的产品", "advertisement", ["第一"], True), + ("我们是行业领导者", "advertisement", ["领导者"], True), + ("史上最低价促销", "advertisement", ["最", "史上"], True), + ("绝对有效果", "advertisement", ["绝对"], True), + + # 日常语境 - 不应检出 (语境感知) + ("今天是我最开心的一天", "daily", [], False), + ("这是我第一次来这里", "daily", [], False), + ("我最喜欢吃苹果", "daily", [], False), + + # 边界情况 + ("", "advertisement", [], False), + ("普通的产品介绍,没有违禁词", "advertisement", [], False), + ]) + def test_detect_prohibited_words( + self, + text: str, + context: str, + expected_violations: list[str], + should_detect: bool, + ) -> None: + """测试违禁词检测的准确性""" + # TODO: 实现 ProhibitedWordDetector + # detector = ProhibitedWordDetector() + # result = detector.detect(text, context=context) + # + # if should_detect: + # assert len(result.violations) > 0 + # for word in expected_violations: + # assert any(word in v.content for v in result.violations) + # else: + # assert len(result.violations) == 0 + pytest.skip("待实现:ProhibitedWordDetector") + + @pytest.mark.unit + def test_recall_rate_above_threshold( + self, + prohibited_word_test_cases: list[dict[str, Any]], + ) -> None: + """ + 验证召回率 ≥ 95% + + 召回率 = 正确检出数 / 应检出总数 + """ + # TODO: 使用完整测试集验证召回率 + # detector = ProhibitedWordDetector() + # positive_cases = [c for c in prohibited_word_test_cases if c["should_detect"]] + # + # true_positives = 0 + # for case in positive_cases: + # result = detector.detect(case["text"], context=case["context"]) + # if result.violations: + # true_positives += 1 + # + # recall = true_positives / len(positive_cases) + # assert recall >= 0.95, f"召回率 {recall:.2%} 低于阈值 95%" + pytest.skip("待实现:召回率测试") + + @pytest.mark.unit + def test_false_positive_rate_below_threshold( + self, + prohibited_word_test_cases: list[dict[str, Any]], + ) -> None: + """ + 验证误报率 ≤ 5% + + 误报率 = 错误检出数 / 不应检出总数 + """ + # TODO: 使用完整测试集验证误报率 + # detector = ProhibitedWordDetector() + # negative_cases = [c for c in prohibited_word_test_cases if not c["should_detect"]] + # + # false_positives = 0 + # for case in negative_cases: + # result = detector.detect(case["text"], context=case["context"]) + # if result.violations: + # false_positives += 1 + # + # fpr = false_positives / len(negative_cases) + # assert fpr <= 0.05, f"误报率 {fpr:.2%} 超过阈值 5%" + pytest.skip("待实现:误报率测试") + + +class TestContextUnderstanding: + """ + 语境理解测试 + + 验收标准 (DevelopmentPlan.md 第 8 章): + - 广告极限词与非广告语境区分误报率 ≤ 5% + - 不将「最开心的一天」误判为违规 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("text,expected_context,should_flag", [ + ("这款产品是最好的选择", "advertisement", True), + ("最近天气真好", "daily", False), + ("今天心情最棒了", "daily", False), + ("我们的产品效果最显著", "advertisement", True), + ("这是我见过最美的风景", "daily", False), + ("全网销量第一,值得信赖", "advertisement", True), + ("我第一次尝试这个运动", "daily", False), + ]) + def test_context_classification( + self, + text: str, + expected_context: str, + should_flag: bool, + ) -> None: + """测试语境分类准确性""" + # TODO: 实现语境分类器 + # classifier = ContextClassifier() + # result = classifier.classify(text) + # + # assert result.context == expected_context + # if should_flag: + # assert result.is_advertisement_context + # else: + # assert not result.is_advertisement_context + pytest.skip("待实现:ContextClassifier") + + @pytest.mark.unit + def test_happy_day_not_flagged(self) -> None: + """ + 关键测试:「最开心的一天」不应被误判 + + 这是 DevelopmentPlan.md 明确要求的测试用例 + """ + text = "今天是我最开心的一天" + + # TODO: 实现检测器 + # detector = ProhibitedWordDetector() + # result = detector.detect(text, context="auto") # 自动识别语境 + # + # assert len(result.violations) == 0, "「最开心的一天」被误判为违规" + pytest.skip("待实现:语境感知检测") + + +class TestRuleConflictDetector: + """ + 规则冲突检测测试 + + 验收标准 (FeatureSummary.md F-03): + - 规则冲突提示清晰可追溯 + """ + + @pytest.mark.unit + def test_detect_brief_platform_conflict( + self, + sample_brief_rules: dict[str, Any], + sample_platform_rules: dict[str, Any], + ) -> None: + """测试 Brief 规则与平台规则冲突检测""" + # 构造冲突场景:Brief 允许使用「最佳效果」,但平台禁止「最」 + brief_rules = { + **sample_brief_rules, + "allowed_words": ["最佳效果"], + } + + # TODO: 实现冲突检测器 + # detector = RuleConflictDetector() + # conflicts = detector.detect(brief_rules, sample_platform_rules) + # + # assert len(conflicts) > 0 + # assert any("最" in c.conflicting_term for c in conflicts) + # assert all(c.resolution_suggestion is not None for c in conflicts) + pytest.skip("待实现:RuleConflictDetector") + + @pytest.mark.unit + def test_no_conflict_when_compatible( + self, + sample_brief_rules: dict[str, Any], + sample_platform_rules: dict[str, Any], + ) -> None: + """测试规则兼容时无冲突""" + # TODO: 实现冲突检测器 + # detector = RuleConflictDetector() + # conflicts = detector.detect(sample_brief_rules, sample_platform_rules) + # + # # 标准 Brief 规则应与平台规则兼容 + # assert len(conflicts) == 0 + pytest.skip("待实现:规则兼容性测试") + + +class TestRuleVersioning: + """ + 规则版本管理测试 + + 验收标准 (FeatureSummary.md F-06): + - 规则变更历史可追溯 + - 支持回滚到历史版本 + """ + + @pytest.mark.unit + def test_rule_version_tracking(self) -> None: + """测试规则版本追踪""" + # TODO: 实现规则版本管理 + # rule_manager = RuleVersionManager() + # + # # 创建规则 + # rule_v1 = rule_manager.create_rule({"word": "最", "severity": "hard"}) + # assert rule_v1.version == "v1.0.0" + # + # # 更新规则 + # rule_v2 = rule_manager.update_rule(rule_v1.id, {"severity": "soft"}) + # assert rule_v2.version == "v1.1.0" + # + # # 查看历史 + # history = rule_manager.get_history(rule_v1.id) + # assert len(history) == 2 + pytest.skip("待实现:RuleVersionManager") + + @pytest.mark.unit + def test_rule_rollback(self) -> None: + """测试规则回滚""" + # TODO: 实现规则回滚 + # rule_manager = RuleVersionManager() + # + # rule_v1 = rule_manager.create_rule({"word": "最", "severity": "hard"}) + # rule_v2 = rule_manager.update_rule(rule_v1.id, {"severity": "soft"}) + # + # # 回滚到 v1 + # rolled_back = rule_manager.rollback(rule_v1.id, "v1.0.0") + # assert rolled_back.severity == "hard" + pytest.skip("待实现:规则回滚") + + +class TestPlatformRuleSync: + """ + 平台规则同步测试 + + 验收标准 (PRD.md): + - 平台规则变更后 ≤ 1 工作日内更新 + """ + + @pytest.mark.unit + def test_platform_rule_update_notification(self) -> None: + """测试平台规则更新通知""" + # TODO: 实现平台规则同步 + # sync_service = PlatformRuleSyncService() + # + # # 模拟抖音规则更新 + # new_rules = {"forbidden_words": [{"word": "新违禁词", "category": "ad_law"}]} + # result = sync_service.sync_platform_rules("douyin", new_rules) + # + # assert result.updated + # assert result.notification_sent + pytest.skip("待实现:PlatformRuleSyncService") diff --git a/backend/tests/unit/test_timestamp_alignment.py b/backend/tests/unit/test_timestamp_alignment.py new file mode 100644 index 0000000..c84643f --- /dev/null +++ b/backend/tests/unit/test_timestamp_alignment.py @@ -0,0 +1,365 @@ +""" +多模态时间戳对齐模块单元测试 + +TDD 测试用例 - 基于 DevelopmentPlan.md (F-14, F-45) 的验收标准 + +验收标准: +- 时长统计误差 ≤ 0.5秒 +- 频次统计准确率 ≥ 95% +- 时间轴归一化精度 ≤ 0.1秒 +- 模糊匹配容差窗口 ±0.5秒 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.utils.timestamp_align import ( +# TimestampAligner, +# MultiModalEvent, +# AlignmentResult, +# ) + + +class TestTimestampAligner: + """ + 时间戳对齐器测试 + + 验收标准: + - 时间轴归一化精度 ≤ 0.1秒 + - 模糊匹配容差窗口 ±0.5秒 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("asr_ts,ocr_ts,cv_ts,tolerance,expected_merged,expected_ts", [ + # 完全对齐 + (1000, 1000, 1000, 500, True, 1000), + # 容差范围内 - 应合并 + (1000, 1200, 1100, 500, True, 1100), # 中位数 + (1000, 1400, 1200, 500, True, 1200), # 中位数 + # 超出容差 - 不应合并 + (1000, 2000, 3000, 500, False, None), + (1000, 1600, 1000, 500, False, None), # OCR 超出容差 + ]) + def test_multimodal_event_alignment( + self, + asr_ts: int, + ocr_ts: int, + cv_ts: int, + tolerance: int, + expected_merged: bool, + expected_ts: int | None, + ) -> None: + """测试多模态事件对齐""" + events = [ + {"source": "asr", "timestamp_ms": asr_ts, "content": "测试文本"}, + {"source": "ocr", "timestamp_ms": ocr_ts, "content": "字幕内容"}, + {"source": "cv", "timestamp_ms": cv_ts, "content": "product_detected"}, + ] + + # TODO: 实现 TimestampAligner + # aligner = TimestampAligner(tolerance_ms=tolerance) + # result = aligner.align_events(events) + # + # if expected_merged: + # assert len(result.merged_events) == 1 + # assert abs(result.merged_events[0].timestamp_ms - expected_ts) <= 100 + # else: + # # 未合并时,每个事件独立 + # assert len(result.merged_events) == 3 + pytest.skip("待实现:TimestampAligner") + + @pytest.mark.unit + def test_timestamp_normalization_precision(self) -> None: + """ + 测试时间戳归一化精度 + + 验收标准:精度 ≤ 0.1秒 (100ms) + """ + # 不同来源的时间戳格式 + asr_event = {"source": "asr", "timestamp_ms": 1500} # 毫秒 + cv_event = {"source": "cv", "frame": 45, "fps": 30} # 帧号 (45/30 = 1.5秒) + ocr_event = {"source": "ocr", "timestamp_seconds": 1.5} # 秒 + + # TODO: 实现时间戳归一化 + # aligner = TimestampAligner() + # normalized = aligner.normalize_timestamps([asr_event, cv_event, ocr_event]) + # + # # 所有归一化后的时间戳应在 100ms 误差范围内 + # timestamps = [e.timestamp_ms for e in normalized] + # assert max(timestamps) - min(timestamps) <= 100 + pytest.skip("待实现:时间戳归一化") + + @pytest.mark.unit + def test_fuzzy_matching_window(self) -> None: + """ + 测试模糊匹配容差窗口 + + 验收标准:容差 ±0.5秒 + """ + # TODO: 实现模糊匹配 + # aligner = TimestampAligner(tolerance_ms=500) + # + # # 1000ms 和 1499ms 应该匹配(差值 < 500ms) + # assert aligner.is_within_tolerance(1000, 1499) + # + # # 1000ms 和 1501ms 不应匹配(差值 > 500ms) + # assert not aligner.is_within_tolerance(1000, 1501) + pytest.skip("待实现:模糊匹配容差") + + +class TestDurationCalculation: + """ + 时长统计测试 + + 验收标准 (FeatureSummary.md F-45): + - 时长统计误差 ≤ 0.5秒 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("start_ms,end_ms,expected_duration_ms,tolerance_ms", [ + (0, 5000, 5000, 500), + (1000, 6500, 5500, 500), + (0, 10000, 10000, 500), + (500, 3200, 2700, 500), + ]) + def test_duration_calculation_accuracy( + self, + start_ms: int, + end_ms: int, + expected_duration_ms: int, + tolerance_ms: int, + ) -> None: + """测试时长计算准确性 - 误差 ≤ 0.5秒""" + events = [ + {"timestamp_ms": start_ms, "type": "object_appear"}, + {"timestamp_ms": end_ms, "type": "object_disappear"}, + ] + + # TODO: 实现时长计算 + # aligner = TimestampAligner() + # duration = aligner.calculate_duration(events) + # + # assert abs(duration - expected_duration_ms) <= tolerance_ms + pytest.skip("待实现:时长计算") + + @pytest.mark.unit + def test_product_visible_duration( + self, + sample_cv_result: dict[str, Any], + ) -> None: + """测试产品可见时长统计""" + # sample_cv_result 包含 start_frame=30, end_frame=180, fps=30 + # 预期时长: (180-30)/30 = 5 秒 + + # TODO: 实现产品时长统计 + # aligner = TimestampAligner() + # duration = aligner.calculate_object_duration( + # sample_cv_result["detections"], + # object_type="product" + # ) + # + # expected_duration_ms = 5000 + # assert abs(duration - expected_duration_ms) <= 500 + pytest.skip("待实现:产品可见时长统计") + + @pytest.mark.unit + def test_multiple_segments_duration(self) -> None: + """测试多段时长累加""" + # 产品在视频中多次出现 + segments = [ + {"start_ms": 0, "end_ms": 3000}, # 3秒 + {"start_ms": 10000, "end_ms": 12000}, # 2秒 + {"start_ms": 25000, "end_ms": 30000}, # 5秒 + ] + # 总时长应为 10秒 + + # TODO: 实现多段时长累加 + # aligner = TimestampAligner() + # total_duration = aligner.calculate_total_duration(segments) + # + # assert abs(total_duration - 10000) <= 500 + pytest.skip("待实现:多段时长累加") + + +class TestFrequencyCount: + """ + 频次统计测试 + + 验收标准 (FeatureSummary.md F-45): + - 频次统计准确率 ≥ 95% + """ + + @pytest.mark.unit + def test_brand_mention_frequency( + self, + sample_asr_result: dict[str, Any], + ) -> None: + """测试品牌名提及频次统计""" + # TODO: 实现频次统计 + # counter = FrequencyCounter() + # count = counter.count_mentions( + # sample_asr_result["segments"], + # keyword="品牌" + # ) + # + # # 验证统计准确性 + # assert count >= 0 + pytest.skip("待实现:品牌名提及频次") + + @pytest.mark.unit + @pytest.mark.parametrize("text_segments,keyword,expected_count", [ + # 简单情况 + ( + [{"text": "这个品牌真不错"}, {"text": "品牌介绍"}, {"text": "品牌故事"}], + "品牌", + 3 + ), + # 无匹配 + ( + [{"text": "产品介绍"}, {"text": "使用方法"}], + "品牌", + 0 + ), + # 同一句多次出现 + ( + [{"text": "品牌品牌品牌"}], + "品牌", + 3 + ), + ]) + def test_keyword_frequency_accuracy( + self, + text_segments: list[dict[str, str]], + keyword: str, + expected_count: int, + ) -> None: + """测试关键词频次准确性""" + # TODO: 实现频次统计 + # counter = FrequencyCounter() + # count = counter.count_keyword(text_segments, keyword) + # + # assert count == expected_count + pytest.skip("待实现:关键词频次统计") + + @pytest.mark.unit + def test_frequency_count_accuracy_rate(self) -> None: + """ + 测试频次统计准确率 + + 验收标准:准确率 ≥ 95% + """ + # TODO: 使用标注测试集验证 + # test_cases = load_frequency_test_set() + # counter = FrequencyCounter() + # + # correct = 0 + # for case in test_cases: + # count = counter.count_keyword(case["segments"], case["keyword"]) + # if count == case["expected_count"]: + # correct += 1 + # + # accuracy = correct / len(test_cases) + # assert accuracy >= 0.95 + pytest.skip("待实现:频次准确率测试") + + +class TestMultiModalFusion: + """ + 多模态融合测试 + """ + + @pytest.mark.unit + def test_asr_ocr_cv_fusion( + self, + sample_asr_result: dict[str, Any], + sample_ocr_result: dict[str, Any], + sample_cv_result: dict[str, Any], + ) -> None: + """测试 ASR + OCR + CV 三模态融合""" + # TODO: 实现多模态融合 + # aligner = TimestampAligner() + # fused = aligner.fuse_multimodal( + # asr_result=sample_asr_result, + # ocr_result=sample_ocr_result, + # cv_result=sample_cv_result, + # ) + # + # # 验证融合结果包含所有模态 + # assert fused.has_asr + # assert fused.has_ocr + # assert fused.has_cv + # + # # 验证时间轴统一 + # for event in fused.timeline: + # assert event.timestamp_ms is not None + pytest.skip("待实现:多模态融合") + + @pytest.mark.unit + def test_cross_modality_consistency(self) -> None: + """测试跨模态一致性检测""" + # ASR 说"产品名",OCR 显示"产品名",CV 检测到产品 + # 三者应该在时间上一致 + + asr_event = {"source": "asr", "timestamp_ms": 5000, "content": "产品名"} + ocr_event = {"source": "ocr", "timestamp_ms": 5100, "content": "产品名"} + cv_event = {"source": "cv", "timestamp_ms": 5050, "content": "product"} + + # TODO: 实现一致性检测 + # aligner = TimestampAligner(tolerance_ms=500) + # consistency = aligner.check_consistency([asr_event, ocr_event, cv_event]) + # + # assert consistency.is_consistent + # assert consistency.cross_modality_score >= 0.9 + pytest.skip("待实现:跨模态一致性") + + @pytest.mark.unit + def test_handle_missing_modality(self) -> None: + """测试缺失模态处理""" + # 视频无字幕时,OCR 结果为空 + asr_events = [{"source": "asr", "timestamp_ms": 1000, "content": "测试"}] + ocr_events = [] # 无 OCR 结果 + cv_events = [{"source": "cv", "timestamp_ms": 1000, "content": "product"}] + + # TODO: 实现缺失模态处理 + # aligner = TimestampAligner() + # result = aligner.align_events(asr_events + ocr_events + cv_events) + # + # # 应正常处理,不报错 + # assert result.status == "success" + # assert result.missing_modalities == ["ocr"] + pytest.skip("待实现:缺失模态处理") + + +class TestTimestampOutput: + """ + 时间戳输出格式测试 + """ + + @pytest.mark.unit + def test_unified_timeline_format(self) -> None: + """测试统一时间轴输出格式""" + # TODO: 实现时间轴输出 + # aligner = TimestampAligner() + # timeline = aligner.get_unified_timeline(events) + # + # # 验证输出格式 + # for entry in timeline: + # assert "timestamp_seconds" in entry + # assert "multimodal_events" in entry + # assert isinstance(entry["multimodal_events"], list) + pytest.skip("待实现:统一时间轴格式") + + @pytest.mark.unit + def test_violation_with_timestamp(self) -> None: + """测试违规项时间戳标注""" + # TODO: 实现违规时间戳 + # violation = { + # "type": "forbidden_word", + # "content": "最好的", + # "timestamp_start": 5.0, + # "timestamp_end": 5.5, + # } + # + # assert violation["timestamp_end"] > violation["timestamp_start"] + pytest.skip("待实现:违规时间戳") diff --git a/backend/tests/unit/test_validators.py b/backend/tests/unit/test_validators.py new file mode 100644 index 0000000..82dbd12 --- /dev/null +++ b/backend/tests/unit/test_validators.py @@ -0,0 +1,275 @@ +""" +数据验证器单元测试 + +TDD 测试用例 - 验证所有输入数据的格式和约束 +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.utils.validators import ( +# BriefValidator, +# VideoValidator, +# ReviewDecisionValidator, +# TaskValidator, +# ) + + +class TestBriefValidator: + """Brief 数据验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("platform,expected_valid", [ + ("douyin", True), + ("xiaohongshu", True), + ("bilibili", True), + ("kuaishou", True), + ("weibo", False), # 暂不支持 + ("unknown", False), + ("", False), + (None, False), + ]) + def test_platform_validation(self, platform: str | None, expected_valid: bool) -> None: + """测试平台验证""" + # TODO: 实现平台验证 + # validator = BriefValidator() + # result = validator.validate_platform(platform) + # assert result.is_valid == expected_valid + pytest.skip("待实现:平台验证") + + @pytest.mark.unit + @pytest.mark.parametrize("region,expected_valid", [ + ("mainland_china", True), + ("hk_tw", True), + ("overseas", True), + ("unknown", False), + ("", False), + ]) + def test_region_validation(self, region: str, expected_valid: bool) -> None: + """测试区域验证""" + # TODO: 实现区域验证 + # validator = BriefValidator() + # result = validator.validate_region(region) + # assert result.is_valid == expected_valid + pytest.skip("待实现:区域验证") + + @pytest.mark.unit + def test_selling_points_structure(self) -> None: + """测试卖点结构验证""" + valid_selling_points = [ + {"text": "24小时持妆", "priority": "high"}, + {"text": "天然成分", "priority": "medium"}, + ] + + invalid_selling_points = [ + {"text": ""}, # 缺少 priority,文本为空 + "just a string", # 格式错误 + ] + + # TODO: 实现卖点结构验证 + # validator = BriefValidator() + # + # assert validator.validate_selling_points(valid_selling_points).is_valid + # assert not validator.validate_selling_points(invalid_selling_points).is_valid + pytest.skip("待实现:卖点结构验证") + + +class TestVideoValidator: + """视频数据验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("duration_seconds,expected_valid", [ + (30, True), + (60, True), + (300, True), # 5 分钟 + (1800, True), # 30 分钟 - 边界 + (3600, False), # 1 小时 - 可能需要警告 + (0, False), + (-1, False), + ]) + def test_duration_validation(self, duration_seconds: int, expected_valid: bool) -> None: + """测试视频时长验证""" + # TODO: 实现时长验证 + # validator = VideoValidator() + # result = validator.validate_duration(duration_seconds) + # assert result.is_valid == expected_valid + pytest.skip("待实现:时长验证") + + @pytest.mark.unit + @pytest.mark.parametrize("resolution,expected_valid", [ + ("1920x1080", True), # 1080p + ("1080x1920", True), # 竖屏 1080p + ("3840x2160", True), # 4K + ("1280x720", True), # 720p + ("640x480", False), # 480p - 太低 + ("320x240", False), + ]) + def test_resolution_validation(self, resolution: str, expected_valid: bool) -> None: + """测试分辨率验证""" + # TODO: 实现分辨率验证 + # validator = VideoValidator() + # result = validator.validate_resolution(resolution) + # assert result.is_valid == expected_valid + pytest.skip("待实现:分辨率验证") + + +class TestReviewDecisionValidator: + """审核决策验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("decision,expected_valid", [ + ("passed", True), + ("rejected", True), + ("force_passed", True), + ("pending", False), # 无效决策 + ("unknown", False), + ("", False), + ]) + def test_decision_type_validation(self, decision: str, expected_valid: bool) -> None: + """测试决策类型验证""" + # TODO: 实现决策验证 + # validator = ReviewDecisionValidator() + # result = validator.validate_decision_type(decision) + # assert result.is_valid == expected_valid + pytest.skip("待实现:决策类型验证") + + @pytest.mark.unit + def test_force_pass_requires_reason(self) -> None: + """测试强制通过必须填写原因""" + # 强制通过但无原因 + invalid_request = { + "decision": "force_passed", + "force_pass_reason": "", + } + + # 强制通过有原因 + valid_request = { + "decision": "force_passed", + "force_pass_reason": "达人玩的新梗,品牌方认可", + } + + # TODO: 实现强制通过验证 + # validator = ReviewDecisionValidator() + # + # assert not validator.validate(invalid_request).is_valid + # assert "原因" in validator.validate(invalid_request).error_message + # + # assert validator.validate(valid_request).is_valid + pytest.skip("待实现:强制通过原因验证") + + @pytest.mark.unit + def test_rejection_requires_violations(self) -> None: + """测试驳回必须选择违规项""" + # 驳回但无选择违规项 + invalid_request = { + "decision": "rejected", + "selected_violations": [], + } + + # 驳回并选择违规项 + valid_request = { + "decision": "rejected", + "selected_violations": ["violation_001", "violation_002"], + } + + # TODO: 实现驳回验证 + # validator = ReviewDecisionValidator() + # + # assert not validator.validate(invalid_request).is_valid + # assert validator.validate(valid_request).is_valid + pytest.skip("待实现:驳回违规项验证") + + +class TestAppealValidator: + """申诉验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("reason_length,expected_valid", [ + (5, False), # < 10 字 + (9, False), # < 10 字 + (10, True), # = 10 字 + (50, True), # > 10 字 + (500, True), # 长文本 + ]) + def test_appeal_reason_length(self, reason_length: int, expected_valid: bool) -> None: + """测试申诉理由长度 - 必须 ≥ 10 字""" + reason = "字" * reason_length + + # TODO: 实现申诉验证 + # validator = AppealValidator() + # result = validator.validate_reason(reason) + # assert result.is_valid == expected_valid + pytest.skip("待实现:申诉理由长度验证") + + @pytest.mark.unit + def test_appeal_token_check(self) -> None: + """测试申诉令牌检查""" + # TODO: 实现令牌验证 + # validator = AppealValidator() + # + # # 有令牌 + # result = validator.validate_token_available(user_id="user_001") + # assert result.is_valid + # assert result.remaining_tokens > 0 + # + # # 无令牌 + # result = validator.validate_token_available(user_id="user_no_tokens") + # assert not result.is_valid + pytest.skip("待实现:申诉令牌验证") + + +class TestTimestampValidator: + """时间戳验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("timestamp_ms,video_duration_ms,expected_valid", [ + (0, 60000, True), # 开始 + (30000, 60000, True), # 中间 + (60000, 60000, True), # 结束 + (-1, 60000, False), # 负数 + (70000, 60000, False), # 超出视频时长 + ]) + def test_timestamp_range_validation( + self, + timestamp_ms: int, + video_duration_ms: int, + expected_valid: bool, + ) -> None: + """测试时间戳范围验证""" + # TODO: 实现时间戳验证 + # validator = TimestampValidator() + # result = validator.validate_range(timestamp_ms, video_duration_ms) + # assert result.is_valid == expected_valid + pytest.skip("待实现:时间戳范围验证") + + @pytest.mark.unit + def test_timestamp_order_validation(self) -> None: + """测试时间戳顺序验证 - start < end""" + # TODO: 实现顺序验证 + # validator = TimestampValidator() + # + # assert validator.validate_order(start=1000, end=2000).is_valid + # assert not validator.validate_order(start=2000, end=1000).is_valid + # assert not validator.validate_order(start=1000, end=1000).is_valid + pytest.skip("待实现:时间戳顺序验证") + + +class TestUUIDValidator: + """UUID 验证测试""" + + @pytest.mark.unit + @pytest.mark.parametrize("uuid_str,expected_valid", [ + ("550e8400-e29b-41d4-a716-446655440000", True), + ("550E8400-E29B-41D4-A716-446655440000", True), # 大写 + ("not-a-uuid", False), + ("", False), + ("12345", False), + ]) + def test_uuid_format_validation(self, uuid_str: str, expected_valid: bool) -> None: + """测试 UUID 格式验证""" + # TODO: 实现 UUID 验证 + # validator = UUIDValidator() + # result = validator.validate(uuid_str) + # assert result.is_valid == expected_valid + pytest.skip("待实现:UUID 格式验证") diff --git a/backend/tests/unit/test_video_auditor.py b/backend/tests/unit/test_video_auditor.py new file mode 100644 index 0000000..0b1f585 --- /dev/null +++ b/backend/tests/unit/test_video_auditor.py @@ -0,0 +1,411 @@ +""" +视频审核模块单元测试 + +TDD 测试用例 - 基于 FeatureSummary.md (F-10~F-18) 的验收标准 + +验收标准: +- 100MB 视频审核 ≤ 5 分钟 +- 竞品 Logo F1 ≥ 0.85 +- ASR 字错率 ≤ 10% +- OCR 准确率 ≥ 95% +""" + +import pytest +from typing import Any + +# 导入待实现的模块(TDD 红灯阶段) +# from app.services.video_auditor import VideoAuditor, AuditReport + + +class TestVideoUpload: + """ + 视频上传测试 + + 验收标准 (FeatureSummary.md F-10): + - 支持 ≤ 100MB 视频 + - 支持 MP4/MOV 格式 + - 支持断点续传 + """ + + @pytest.mark.unit + @pytest.mark.parametrize("file_size_mb,expected_valid", [ + (50, True), + (100, True), + (101, False), + (200, False), + ]) + def test_file_size_validation(self, file_size_mb: int, expected_valid: bool) -> None: + """测试文件大小验证 - 最大 100MB""" + file_size_bytes = file_size_mb * 1024 * 1024 + + # TODO: 实现文件大小验证 + # validator = VideoFileValidator() + # result = validator.validate_size(file_size_bytes) + # + # assert result.is_valid == expected_valid + # if not expected_valid: + # assert "100MB" in result.error_message + pytest.skip("待实现:文件大小验证") + + @pytest.mark.unit + @pytest.mark.parametrize("file_format,mime_type,expected_valid", [ + ("mp4", "video/mp4", True), + ("mov", "video/quicktime", True), + ("avi", "video/x-msvideo", False), + ("mkv", "video/x-matroska", False), + ("pdf", "application/pdf", False), + ]) + def test_file_format_validation( + self, + file_format: str, + mime_type: str, + expected_valid: bool, + ) -> None: + """测试文件格式验证 - 仅支持 MP4/MOV""" + # TODO: 实现格式验证 + # validator = VideoFileValidator() + # result = validator.validate_format(file_format, mime_type) + # + # assert result.is_valid == expected_valid + pytest.skip("待实现:文件格式验证") + + +class TestASRAccuracy: + """ + ASR 语音识别测试 + + 验收标准 (DevelopmentPlan.md): + - 字错率 (WER) ≤ 10% + """ + + @pytest.mark.unit + def test_asr_output_format(self) -> None: + """测试 ASR 输出格式""" + # TODO: 实现 ASR 服务 + # asr = ASRService() + # result = asr.transcribe("test_audio.wav") + # + # assert "text" in result + # assert "segments" in result + # for segment in result["segments"]: + # assert "word" in segment + # assert "start_ms" in segment + # assert "end_ms" in segment + # assert "confidence" in segment + # assert segment["end_ms"] >= segment["start_ms"] + pytest.skip("待实现:ASR 输出格式") + + @pytest.mark.unit + def test_asr_word_error_rate(self) -> None: + """ + 测试 ASR 字错率 + + 验收标准:WER ≤ 10% + """ + # TODO: 使用标注测试集验证 + # asr = ASRService() + # test_set = load_asr_test_set() # 标注数据集 + # + # total_errors = 0 + # total_words = 0 + # + # for sample in test_set: + # result = asr.transcribe(sample["audio_path"]) + # wer = calculate_wer(result["text"], sample["ground_truth"]) + # total_errors += wer * len(sample["ground_truth"].split()) + # total_words += len(sample["ground_truth"].split()) + # + # overall_wer = total_errors / total_words + # assert overall_wer <= 0.10, f"WER {overall_wer:.2%} 超过阈值 10%" + pytest.skip("待实现:ASR 字错率测试") + + @pytest.mark.unit + def test_asr_timestamp_accuracy(self) -> None: + """测试 ASR 时间戳准确性""" + # TODO: 实现时间戳验证 + # asr = ASRService() + # result = asr.transcribe("test_audio.wav") + # + # # 时间戳应递增 + # prev_end = 0 + # for segment in result["segments"]: + # assert segment["start_ms"] >= prev_end + # prev_end = segment["end_ms"] + pytest.skip("待实现:ASR 时间戳准确性") + + +class TestOCRAccuracy: + """ + OCR 字幕识别测试 + + 验收标准 (DevelopmentPlan.md): + - 准确率 ≥ 95%(含复杂背景) + """ + + @pytest.mark.unit + def test_ocr_output_format(self) -> None: + """测试 OCR 输出格式""" + # TODO: 实现 OCR 服务 + # ocr = OCRService() + # result = ocr.extract_text("video_frame.jpg") + # + # assert "frames" in result + # for frame in result["frames"]: + # assert "timestamp_ms" in frame + # assert "text" in frame + # assert "confidence" in frame + # assert "bbox" in frame + pytest.skip("待实现:OCR 输出格式") + + @pytest.mark.unit + def test_ocr_accuracy_rate(self) -> None: + """ + 测试 OCR 准确率 + + 验收标准:准确率 ≥ 95% + """ + # TODO: 使用标注测试集验证 + # ocr = OCRService() + # test_set = load_ocr_test_set() + # + # correct = 0 + # for sample in test_set: + # result = ocr.extract_text(sample["image_path"]) + # if result["text"] == sample["ground_truth"]: + # correct += 1 + # + # accuracy = correct / len(test_set) + # assert accuracy >= 0.95, f"准确率 {accuracy:.2%} 低于阈值 95%" + pytest.skip("待实现:OCR 准确率测试") + + @pytest.mark.unit + def test_ocr_complex_background(self) -> None: + """测试复杂背景下的 OCR""" + # TODO: 测试复杂背景 + # ocr = OCRService() + # + # # 测试不同背景复杂度 + # test_cases = [ + # {"image": "simple_bg.jpg", "text": "测试文字"}, + # {"image": "complex_bg.jpg", "text": "复杂背景"}, + # {"image": "gradient_bg.jpg", "text": "渐变背景"}, + # ] + # + # for case in test_cases: + # result = ocr.extract_text(case["image"]) + # assert result["text"] == case["text"] + pytest.skip("待实现:复杂背景 OCR") + + +class TestLogoDetection: + """ + 竞品 Logo 检测测试 + + 验收标准 (FeatureSummary.md F-12): + - F1 ≥ 0.85(含遮挡 30% 场景) + """ + + @pytest.mark.unit + def test_logo_detection_output_format(self) -> None: + """测试 Logo 检测输出格式""" + # TODO: 实现 Logo 检测服务 + # detector = LogoDetector() + # result = detector.detect("video_frame.jpg") + # + # assert "detections" in result + # for detection in result["detections"]: + # assert "logo_id" in detection + # assert "confidence" in detection + # assert "bbox" in detection + # assert detection["confidence"] >= 0 and detection["confidence"] <= 1 + pytest.skip("待实现:Logo 检测输出格式") + + @pytest.mark.unit + def test_logo_detection_f1_score(self) -> None: + """ + 测试 Logo 检测 F1 值 + + 验收标准:F1 ≥ 0.85 + """ + # TODO: 使用标注测试集验证 + # detector = LogoDetector() + # test_set = load_logo_test_set() # ≥ 200 张图片 + # + # predictions = [] + # ground_truths = [] + # + # for sample in test_set: + # result = detector.detect(sample["image_path"]) + # predictions.append(result["detections"]) + # ground_truths.append(sample["ground_truth_logos"]) + # + # f1 = calculate_f1(predictions, ground_truths) + # assert f1 >= 0.85, f"F1 {f1:.2f} 低于阈值 0.85" + pytest.skip("待实现:Logo F1 测试") + + @pytest.mark.unit + def test_logo_detection_with_occlusion(self) -> None: + """ + 测试遮挡场景下的 Logo 检测 + + 验收标准:30% 遮挡仍可检测 + """ + # TODO: 测试遮挡场景 + # detector = LogoDetector() + # + # # 30% 遮挡的 Logo 图片 + # result = detector.detect("logo_30_percent_occluded.jpg") + # + # assert len(result["detections"]) > 0 + # assert result["detections"][0]["confidence"] >= 0.7 + pytest.skip("待实现:遮挡场景 Logo 检测") + + @pytest.mark.unit + def test_new_logo_instant_effect(self) -> None: + """测试新 Logo 上传即刻生效""" + # TODO: 测试动态添加 Logo + # detector = LogoDetector() + # + # # 上传新 Logo + # detector.add_logo("new_competitor_logo.png", brand="New Competitor") + # + # # 立即测试检测 + # result = detector.detect("frame_with_new_logo.jpg") + # assert any(d["brand"] == "New Competitor" for d in result["detections"]) + pytest.skip("待实现:Logo 动态添加") + + +class TestAuditPipeline: + """ + 审核流水线集成测试 + """ + + @pytest.mark.unit + def test_audit_processing_time(self) -> None: + """ + 测试审核处理时间 + + 验收标准:100MB 视频 ≤ 5 分钟 + """ + # TODO: 实现处理时间测试 + # import time + # + # auditor = VideoAuditor() + # start_time = time.time() + # + # result = auditor.audit("100mb_test_video.mp4") + # + # processing_time = time.time() - start_time + # assert processing_time <= 300, f"处理时间 {processing_time:.1f}s 超过 5 分钟" + pytest.skip("待实现:处理时间测试") + + @pytest.mark.unit + def test_audit_report_structure(self) -> None: + """测试审核报告结构""" + # TODO: 实现报告结构验证 + # auditor = VideoAuditor() + # report = auditor.audit("test_video.mp4") + # + # # 验证报告必需字段 + # required_fields = [ + # "report_id", "video_id", "processing_status", + # "asr_results", "ocr_results", "cv_results", + # "violations", "brief_compliance" + # ] + # for field in required_fields: + # assert field in report + pytest.skip("待实现:报告结构验证") + + @pytest.mark.unit + def test_violation_with_evidence(self) -> None: + """测试违规项包含证据""" + # TODO: 实现证据验证 + # auditor = VideoAuditor() + # report = auditor.audit("video_with_violation.mp4") + # + # for violation in report["violations"]: + # assert "evidence" in violation + # assert violation["evidence"]["url"] is not None + # assert violation["evidence"]["timestamp_start"] is not None + pytest.skip("待实现:违规证据") + + +class TestBriefCompliance: + """ + Brief 合规检查测试 + + 验收标准 (FeatureSummary.md F-45): + - 时长统计误差 ≤ 0.5秒 + - 频次统计准确率 ≥ 95% + """ + + @pytest.mark.unit + def test_selling_point_coverage( + self, + sample_brief_rules: dict[str, Any], + ) -> None: + """测试卖点覆盖检测""" + video_content = { + "asr_text": "24小时持妆效果非常好,使用天然成分", + "ocr_text": "24小时持妆", + } + + # TODO: 实现卖点覆盖检测 + # checker = BriefComplianceChecker() + # result = checker.check_selling_points( + # video_content, + # sample_brief_rules["selling_points"] + # ) + # + # # 应检测到 2/3 卖点覆盖 + # assert result["coverage_rate"] >= 0.66 + # assert "24小时持妆" in result["detected"] + # assert "天然成分" in result["detected"] + pytest.skip("待实现:卖点覆盖检测") + + @pytest.mark.unit + def test_duration_requirement_check( + self, + sample_brief_rules: dict[str, Any], + ) -> None: + """测试时长要求检查""" + cv_detections = [ + {"object_type": "product", "start_ms": 0, "end_ms": 6000}, # 6秒 + ] + + # 要求: 产品同框 > 5秒 + # TODO: 实现时长检查 + # checker = BriefComplianceChecker() + # result = checker.check_duration( + # cv_detections, + # sample_brief_rules["timing_requirements"] + # ) + # + # assert result["product_visible"]["status"] == "passed" + # assert result["product_visible"]["detected_seconds"] == 6.0 + pytest.skip("待实现:时长要求检查") + + @pytest.mark.unit + def test_frequency_requirement_check( + self, + sample_brief_rules: dict[str, Any], + ) -> None: + """测试频次要求检查""" + asr_segments = [ + {"text": "品牌名产品"}, + {"text": "这个品牌名很好"}, + {"text": "推荐品牌名"}, + ] + + # 要求: 品牌名提及 ≥ 3次 + # TODO: 实现频次检查 + # checker = BriefComplianceChecker() + # result = checker.check_frequency( + # asr_segments, + # sample_brief_rules["timing_requirements"], + # brand_keyword="品牌名" + # ) + # + # assert result["brand_mention"]["status"] == "passed" + # assert result["brand_mention"]["detected_count"] == 3 + pytest.skip("待实现:频次要求检查") diff --git a/frontend/e2e/playwright.config.ts b/frontend/e2e/playwright.config.ts new file mode 100644 index 0000000..ac11888 --- /dev/null +++ b/frontend/e2e/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright E2E 测试配置 + * + * 用于端到端测试,覆盖关键用户流程 + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { open: 'never' }], + ['json', { outputFile: 'test-results/results.json' }], + ], + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + // 移动端测试 + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + ], + // 启动开发服务器 + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}) diff --git a/frontend/e2e/tests/appeal.spec.ts b/frontend/e2e/tests/appeal.spec.ts new file mode 100644 index 0000000..556a64c --- /dev/null +++ b/frontend/e2e/tests/appeal.spec.ts @@ -0,0 +1,210 @@ +/** + * 申诉流程 E2E 测试 + * + * TDD 测试用例 - 测试达人申诉和审核员处理申诉的流程 + * + * 用户流程参考:User_Role_Interfaces.md + */ + +import { test, expect } from '@playwright/test' + +test.describe('Creator Appeal Flow', () => { + test.beforeEach(async ({ page }) => { + // 以达人身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('creator@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // await page.waitForURL('/dashboard') + }) + + test.skip('should display appeal tokens', async ({ page }) => { + // await page.goto('/dashboard') + // + // // 验证显示申诉令牌数量 + // await expect(page.getByTestId('appeal-tokens')).toBeVisible() + // await expect(page.getByText('剩余申诉次数:3')).toBeVisible() + }) + + test.skip('should open appeal form from rejected video', async ({ page }) => { + // await page.goto('/videos/video_rejected') + // + // // 验证显示驳回状态 + // await expect(page.getByText('已驳回')).toBeVisible() + // + // // 验证显示申诉按钮 + // await expect(page.getByRole('button', { name: '申诉' })).toBeVisible() + // + // // 点击申诉 + // await page.getByRole('button', { name: '申诉' }).click() + // + // // 验证申诉表单 + // await expect(page.getByTestId('appeal-form')).toBeVisible() + }) + + test.skip('should select violations to appeal', async ({ page }) => { + // await page.goto('/videos/video_rejected') + // await page.getByRole('button', { name: '申诉' }).click() + // + // // 显示违规列表 + // const violationList = page.getByTestId('appeal-violation-list') + // await expect(violationList).toBeVisible() + // + // // 选择要申诉的违规项 + // await violationList.getByRole('checkbox').first().click() + // + // // 验证已选择 + // await expect(page.getByText('已选择 1 项')).toBeVisible() + }) + + test.skip('should require appeal reason >= 10 characters', async ({ page }) => { + // await page.goto('/videos/video_rejected') + // await page.getByRole('button', { name: '申诉' }).click() + // + // // 选择违规项 + // await page.getByTestId('appeal-violation-list').getByRole('checkbox').first().click() + // + // // 输入过短的理由 + // await page.getByPlaceholder('请输入申诉理由').fill('太短了') + // + // // 尝试提交 + // await page.getByRole('button', { name: '提交申诉' }).click() + // + // // 验证错误提示 + // await expect(page.getByText('申诉理由至少 10 个字')).toBeVisible() + }) + + test.skip('should submit appeal successfully', async ({ page }) => { + // await page.goto('/videos/video_rejected') + // await page.getByRole('button', { name: '申诉' }).click() + // + // // 选择违规项 + // await page.getByTestId('appeal-violation-list').getByRole('checkbox').first().click() + // + // // 输入申诉理由 + // await page.getByPlaceholder('请输入申诉理由').fill('这个词语在此语境下是正常使用,表达的是个人主观感受,不应被判定为违规广告语') + // + // // 提交申诉 + // await page.getByRole('button', { name: '提交申诉' }).click() + // + // // 验证成功 + // await expect(page.getByText('申诉已提交')).toBeVisible() + // await expect(page.getByText('申诉中')).toBeVisible() + }) + + test.skip('should deduct appeal token on submit', async ({ page }) => { + // // 获取当前令牌数 + // await page.goto('/dashboard') + // const initialTokens = await page.getByTestId('appeal-tokens').textContent() + // + // // 提交申诉 + // await page.goto('/videos/video_rejected') + // await page.getByRole('button', { name: '申诉' }).click() + // await page.getByTestId('appeal-violation-list').getByRole('checkbox').first().click() + // await page.getByPlaceholder('请输入申诉理由').fill('这个词语在此语境下是正常使用,不应被判定为违规') + // await page.getByRole('button', { name: '提交申诉' }).click() + // + // // 验证令牌已扣除 + // await page.goto('/dashboard') + // const newTokens = await page.getByTestId('appeal-tokens').textContent() + // expect(parseInt(newTokens!)).toBe(parseInt(initialTokens!) - 1) + }) + + test.skip('should show error when no tokens available', async ({ page }) => { + // // 假设用户无令牌 + // await page.goto('/videos/video_rejected') + // + // // 点击申诉按钮 + // await page.getByRole('button', { name: '申诉' }).click() + // + // // 验证提示无令牌 + // await expect(page.getByText('申诉次数已用完')).toBeVisible() + // await expect(page.getByText('联系管理员')).toBeVisible() + }) +}) + +test.describe('Reviewer Process Appeal', () => { + test.beforeEach(async ({ page }) => { + // 以 Agency 审核员身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('agency@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // await page.waitForURL('/dashboard') + }) + + test.skip('should display appeal list', async ({ page }) => { + // await page.goto('/appeals') + // + // await expect(page.getByRole('heading', { name: '申诉处理' })).toBeVisible() + // await expect(page.getByTestId('appeal-list')).toBeVisible() + }) + + test.skip('should show appeal details', async ({ page }) => { + // await page.goto('/appeals/appeal_001') + // + // // 验证显示申诉信息 + // await expect(page.getByTestId('appeal-reason')).toBeVisible() + // await expect(page.getByTestId('original-violation')).toBeVisible() + // await expect(page.getByTestId('video-preview')).toBeVisible() + }) + + test.skip('should approve appeal', async ({ page }) => { + // await page.goto('/appeals/appeal_001') + // + // // 点击通过申诉 + // await page.getByRole('button', { name: '申诉成立' }).click() + // + // // 填写处理意见 + // await page.getByPlaceholder('处理意见').fill('申诉理由成立') + // + // // 确认 + // await page.getByRole('button', { name: '确认' }).click() + // + // // 验证成功 + // await expect(page.getByText('申诉已处理')).toBeVisible() + }) + + test.skip('should reject appeal', async ({ page }) => { + // await page.goto('/appeals/appeal_001') + // + // // 点击驳回申诉 + // await page.getByRole('button', { name: '申诉不成立' }).click() + // + // // 填写处理意见 + // await page.getByPlaceholder('处理意见').fill('违规判定正确') + // + // // 确认 + // await page.getByRole('button', { name: '确认' }).click() + // + // // 验证成功 + // await expect(page.getByText('申诉已处理')).toBeVisible() + }) + + test.skip('should restore token on appeal approval', async ({ page }) => { + // // 审批通过申诉后,达人的令牌应该返还 + // // 这个测试需要跨用户验证,可能需要特殊处理 + }) +}) + +test.describe('Appeal Status Tracking', () => { + test.skip('should show appeal status in video list', async ({ page }) => { + // await page.goto('/videos') + // + // // 找到正在申诉的视频 + // const appealingVideo = page.getByTestId('video-item').filter({ hasText: '申诉中' }) + // await expect(appealingVideo).toBeVisible() + // + // // 验证显示申诉状态徽章 + // await expect(appealingVideo.getByTestId('appeal-badge')).toBeVisible() + }) + + test.skip('should notify on appeal result', async ({ page }) => { + // await page.goto('/dashboard') + // + // // 验证通知中心显示申诉结果 + // await page.getByRole('button', { name: '通知' }).click() + // + // await expect(page.getByText('申诉结果')).toBeVisible() + }) +}) diff --git a/frontend/e2e/tests/auth.spec.ts b/frontend/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..aba08a7 --- /dev/null +++ b/frontend/e2e/tests/auth.spec.ts @@ -0,0 +1,119 @@ +/** + * 认证流程 E2E 测试 + * + * TDD 测试用例 - 测试登录、登出、权限验证 + */ + +import { test, expect } from '@playwright/test' + +test.describe('Authentication', () => { + test.skip('should display login page', async ({ page }) => { + // await page.goto('/login') + // + // await expect(page.getByRole('heading', { name: '登录' })).toBeVisible() + // await expect(page.getByPlaceholder('邮箱')).toBeVisible() + // await expect(page.getByPlaceholder('密码')).toBeVisible() + // await expect(page.getByRole('button', { name: '登录' })).toBeVisible() + }) + + test.skip('should login with valid credentials', async ({ page }) => { + // await page.goto('/login') + // + // await page.getByPlaceholder('邮箱').fill('test@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 登录成功后跳转到首页 + // await expect(page).toHaveURL('/dashboard') + // await expect(page.getByText('欢迎回来')).toBeVisible() + }) + + test.skip('should show error for invalid credentials', async ({ page }) => { + // await page.goto('/login') + // + // await page.getByPlaceholder('邮箱').fill('wrong@example.com') + // await page.getByPlaceholder('密码').fill('wrongpassword') + // await page.getByRole('button', { name: '登录' }).click() + // + // await expect(page.getByText('邮箱或密码错误')).toBeVisible() + // await expect(page).toHaveURL('/login') + }) + + test.skip('should logout successfully', async ({ page }) => { + // // 先登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('test@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 点击登出 + // await page.getByRole('button', { name: /用户菜单/ }).click() + // await page.getByRole('menuitem', { name: '退出登录' }).click() + // + // // 验证跳转到登录页 + // await expect(page).toHaveURL('/login') + }) + + test.skip('should redirect unauthenticated users to login', async ({ page }) => { + // await page.goto('/dashboard') + // + // // 未登录用户应被重定向到登录页 + // await expect(page).toHaveURL('/login?redirect=/dashboard') + }) + + test.skip('should redirect to original page after login', async ({ page }) => { + // // 尝试访问受保护页面 + // await page.goto('/videos/video_001') + // + // // 被重定向到登录页 + // await expect(page).toHaveURL(/\/login.*redirect/) + // + // // 登录 + // await page.getByPlaceholder('邮箱').fill('test@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 登录后应跳转到原来的页面 + // await expect(page).toHaveURL('/videos/video_001') + }) +}) + +test.describe('Role-based Access', () => { + test.skip('creator should see creator-specific menu', async ({ page }) => { + // // 以达人身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('creator@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 验证达人菜单 + // await expect(page.getByRole('link', { name: '我的视频' })).toBeVisible() + // await expect(page.getByRole('link', { name: '提交视频' })).toBeVisible() + // // 不应看到管理功能 + // await expect(page.getByRole('link', { name: '用户管理' })).not.toBeVisible() + }) + + test.skip('agency should see review options', async ({ page }) => { + // // 以 Agency 身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('agency@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 验证 Agency 菜单 + // await expect(page.getByRole('link', { name: '待审核' })).toBeVisible() + // await expect(page.getByRole('link', { name: '任务管理' })).toBeVisible() + }) + + test.skip('admin should see all menu items', async ({ page }) => { + // // 以管理员身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('admin@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // + // // 验证管理员菜单 + // await expect(page.getByRole('link', { name: '用户管理' })).toBeVisible() + // await expect(page.getByRole('link', { name: '系统设置' })).toBeVisible() + }) +}) diff --git a/frontend/e2e/tests/video-review.spec.ts b/frontend/e2e/tests/video-review.spec.ts new file mode 100644 index 0000000..14d47ce --- /dev/null +++ b/frontend/e2e/tests/video-review.spec.ts @@ -0,0 +1,217 @@ +/** + * 视频审核流程 E2E 测试 + * + * TDD 测试用例 - 测试完整的视频审核用户流程 + * + * 用户流程参考:User_Role_Interfaces.md + */ + +import { test, expect } from '@playwright/test' + +test.describe('Video Review Flow', () => { + test.beforeEach(async ({ page }) => { + // 以 Agency 审核员身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('agency@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // await page.waitForURL('/dashboard') + }) + + test.skip('should display pending review list', async ({ page }) => { + // await page.goto('/reviews/pending') + // + // // 验证列表显示 + // await expect(page.getByRole('heading', { name: '待审核视频' })).toBeVisible() + // await expect(page.getByTestId('video-list')).toBeVisible() + // + // // 验证列表项 + // const videos = page.getByTestId('video-item') + // await expect(videos).toHaveCount.greaterThan(0) + }) + + test.skip('should open video review page', async ({ page }) => { + // await page.goto('/reviews/pending') + // + // // 点击第一个视频 + // await page.getByTestId('video-item').first().click() + // + // // 验证审核页面 + // await expect(page.getByTestId('video-player')).toBeVisible() + // await expect(page.getByTestId('violation-list')).toBeVisible() + // await expect(page.getByTestId('brief-compliance')).toBeVisible() + }) + + test.skip('should play video and seek to violation', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 找到违规项 + // const violation = page.getByTestId('violation-item').first() + // await expect(violation).toBeVisible() + // + // // 点击时间戳跳转 + // await violation.getByTestId('timestamp-link').click() + // + // // 验证视频跳转到对应时间 + // const currentTime = page.getByTestId('current-time') + // await expect(currentTime).toContainText('00:05') + }) + + test.skip('should show violation evidence screenshot', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 找到 Logo 违规项 + // const logoViolation = page.getByText('竞品 Logo').locator('..') + // + // // 点击查看证据 + // await logoViolation.getByRole('button', { name: '查看证据' }).click() + // + // // 验证截图显示 + // await expect(page.getByTestId('evidence-modal')).toBeVisible() + // await expect(page.getByRole('img', { name: '违规截图' })).toBeVisible() + }) + + test.skip('should pass video review', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 点击通过按钮 + // await page.getByRole('button', { name: '通过' }).click() + // + // // 填写评语(可选) + // await page.getByPlaceholder('审核评语').fill('内容符合要求') + // + // // 确认提交 + // await page.getByRole('button', { name: '确认通过' }).click() + // + // // 验证成功提示 + // await expect(page.getByText('审核完成')).toBeVisible() + // + // // 验证跳转到下一个待审核视频或列表 + // await expect(page).toHaveURL(/\/reviews/) + }) + + test.skip('should reject video with selected violations', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 选择违规项 + // await page.getByTestId('violation-checkbox').first().click() + // await page.getByTestId('violation-checkbox').nth(1).click() + // + // // 点击驳回按钮 + // await page.getByRole('button', { name: '驳回' }).click() + // + // // 验证显示已选违规项 + // const modal = page.getByTestId('reject-modal') + // await expect(modal).toBeVisible() + // await expect(modal.getByText('已选择 2 项违规')).toBeVisible() + // + // // 填写评语 + // await modal.getByPlaceholder('审核评语').fill('存在违规内容,请修改') + // + // // 确认驳回 + // await modal.getByRole('button', { name: '确认驳回' }).click() + // + // // 验证成功提示 + // await expect(page.getByText('已驳回')).toBeVisible() + }) + + test.skip('should force pass with reason', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 点击强制通过 + // await page.getByRole('button', { name: '强制通过' }).click() + // + // // 验证需要填写原因 + // const modal = page.getByTestId('force-pass-modal') + // await expect(modal).toBeVisible() + // + // // 尝试不填原因提交 + // await modal.getByRole('button', { name: '确认' }).click() + // await expect(modal.getByText('请填写强制通过原因')).toBeVisible() + // + // // 填写原因 + // await modal.getByPlaceholder('请填写原因').fill('达人玩的新梗,品牌方认可') + // await modal.getByRole('button', { name: '确认' }).click() + // + // // 验证成功 + // await expect(page.getByText('强制通过成功')).toBeVisible() + }) + + test.skip('should add manual violation', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 点击添加违规 + // await page.getByRole('button', { name: '添加违规项' }).click() + // + // // 填写违规信息 + // const modal = page.getByTestId('add-violation-modal') + // await modal.getByLabel('违规类型').selectOption('other') + // await modal.getByPlaceholder('违规内容').fill('发现额外问题') + // await modal.getByLabel('开始时间').fill('00:10') + // await modal.getByLabel('结束时间').fill('00:15') + // await modal.getByLabel('严重程度').selectOption('medium') + // + // // 提交 + // await modal.getByRole('button', { name: '添加' }).click() + // + // // 验证违规项已添加 + // await expect(page.getByText('发现额外问题')).toBeVisible() + // await expect(page.getByTestId('violation-item').filter({ hasText: '手动添加' })).toBeVisible() + }) + + test.skip('should delete AI violation', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 找到 AI 检测的违规项 + // const aiViolation = page.getByTestId('violation-item').filter({ hasText: 'AI 检测' }).first() + // + // // 点击删除 + // await aiViolation.getByRole('button', { name: '删除' }).click() + // + // // 确认删除 + // const confirmModal = page.getByTestId('confirm-modal') + // await confirmModal.getByPlaceholder('删除原因').fill('误检') + // await confirmModal.getByRole('button', { name: '确认删除' }).click() + // + // // 验证违规项已删除 + // await expect(aiViolation).not.toBeVisible() + }) +}) + +test.describe('Brief Compliance Check', () => { + test.skip('should display brief compliance status', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 验证 Brief 合规面板 + // const compliancePanel = page.getByTestId('brief-compliance') + // await expect(compliancePanel).toBeVisible() + // + // // 验证卖点覆盖 + // await expect(compliancePanel.getByText('卖点覆盖')).toBeVisible() + // await expect(compliancePanel.getByText('2/3')).toBeVisible() + // + // // 验证时长要求 + // await expect(compliancePanel.getByText('产品同框')).toBeVisible() + }) + + test.skip('should show uncovered selling points', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 点击查看详情 + // await page.getByTestId('brief-compliance').getByRole('button', { name: '查看详情' }).click() + // + // // 验证显示未覆盖的卖点 + // const detailModal = page.getByTestId('compliance-detail-modal') + // await expect(detailModal.getByText('未覆盖')).toBeVisible() + // await expect(detailModal.getByText('敏感肌适用')).toBeVisible() + }) + + test.skip('should highlight timing requirement failures', async ({ page }) => { + // await page.goto('/reviews/video_001') + // + // // 验证不合规的时长要求显示为红色 + // const timingRequirement = page.getByTestId('timing-requirement').filter({ hasText: '品牌名提及' }) + // await expect(timingRequirement).toHaveClass(/text-red/) + // await expect(timingRequirement.getByText('2/3')).toBeVisible() // 未达标 + }) +}) diff --git a/frontend/e2e/tests/video-upload.spec.ts b/frontend/e2e/tests/video-upload.spec.ts new file mode 100644 index 0000000..22009c6 --- /dev/null +++ b/frontend/e2e/tests/video-upload.spec.ts @@ -0,0 +1,201 @@ +/** + * 视频上传流程 E2E 测试 + * + * TDD 测试用例 - 测试达人上传视频的完整流程 + * + * 用户流程参考:User_Role_Interfaces.md + */ + +import { test, expect } from '@playwright/test' +import path from 'path' + +test.describe('Video Upload Flow', () => { + test.beforeEach(async ({ page }) => { + // 以达人身份登录 + // await page.goto('/login') + // await page.getByPlaceholder('邮箱').fill('creator@example.com') + // await page.getByPlaceholder('密码').fill('password123') + // await page.getByRole('button', { name: '登录' }).click() + // await page.waitForURL('/dashboard') + }) + + test.skip('should display upload page', async ({ page }) => { + // await page.goto('/videos/upload') + // + // await expect(page.getByRole('heading', { name: '上传视频' })).toBeVisible() + // await expect(page.getByTestId('upload-dropzone')).toBeVisible() + // await expect(page.getByText('支持 MP4、MOV 格式')).toBeVisible() + // await expect(page.getByText('最大 100MB')).toBeVisible() + }) + + test.skip('should select task before upload', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 验证需要先选择任务 + // await expect(page.getByLabel('选择任务')).toBeVisible() + // + // // 选择任务 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option', { name: 'XX美妆产品推广' }).click() + // + // // 验证任务信息显示 + // await expect(page.getByText('Brief 要求')).toBeVisible() + }) + + test.skip('should upload video file', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 选择任务 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option').first().click() + // + // // 上传文件 + // const fileInput = page.getByTestId('file-input') + // await fileInput.setInputFiles(path.join(__dirname, '../fixtures/sample-video.mp4')) + // + // // 验证上传进度 + // await expect(page.getByTestId('upload-progress')).toBeVisible() + // await expect(page.getByText(/上传中/)).toBeVisible() + // + // // 等待上传完成 + // await expect(page.getByText('上传成功')).toBeVisible({ timeout: 60000 }) + }) + + test.skip('should show validation error for unsupported format', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 选择任务 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option').first().click() + // + // // 上传不支持的格式 + // const fileInput = page.getByTestId('file-input') + // await fileInput.setInputFiles(path.join(__dirname, '../fixtures/sample.avi')) + // + // // 验证错误提示 + // await expect(page.getByText('不支持的文件格式')).toBeVisible() + // await expect(page.getByText('仅支持 MP4、MOV')).toBeVisible() + }) + + test.skip('should show error for oversized file', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 选择任务 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option').first().click() + // + // // 尝试上传超大文件(模拟) + // // 由于无法真正创建超大文件,这里验证前端校验逻辑 + // + // // 假设通过 JavaScript 注入一个超大文件 + // await page.evaluate(() => { + // const file = new File(['x'.repeat(101 * 1024 * 1024)], 'large.mp4', { type: 'video/mp4' }) + // const event = new Event('change', { bubbles: true }) + // const input = document.querySelector('[data-testid="file-input"]') as HTMLInputElement + // Object.defineProperty(input, 'files', { value: [file] }) + // input.dispatchEvent(event) + // }) + // + // await expect(page.getByText('文件大小超过限制')).toBeVisible() + // await expect(page.getByText('最大 100MB')).toBeVisible() + }) + + test.skip('should show processing status after upload', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 选择任务并上传 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option').first().click() + // + // const fileInput = page.getByTestId('file-input') + // await fileInput.setInputFiles(path.join(__dirname, '../fixtures/sample-video.mp4')) + // + // // 等待上传完成 + // await expect(page.getByText('上传成功')).toBeVisible({ timeout: 60000 }) + // + // // 验证显示处理状态 + // await expect(page.getByText('AI 审核中')).toBeVisible() + // await expect(page.getByTestId('processing-progress')).toBeVisible() + }) + + test.skip('should navigate to video detail after processing', async ({ page }) => { + // // 假设视频已上传并处理完成 + // await page.goto('/videos/video_new') + // + // // 验证显示审核结果 + // await expect(page.getByTestId('audit-result')).toBeVisible() + // await expect(page.getByText('AI 检测完成')).toBeVisible() + }) +}) + +test.describe('Drag and Drop Upload', () => { + test.skip('should highlight dropzone on drag over', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 模拟拖拽进入 + // const dropzone = page.getByTestId('upload-dropzone') + // + // // 触发 dragenter 事件 + // await dropzone.dispatchEvent('dragenter', { + // dataTransfer: { types: ['Files'] }, + // }) + // + // // 验证高亮状态 + // await expect(dropzone).toHaveClass(/border-primary/) + }) + + test.skip('should remove highlight on drag leave', async ({ page }) => { + // await page.goto('/videos/upload') + // + // const dropzone = page.getByTestId('upload-dropzone') + // + // // 触发 dragenter + // await dropzone.dispatchEvent('dragenter', { + // dataTransfer: { types: ['Files'] }, + // }) + // + // // 触发 dragleave + // await dropzone.dispatchEvent('dragleave') + // + // // 验证高亮已移除 + // await expect(dropzone).not.toHaveClass(/border-primary/) + }) +}) + +test.describe('Resumable Upload', () => { + test.skip('should resume interrupted upload', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 选择任务 + // await page.getByLabel('选择任务').click() + // await page.getByRole('option').first().click() + // + // // 开始上传 + // const fileInput = page.getByTestId('file-input') + // await fileInput.setInputFiles(path.join(__dirname, '../fixtures/sample-video.mp4')) + // + // // 等待开始上传 + // await expect(page.getByTestId('upload-progress')).toBeVisible() + // + // // 模拟中断(刷新页面) + // await page.reload() + // + // // 验证显示恢复上传选项 + // await expect(page.getByText('检测到未完成的上传')).toBeVisible() + // await expect(page.getByRole('button', { name: '继续上传' })).toBeVisible() + // + // // 点击继续上传 + // await page.getByRole('button', { name: '继续上传' }).click() + // + // // 验证从中断处继续 + // await expect(page.getByTestId('upload-progress')).toBeVisible() + }) + + test.skip('should allow canceling pending upload', async ({ page }) => { + // await page.goto('/videos/upload') + // + // // 如果有未完成的上传 + // // await page.getByRole('button', { name: '取消' }).click() + // // await expect(page.getByText('已取消上传')).toBeVisible() + }) +}) diff --git a/frontend/src/components/review/ViolationList.test.tsx b/frontend/src/components/review/ViolationList.test.tsx new file mode 100644 index 0000000..2eea105 --- /dev/null +++ b/frontend/src/components/review/ViolationList.test.tsx @@ -0,0 +1,268 @@ +/** + * ViolationList 组件单元测试 + * + * TDD 测试用例 - 测试违规项列表组件 + * + * UI 规范参考:UIDesign.md 审核界面 + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +// import { ViolationList } from './ViolationList' + +describe('ViolationList', () => { + const mockViolations = [ + { + id: 'vio_001', + type: 'prohibited_word', + content: '最好的', + timestamp_start: 5.0, + timestamp_end: 5.5, + severity: 'high', + source: 'ai', + context: '这是最好的产品', + }, + { + id: 'vio_002', + type: 'competitor_logo', + content: 'CompetitorBrand', + timestamp_start: 10.0, + timestamp_end: 15.0, + severity: 'medium', + source: 'ai', + screenshot_url: 'https://example.com/screenshot.jpg', + }, + { + id: 'vio_003', + type: 'brand_tone', + content: '表达过于生硬', + timestamp_start: 20.0, + timestamp_end: 25.0, + severity: 'low', + source: 'manual', + }, + ] + + it.skip('should render all violations', () => { + // render() + // + // expect(screen.getAllByTestId('violation-item')).toHaveLength(3) + }) + + it.skip('should display violation type label', () => { + // render() + // + // expect(screen.getByText('禁用词')).toBeInTheDocument() + // expect(screen.getByText('竞品 Logo')).toBeInTheDocument() + // expect(screen.getByText('品牌调性')).toBeInTheDocument() + }) + + it.skip('should display violation content', () => { + // render() + // + // expect(screen.getByText('最好的')).toBeInTheDocument() + // expect(screen.getByText('CompetitorBrand')).toBeInTheDocument() + }) + + it.skip('should display timestamp', () => { + // render() + // + // expect(screen.getByText('00:05 - 00:05')).toBeInTheDocument() + // expect(screen.getByText('00:10 - 00:15')).toBeInTheDocument() + }) + + it.skip('should show severity badge with correct color', () => { + // render() + // + // const items = screen.getAllByTestId('violation-item') + // + // expect(within(items[0]).getByTestId('severity-badge')).toHaveClass('bg-red-500') + // expect(within(items[1]).getByTestId('severity-badge')).toHaveClass('bg-orange-500') + // expect(within(items[2]).getByTestId('severity-badge')).toHaveClass('bg-yellow-500') + }) + + describe('selection', () => { + it.skip('should allow selecting violations', () => { + // const onSelectionChange = vi.fn() + // render( + // + // ) + // + // const checkbox = screen.getAllByRole('checkbox')[0] + // fireEvent.click(checkbox) + // + // expect(onSelectionChange).toHaveBeenCalledWith(['vio_001']) + }) + + it.skip('should support select all', () => { + // const onSelectionChange = vi.fn() + // render( + // + // ) + // + // const selectAllCheckbox = screen.getByRole('checkbox', { name: /全选/ }) + // fireEvent.click(selectAllCheckbox) + // + // expect(onSelectionChange).toHaveBeenCalledWith(['vio_001', 'vio_002', 'vio_003']) + }) + + it.skip('should show indeterminate state when partially selected', () => { + // render( + // + // ) + // + // const selectAllCheckbox = screen.getByRole('checkbox', { name: /全选/ }) + // expect(selectAllCheckbox).toHaveAttribute('aria-checked', 'mixed') + }) + }) + + describe('actions', () => { + it.skip('should call onSeek when timestamp is clicked', () => { + // const onSeek = vi.fn() + // render( + // + // ) + // + // const timestampLink = screen.getByText('00:05 - 00:05') + // fireEvent.click(timestampLink) + // + // expect(onSeek).toHaveBeenCalledWith(5000) + }) + + it.skip('should show delete button for manual violations', () => { + // render( + // + // ) + // + // const manualItem = screen.getAllByTestId('violation-item')[2] + // expect(within(manualItem).getByRole('button', { name: /删除/ })).toBeInTheDocument() + }) + + it.skip('should call onDelete when delete button is clicked', () => { + // const onDelete = vi.fn() + // render( + // + // ) + // + // const manualItem = screen.getAllByTestId('violation-item')[2] + // const deleteButton = within(manualItem).getByRole('button', { name: /删除/ }) + // fireEvent.click(deleteButton) + // + // expect(onDelete).toHaveBeenCalledWith('vio_003') + }) + }) + + describe('filtering', () => { + it.skip('should filter by severity', () => { + // render( + // + // ) + // + // expect(screen.getAllByTestId('violation-item')).toHaveLength(1) + // expect(screen.getByText('最好的')).toBeInTheDocument() + }) + + it.skip('should filter by type', () => { + // render( + // + // ) + // + // expect(screen.getAllByTestId('violation-item')).toHaveLength(1) + }) + + it.skip('should filter by source (AI vs manual)', () => { + // render( + // + // ) + // + // expect(screen.getAllByTestId('violation-item')).toHaveLength(2) + }) + }) + + describe('sorting', () => { + it.skip('should sort by timestamp ascending by default', () => { + // render() + // + // const items = screen.getAllByTestId('violation-item') + // expect(within(items[0]).getByText('00:05')).toBeInTheDocument() + // expect(within(items[1]).getByText('00:10')).toBeInTheDocument() + // expect(within(items[2]).getByText('00:20')).toBeInTheDocument() + }) + + it.skip('should allow sorting by severity', () => { + // render() + // + // const items = screen.getAllByTestId('violation-item') + // // high -> medium -> low + // expect(within(items[0]).getByText('最好的')).toBeInTheDocument() + }) + }) + + describe('empty state', () => { + it.skip('should show empty state when no violations', () => { + // render() + // + // expect(screen.getByText('暂无违规项')).toBeInTheDocument() + }) + + it.skip('should show custom empty message', () => { + // render( + // + // ) + // + // expect(screen.getByText('AI 未检测到任何问题')).toBeInTheDocument() + }) + }) + + describe('evidence preview', () => { + it.skip('should show screenshot preview for logo violations', () => { + // render() + // + // const logoItem = screen.getAllByTestId('violation-item')[1] + // expect(within(logoItem).getByRole('img')).toHaveAttribute( + // 'src', + // 'https://example.com/screenshot.jpg' + // ) + }) + + it.skip('should show context for text violations', () => { + // render() + // + // expect(screen.getByText('这是最好的产品')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/ui/Button.test.tsx b/frontend/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..e43e3bc --- /dev/null +++ b/frontend/src/components/ui/Button.test.tsx @@ -0,0 +1,127 @@ +/** + * Button 组件单元测试 + * + * TDD 测试用例 - 测试按钮组件的各种状态和交互 + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +// import { Button } from './Button' + +describe('Button', () => { + it.skip('should render button with text', () => { + // render() + // expect(screen.getByRole('button')).toHaveTextContent('点击') + }) + + it.skip('should handle click events', () => { + // const handleClick = vi.fn() + // render() + // + // fireEvent.click(screen.getByRole('button')) + // expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it.skip('should be disabled when disabled prop is true', () => { + // const handleClick = vi.fn() + // render() + // + // const button = screen.getByRole('button') + // expect(button).toBeDisabled() + // + // fireEvent.click(button) + // expect(handleClick).not.toHaveBeenCalled() + }) + + it.skip('should show loading state', () => { + // render() + // + // expect(screen.getByRole('button')).toBeDisabled() + // expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + }) + + describe('variants', () => { + it.skip('should render primary variant by default', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('bg-primary') + }) + + it.skip('should render secondary variant', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('bg-secondary') + }) + + it.skip('should render destructive variant', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('bg-destructive') + }) + + it.skip('should render outline variant', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('border') + }) + + it.skip('should render ghost variant', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('hover:bg-accent') + }) + }) + + describe('sizes', () => { + it.skip('should render default size', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('h-10') + }) + + it.skip('should render small size', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('h-8') + }) + + it.skip('should render large size', () => { + // render() + // expect(screen.getByRole('button')).toHaveClass('h-12') + }) + }) + + describe('with icons', () => { + it.skip('should render with left icon', () => { + // const Icon = () => icon + // render() + // + // expect(screen.getByTestId('icon')).toBeInTheDocument() + // expect(screen.getByText('With Icon')).toBeInTheDocument() + }) + + it.skip('should render with right icon', () => { + // const Icon = () => icon + // render() + // + // expect(screen.getByTestId('icon')).toBeInTheDocument() + }) + + it.skip('should render icon only button', () => { + // const Icon = () => icon + // render() + // + // expect(screen.getByRole('button')).toHaveClass('h-10 w-10') + }) + }) + + describe('accessibility', () => { + it.skip('should have correct role', () => { + // render() + // expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it.skip('should support aria-label', () => { + // render() + // expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Close dialog') + }) + + it.skip('should indicate loading state to screen readers', () => { + // render() + // expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true') + }) + }) +}) diff --git a/frontend/src/components/video/VideoPlayer.test.tsx b/frontend/src/components/video/VideoPlayer.test.tsx new file mode 100644 index 0000000..210fd5e --- /dev/null +++ b/frontend/src/components/video/VideoPlayer.test.tsx @@ -0,0 +1,204 @@ +/** + * VideoPlayer 组件单元测试 + * + * TDD 测试用例 - 测试视频播放器组件 + * + * UI 规范参考:UIDesign.md + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +// import { VideoPlayer } from './VideoPlayer' + +describe('VideoPlayer', () => { + const mockVideoSrc = 'https://example.com/video.mp4' + const mockViolations = [ + { + id: 'vio_001', + timestamp_start: 5.0, + timestamp_end: 5.5, + type: 'prohibited_word', + content: '最好的', + severity: 'high', + }, + { + id: 'vio_002', + timestamp_start: 10.0, + timestamp_end: 15.0, + type: 'competitor_logo', + content: 'CompetitorBrand', + severity: 'medium', + }, + ] + + it.skip('should render video element', () => { + // render() + // expect(screen.getByTestId('video-element')).toBeInTheDocument() + }) + + it.skip('should show loading state initially', () => { + // render() + // expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() + }) + + describe('playback controls', () => { + it.skip('should toggle play/pause on click', () => { + // render() + // const playButton = screen.getByRole('button', { name: /play/i }) + // + // fireEvent.click(playButton) + // expect(screen.getByRole('button', { name: /pause/i })).toBeInTheDocument() + // + // fireEvent.click(screen.getByRole('button', { name: /pause/i })) + // expect(screen.getByRole('button', { name: /play/i })).toBeInTheDocument() + }) + + it.skip('should show current time and duration', () => { + // render() + // expect(screen.getByText('00:00 / 01:00')).toBeInTheDocument() + }) + + it.skip('should update time on seek', () => { + // render() + // const seekBar = screen.getByRole('slider', { name: /seek/i }) + // + // fireEvent.change(seekBar, { target: { value: 30000 } }) + // expect(screen.getByText('00:30 / 01:00')).toBeInTheDocument() + }) + + it.skip('should toggle mute', () => { + // render() + // const muteButton = screen.getByRole('button', { name: /mute/i }) + // + // fireEvent.click(muteButton) + // expect(screen.getByRole('button', { name: /unmute/i })).toBeInTheDocument() + }) + + it.skip('should toggle fullscreen', () => { + // render() + // const fullscreenButton = screen.getByRole('button', { name: /fullscreen/i }) + // + // fireEvent.click(fullscreenButton) + // // 验证全屏 API 被调用 + }) + }) + + describe('violation markers', () => { + it.skip('should display violation markers on timeline', () => { + // render( + // + // ) + // + // const markers = screen.getAllByTestId('violation-marker') + // expect(markers).toHaveLength(2) + }) + + it.skip('should show violation tooltip on marker hover', async () => { + // render( + // + // ) + // + // const marker = screen.getAllByTestId('violation-marker')[0] + // fireEvent.mouseEnter(marker) + // + // await waitFor(() => { + // expect(screen.getByText('最好的')).toBeInTheDocument() + // }) + }) + + it.skip('should seek to violation time on marker click', () => { + // const onSeek = vi.fn() + // render( + // + // ) + // + // const marker = screen.getAllByTestId('violation-marker')[0] + // fireEvent.click(marker) + // + // expect(onSeek).toHaveBeenCalledWith(5000) // 5.0 seconds in ms + }) + + it.skip('should highlight marker by severity color', () => { + // render( + // + // ) + // + // const markers = screen.getAllByTestId('violation-marker') + // expect(markers[0]).toHaveClass('bg-red-500') // high severity + // expect(markers[1]).toHaveClass('bg-orange-500') // medium severity + }) + }) + + describe('keyboard navigation', () => { + it.skip('should play/pause with space key', () => { + // render() + // const player = screen.getByTestId('video-player') + // + // fireEvent.keyDown(player, { key: ' ' }) + // // 验证播放状态切换 + }) + + it.skip('should seek forward with arrow right', () => { + // render() + // const player = screen.getByTestId('video-player') + // + // fireEvent.keyDown(player, { key: 'ArrowRight' }) + // // 验证前进 5 秒 + }) + + it.skip('should seek backward with arrow left', () => { + // render() + // const player = screen.getByTestId('video-player') + // + // fireEvent.keyDown(player, { key: 'ArrowLeft' }) + // // 验证后退 5 秒 + }) + }) + + describe('playback rate', () => { + it.skip('should allow changing playback speed', () => { + // render() + // const speedButton = screen.getByRole('button', { name: /speed/i }) + // + // fireEvent.click(speedButton) + // fireEvent.click(screen.getByText('1.5x')) + // + // // 验证播放速度已更改 + }) + }) + + describe('error handling', () => { + it.skip('should show error message on load failure', async () => { + // render() + // + // await waitFor(() => { + // expect(screen.getByText(/加载失败/)).toBeInTheDocument() + // }) + }) + + it.skip('should provide retry option on error', async () => { + // render() + // + // await waitFor(() => { + // expect(screen.getByRole('button', { name: /重试/ })).toBeInTheDocument() + // }) + }) + }) +}) diff --git a/frontend/src/hooks/useVideoAudit.test.ts b/frontend/src/hooks/useVideoAudit.test.ts new file mode 100644 index 0000000..f833757 --- /dev/null +++ b/frontend/src/hooks/useVideoAudit.test.ts @@ -0,0 +1,300 @@ +/** + * useVideoAudit Hook 单元测试 + * + * TDD 测试用例 - 测试视频审核相关的自定义 Hook + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +// import { useVideoAudit } from './useVideoAudit' +// import { QueryClientProvider, QueryClient } from '@tanstack/react-query' + +describe('useVideoAudit', () => { + // let queryClient: QueryClient + // let wrapper: React.FC<{ children: React.ReactNode }> + + beforeEach(() => { + // queryClient = new QueryClient({ + // defaultOptions: { + // queries: { retry: false }, + // }, + // }) + // wrapper = ({ children }) => ( + // + // {children} + // + // ) + }) + + it.skip('should fetch video audit data', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // expect(result.current.isLoading).toBe(true) + // + // await waitFor(() => { + // expect(result.current.isLoading).toBe(false) + // }) + // + // expect(result.current.data).toBeDefined() + // expect(result.current.data?.video_id).toBe('video_001') + }) + + it.skip('should return violations', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.violations).toBeDefined() + // }) + // + // expect(result.current.violations.length).toBeGreaterThan(0) + }) + + it.skip('should return brief compliance data', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.briefCompliance).toBeDefined() + // }) + // + // expect(result.current.briefCompliance?.selling_points).toBeDefined() + }) + + it.skip('should handle error state', async () => { + // const { result } = renderHook( + // () => useVideoAudit('nonexistent_video'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.isError).toBe(true) + // }) + // + // expect(result.current.error).toBeDefined() + }) + + describe('mutations', () => { + it.skip('should submit review decision', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.isLoading).toBe(false) + // }) + // + // await act(async () => { + // await result.current.submitDecision({ + // decision: 'passed', + // comment: '内容符合要求', + // }) + // }) + // + // expect(result.current.isSubmitting).toBe(false) + }) + + it.skip('should add manual violation', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.isLoading).toBe(false) + // }) + // + // const initialCount = result.current.violations.length + // + // await act(async () => { + // await result.current.addViolation({ + // type: 'other', + // content: '手动添加的问题', + // timestamp_start: 10.0, + // timestamp_end: 15.0, + // severity: 'medium', + // }) + // }) + // + // expect(result.current.violations.length).toBe(initialCount + 1) + }) + + it.skip('should delete violation', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.violations.length).toBeGreaterThan(0) + // }) + // + // const initialCount = result.current.violations.length + // + // await act(async () => { + // await result.current.deleteViolation('vio_001') + // }) + // + // expect(result.current.violations.length).toBe(initialCount - 1) + }) + }) + + describe('optimistic updates', () => { + it.skip('should optimistically update violation selection', async () => { + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.isLoading).toBe(false) + // }) + // + // act(() => { + // result.current.toggleViolationSelection('vio_001') + // }) + // + // expect(result.current.selectedViolationIds).toContain('vio_001') + }) + + it.skip('should rollback on mutation error', async () => { + // // 模拟 API 错误 + // server.use( + // http.delete('/api/v1/violations/:id', () => { + // return new HttpResponse(null, { status: 500 }) + // }) + // ) + // + // const { result } = renderHook( + // () => useVideoAudit('video_001'), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.violations.length).toBeGreaterThan(0) + // }) + // + // const initialCount = result.current.violations.length + // + // await act(async () => { + // try { + // await result.current.deleteViolation('vio_001') + // } catch (e) { + // // 预期的错误 + // } + // }) + // + // // 应该回滚到原始状态 + // expect(result.current.violations.length).toBe(initialCount) + }) + }) +}) + +describe('useVideoPlayer', () => { + it.skip('should manage playback state', () => { + // const { result } = renderHook(() => useVideoPlayer()) + // + // expect(result.current.isPlaying).toBe(false) + // + // act(() => { + // result.current.play() + // }) + // + // expect(result.current.isPlaying).toBe(true) + // + // act(() => { + // result.current.pause() + // }) + // + // expect(result.current.isPlaying).toBe(false) + }) + + it.skip('should manage current time', () => { + // const { result } = renderHook(() => useVideoPlayer()) + // + // expect(result.current.currentTime).toBe(0) + // + // act(() => { + // result.current.seekTo(5000) + // }) + // + // expect(result.current.currentTime).toBe(5000) + }) + + it.skip('should manage volume', () => { + // const { result } = renderHook(() => useVideoPlayer()) + // + // expect(result.current.volume).toBe(1) + // expect(result.current.isMuted).toBe(false) + // + // act(() => { + // result.current.setVolume(0.5) + // }) + // + // expect(result.current.volume).toBe(0.5) + // + // act(() => { + // result.current.toggleMute() + // }) + // + // expect(result.current.isMuted).toBe(true) + }) +}) + +describe('useAppeal', () => { + it.skip('should fetch appeal tokens', async () => { + // const { result } = renderHook( + // () => useAppeal(), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.tokens).toBeDefined() + // }) + // + // expect(result.current.tokens).toBeGreaterThanOrEqual(0) + }) + + it.skip('should check if appeal is available', async () => { + // const { result } = renderHook( + // () => useAppeal(), + // { wrapper } + // ) + // + // await waitFor(() => { + // expect(result.current.canAppeal).toBeDefined() + // }) + }) + + it.skip('should submit appeal', async () => { + // const { result } = renderHook( + // () => useAppeal(), + // { wrapper } + // ) + // + // await act(async () => { + // await result.current.submitAppeal({ + // videoId: 'video_001', + // violationIds: ['vio_001'], + // reason: '这个词语在此语境下是正常使用,不应被判定为违规', + // }) + // }) + // + // expect(result.current.isSubmitting).toBe(false) + }) + + it.skip('should validate appeal reason length', () => { + // const { result } = renderHook(() => useAppeal()) + // + // expect(result.current.validateReason('短')).toBe(false) + // expect(result.current.validateReason('这是一个足够长的申诉理由')).toBe(true) + }) +}) diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts new file mode 100644 index 0000000..bd6e2f3 --- /dev/null +++ b/frontend/src/lib/utils.test.ts @@ -0,0 +1,176 @@ +/** + * 工具函数单元测试 + * + * TDD 测试用例 - 测试通用工具函数 + */ + +import { describe, it, expect } from 'vitest' + +// 导入待实现的模块(TDD 红灯阶段) +// import { +// formatTimestamp, +// formatDuration, +// formatFileSize, +// truncateText, +// validateEmail, +// validatePassword, +// cn, +// } from './utils' + +describe('formatTimestamp', () => { + it.skip('should format milliseconds to mm:ss format', () => { + // expect(formatTimestamp(0)).toBe('00:00') + // expect(formatTimestamp(5000)).toBe('00:05') + // expect(formatTimestamp(60000)).toBe('01:00') + // expect(formatTimestamp(90500)).toBe('01:30') + }) + + it.skip('should format to hh:mm:ss for long durations', () => { + // expect(formatTimestamp(3600000)).toBe('01:00:00') + // expect(formatTimestamp(3661000)).toBe('01:01:01') + }) + + it.skip('should handle negative values', () => { + // expect(formatTimestamp(-1000)).toBe('00:00') + }) +}) + +describe('formatDuration', () => { + it.skip('should format seconds to human readable format', () => { + // expect(formatDuration(5)).toBe('5秒') + // expect(formatDuration(60)).toBe('1分钟') + // expect(formatDuration(90)).toBe('1分30秒') + // expect(formatDuration(3600)).toBe('1小时') + // expect(formatDuration(3661)).toBe('1小时1分1秒') + }) + + it.skip('should handle decimal values', () => { + // expect(formatDuration(5.5)).toBe('5.5秒') + }) +}) + +describe('formatFileSize', () => { + it.skip('should format bytes to human readable format', () => { + // expect(formatFileSize(0)).toBe('0 B') + // expect(formatFileSize(500)).toBe('500 B') + // expect(formatFileSize(1024)).toBe('1 KB') + // expect(formatFileSize(1048576)).toBe('1 MB') + // expect(formatFileSize(1073741824)).toBe('1 GB') + }) + + it.skip('should handle decimal precision', () => { + // expect(formatFileSize(1536, 2)).toBe('1.50 KB') + }) +}) + +describe('truncateText', () => { + it.skip('should truncate text exceeding max length', () => { + // expect(truncateText('Hello World', 5)).toBe('Hello...') + // expect(truncateText('Hello', 10)).toBe('Hello') + }) + + it.skip('should handle Chinese characters', () => { + // expect(truncateText('这是一段测试文字', 4)).toBe('这是一段...') + }) + + it.skip('should allow custom ellipsis', () => { + // expect(truncateText('Hello World', 5, '…')).toBe('Hello…') + }) +}) + +describe('validateEmail', () => { + it.skip('should validate correct email formats', () => { + // expect(validateEmail('test@example.com')).toBe(true) + // expect(validateEmail('user.name@domain.co.jp')).toBe(true) + // expect(validateEmail('user+tag@example.com')).toBe(true) + }) + + it.skip('should reject invalid email formats', () => { + // expect(validateEmail('')).toBe(false) + // expect(validateEmail('invalid')).toBe(false) + // expect(validateEmail('invalid@')).toBe(false) + // expect(validateEmail('@domain.com')).toBe(false) + // expect(validateEmail('test@.com')).toBe(false) + }) +}) + +describe('validatePassword', () => { + it.skip('should require minimum length', () => { + // const result = validatePassword('short') + // expect(result.isValid).toBe(false) + // expect(result.errors).toContain('密码长度至少 8 位') + }) + + it.skip('should require complexity', () => { + // const result = validatePassword('password') + // expect(result.isValid).toBe(false) + // expect(result.errors).toContain('密码需包含数字') + }) + + it.skip('should accept valid passwords', () => { + // const result = validatePassword('Password123!') + // expect(result.isValid).toBe(true) + // expect(result.errors).toHaveLength(0) + }) +}) + +describe('cn (classnames utility)', () => { + it.skip('should merge class names', () => { + // expect(cn('foo', 'bar')).toBe('foo bar') + // expect(cn('foo', undefined, 'bar')).toBe('foo bar') + // expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz') + }) + + it.skip('should handle tailwind class conflicts', () => { + // expect(cn('p-4', 'p-2')).toBe('p-2') + // expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500') + }) +}) + +describe('timestamp conversion', () => { + it.skip('should convert frame number to milliseconds', () => { + // expect(frameToMs(30, 30)).toBe(1000) // 30 frames at 30fps = 1 second + // expect(frameToMs(45, 30)).toBe(1500) // 45 frames at 30fps = 1.5 seconds + // expect(frameToMs(60, 60)).toBe(1000) // 60 frames at 60fps = 1 second + }) + + it.skip('should convert milliseconds to frame number', () => { + // expect(msToFrame(1000, 30)).toBe(30) + // expect(msToFrame(1500, 30)).toBe(45) + // expect(msToFrame(1000, 60)).toBe(60) + }) + + it.skip('should round frame numbers correctly', () => { + // expect(msToFrame(1033, 30)).toBe(31) // 1.033s * 30fps = 30.99 → 31 + }) +}) + +describe('severity helpers', () => { + it.skip('should return correct color for severity', () => { + // expect(getSeverityColor('high')).toBe('red') + // expect(getSeverityColor('medium')).toBe('orange') + // expect(getSeverityColor('low')).toBe('yellow') + }) + + it.skip('should return correct label for severity', () => { + // expect(getSeverityLabel('high')).toBe('高风险') + // expect(getSeverityLabel('medium')).toBe('中风险') + // expect(getSeverityLabel('low')).toBe('低风险') + }) +}) + +describe('status helpers', () => { + it.skip('should return correct status color', () => { + // expect(getStatusColor('passed')).toBe('green') + // expect(getStatusColor('rejected')).toBe('red') + // expect(getStatusColor('pending_review')).toBe('orange') + // expect(getStatusColor('processing')).toBe('blue') + }) + + it.skip('should return correct status label', () => { + // expect(getStatusLabel('passed')).toBe('已通过') + // expect(getStatusLabel('rejected')).toBe('已驳回') + // expect(getStatusLabel('pending_review')).toBe('待审核') + // expect(getStatusLabel('processing')).toBe('处理中') + }) +}) diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts new file mode 100644 index 0000000..a9d9a1f --- /dev/null +++ b/frontend/tests/mocks/handlers.ts @@ -0,0 +1,154 @@ +/** + * MSW 请求处理器 + * + * 模拟后端 API 响应 + */ + +// import { http, HttpResponse } from 'msw' + +// API Base URL +// const API_BASE = '/api/v1' + +// 模拟用户数据 +export const mockUsers = { + creator: { + id: 'user_creator_001', + email: 'creator@test.com', + name: '测试达人', + role: 'creator', + appeal_tokens: 3, + }, + agency: { + id: 'user_agency_001', + email: 'agency@test.com', + name: '测试 Agency', + role: 'agency', + }, + brand: { + id: 'user_brand_001', + email: 'brand@test.com', + name: '测试品牌方', + role: 'brand', + }, + admin: { + id: 'user_admin_001', + email: 'admin@test.com', + name: '系统管理员', + role: 'admin', + }, +} + +// 模拟视频数据 +export const mockVideos = [ + { + id: 'video_001', + title: '测试视频 1', + status: 'pending_review', + creator_id: 'user_creator_001', + task_id: 'task_001', + duration_ms: 60000, + created_at: '2024-01-01T00:00:00Z', + }, + { + id: 'video_002', + title: '测试视频 2', + status: 'passed', + creator_id: 'user_creator_001', + task_id: 'task_001', + duration_ms: 120000, + created_at: '2024-01-02T00:00:00Z', + }, +] + +// 模拟违规数据 +export const mockViolations = [ + { + id: 'vio_001', + video_id: 'video_001', + type: 'prohibited_word', + content: '最好的', + timestamp_start: 5.0, + timestamp_end: 5.5, + severity: 'high', + source: 'ai', + }, + { + id: 'vio_002', + video_id: 'video_001', + type: 'competitor_logo', + content: 'CompetitorBrand', + timestamp_start: 10.0, + timestamp_end: 15.0, + severity: 'medium', + source: 'ai', + }, +] + +// 模拟 Brief 数据 +export const mockBriefs = { + brief_001: { + id: 'brief_001', + task_id: 'task_001', + selling_points: [ + { text: '24小时持妆', priority: 'high' }, + { text: '天然成分', priority: 'medium' }, + ], + forbidden_words: ['药用', '治疗', '最好的'], + timing_requirements: [ + { type: 'product_visible', min_duration_seconds: 5 }, + { type: 'brand_mention', min_frequency: 3 }, + ], + brand_tone: { + style: ['年轻活力', '专业可信'], + target_audience: '18-35岁女性', + }, + platform: 'douyin', + region: 'mainland_china', + }, +} + +// TODO: 实现 MSW handlers +// export const handlers = [ +// // 认证相关 +// http.post(`${API_BASE}/auth/login`, async ({ request }) => { +// const body = await request.json() +// // 模拟登录逻辑 +// return HttpResponse.json({ +// access_token: 'mock_token', +// user: mockUsers.creator, +// }) +// }), +// +// // 视频相关 +// http.get(`${API_BASE}/videos`, () => { +// return HttpResponse.json({ +// items: mockVideos, +// total: mockVideos.length, +// page: 1, +// page_size: 10, +// }) +// }), +// +// http.get(`${API_BASE}/videos/:videoId`, ({ params }) => { +// const video = mockVideos.find(v => v.id === params.videoId) +// if (!video) { +// return new HttpResponse(null, { status: 404 }) +// } +// return HttpResponse.json(video) +// }), +// +// // 审核相关 +// http.get(`${API_BASE}/videos/:videoId/violations`, ({ params }) => { +// const violations = mockViolations.filter(v => v.video_id === params.videoId) +// return HttpResponse.json({ violations }) +// }), +// +// // Brief 相关 +// http.get(`${API_BASE}/briefs/:briefId`, ({ params }) => { +// const brief = mockBriefs[params.briefId as keyof typeof mockBriefs] +// if (!brief) { +// return new HttpResponse(null, { status: 404 }) +// } +// return HttpResponse.json(brief) +// }), +// ] diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..ff61f6d --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,73 @@ +/** + * 前端测试全局设置 + * + * 配置 MSW (Mock Service Worker) 和测试工具 + */ + +import '@testing-library/jest-dom' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' +// import { server } from './mocks/server' + +// MSW 服务器设置 +// beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +// afterEach(() => server.resetHandlers()) +// afterAll(() => server.close()) + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Mock IntersectionObserver +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | null = null + readonly rootMargin: string = '' + readonly thresholds: ReadonlyArray = [] + + constructor( + private callback: IntersectionObserverCallback, + _options?: IntersectionObserverInit + ) {} + + observe(_target: Element): void {} + unobserve(_target: Element): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return [] + } +} + +window.IntersectionObserver = MockIntersectionObserver + +// Mock ResizeObserver +class MockResizeObserver implements ResizeObserver { + constructor(_callback: ResizeObserverCallback) {} + observe(_target: Element, _options?: ResizeObserverOptions): void {} + unobserve(_target: Element): void {} + disconnect(): void {} +} + +window.ResizeObserver = MockResizeObserver + +// Mock URL.createObjectURL +URL.createObjectURL = vi.fn(() => 'mock-url') +URL.revokeObjectURL = vi.fn() + +// Mock scrollTo +window.scrollTo = vi.fn() + +// 清理 localStorage +afterEach(() => { + localStorage.clear() + sessionStorage.clear() +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..c0148a4 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'tests/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'dist', 'e2e'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'tests/', + '**/*.d.ts', + '**/*.config.*', + '**/types/**', + ], + thresholds: { + // 前端覆盖率目标 >= 70% + lines: 70, + branches: 70, + functions: 70, + statements: 70, + }, + }, + // 测试超时设置 + testTimeout: 10000, + hookTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +})