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(}>With Icon)
+ //
+ // expect(screen.getByTestId('icon')).toBeInTheDocument()
+ // expect(screen.getByText('With Icon')).toBeInTheDocument()
+ })
+
+ it.skip('should render with right icon', () => {
+ // const Icon = () => icon
+ // render(}>With Icon)
+ //
+ // 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'),
+ },
+ },
+})