feat: 添加全面的 TDD 测试套件框架
基于项目需求文档(PRD.md, FeatureSummary.md, DevelopmentPlan.md, UIDesign.md, User_Role_Interfaces.md)编写的 TDD 测试用例。 后端测试 (Python/pytest): - 单元测试: rule_engine, brief_parser, timestamp_alignment, video_auditor, validators - 集成测试: API Brief, Video, Review 端点 - AI 模块测试: ASR, OCR, Logo 检测服务 - 全局 fixtures 和 pytest 配置 前端测试 (TypeScript/Vitest): - 工具函数测试: utils.test.ts - 组件测试: Button, VideoPlayer, ViolationList - Hooks 测试: useVideoAudit, useVideoPlayer, useAppeal - MSW mock handlers 配置 E2E 测试 (Playwright): - 认证流程测试 - 视频上传流程测试 - 视频审核流程测试 - 申诉流程测试 所有测试当前使用 pytest.skip() / it.skip() 作为占位符, 遵循 TDD 红灯阶段 - 等待实现代码后运行。 验收标准覆盖: - ASR WER ≤ 10% - OCR 准确率 ≥ 95% - Logo F1 ≥ 0.85 - 时间戳误差 ≤ 0.5s - 频次统计准确率 ≥ 95% Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
18fe22ce8a
commit
040aada160
56
backend/pyproject.toml
Normal file
56
backend/pyproject.toml
Normal file
@ -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
|
||||||
327
backend/tests/ai/test_asr_service.py
Normal file
327
backend/tests/ai/test_asr_service.py
Normal file
@ -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("待实现:并发转写测试")
|
||||||
370
backend/tests/ai/test_logo_detector.py
Normal file
370
backend/tests/ai/test_logo_detector.py
Normal file
@ -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 检测速度测试")
|
||||||
307
backend/tests/ai/test_ocr_service.py
Normal file
307
backend/tests/ai/test_ocr_service.py
Normal file
@ -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 处理速度测试")
|
||||||
271
backend/tests/conftest.py
Normal file
271
backend/tests/conftest.py
Normal file
@ -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": "达人玩的新梗,品牌方认可",
|
||||||
|
}
|
||||||
177
backend/tests/integration/test_api_brief.py
Normal file
177
backend/tests/integration/test_api_brief.py
Normal file
@ -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")
|
||||||
487
backend/tests/integration/test_api_review.py
Normal file
487
backend/tests/integration/test_api_review.py
Normal file
@ -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("待实现:品牌方权限限制")
|
||||||
379
backend/tests/integration/test_api_video.py
Normal file
379
backend/tests/integration/test_api_video.py
Normal file
@ -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("待实现:任务筛选")
|
||||||
339
backend/tests/unit/test_brief_parser.py
Normal file
339
backend/tests/unit/test_brief_parser.py
Normal file
@ -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("待实现:卖点结构验证")
|
||||||
278
backend/tests/unit/test_rule_engine.py
Normal file
278
backend/tests/unit/test_rule_engine.py
Normal file
@ -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")
|
||||||
365
backend/tests/unit/test_timestamp_alignment.py
Normal file
365
backend/tests/unit/test_timestamp_alignment.py
Normal file
@ -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("待实现:违规时间戳")
|
||||||
275
backend/tests/unit/test_validators.py
Normal file
275
backend/tests/unit/test_validators.py
Normal file
@ -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 格式验证")
|
||||||
411
backend/tests/unit/test_video_auditor.py
Normal file
411
backend/tests/unit/test_video_auditor.py
Normal file
@ -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("待实现:频次要求检查")
|
||||||
54
frontend/e2e/playwright.config.ts
Normal file
54
frontend/e2e/playwright.config.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
210
frontend/e2e/tests/appeal.spec.ts
Normal file
210
frontend/e2e/tests/appeal.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
119
frontend/e2e/tests/auth.spec.ts
Normal file
119
frontend/e2e/tests/auth.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
217
frontend/e2e/tests/video-review.spec.ts
Normal file
217
frontend/e2e/tests/video-review.spec.ts
Normal file
@ -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() // 未达标
|
||||||
|
})
|
||||||
|
})
|
||||||
201
frontend/e2e/tests/video-upload.spec.ts
Normal file
201
frontend/e2e/tests/video-upload.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
268
frontend/src/components/review/ViolationList.test.tsx
Normal file
268
frontend/src/components/review/ViolationList.test.tsx
Normal file
@ -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(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// expect(screen.getAllByTestId('violation-item')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should display violation type label', () => {
|
||||||
|
// render(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// expect(screen.getByText('禁用词')).toBeInTheDocument()
|
||||||
|
// expect(screen.getByText('竞品 Logo')).toBeInTheDocument()
|
||||||
|
// expect(screen.getByText('品牌调性')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should display violation content', () => {
|
||||||
|
// render(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// expect(screen.getByText('最好的')).toBeInTheDocument()
|
||||||
|
// expect(screen.getByText('CompetitorBrand')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should display timestamp', () => {
|
||||||
|
// render(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// 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(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// selectable
|
||||||
|
// onSelectionChange={onSelectionChange}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// selectable
|
||||||
|
// onSelectionChange={onSelectionChange}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// selectable
|
||||||
|
// selectedIds={['vio_001']}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// onSeek={onSeek}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// editable
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// editable
|
||||||
|
// onDelete={onDelete}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// filterBySeverity="high"
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// expect(screen.getAllByTestId('violation-item')).toHaveLength(1)
|
||||||
|
// expect(screen.getByText('最好的')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should filter by type', () => {
|
||||||
|
// render(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// filterByType="prohibited_word"
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// expect(screen.getAllByTestId('violation-item')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should filter by source (AI vs manual)', () => {
|
||||||
|
// render(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={mockViolations}
|
||||||
|
// filterBySource="ai"
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// expect(screen.getAllByTestId('violation-item')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sorting', () => {
|
||||||
|
it.skip('should sort by timestamp ascending by default', () => {
|
||||||
|
// render(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// 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(<ViolationList violations={mockViolations} sortBy="severity" />)
|
||||||
|
//
|
||||||
|
// 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(<ViolationList violations={[]} />)
|
||||||
|
//
|
||||||
|
// expect(screen.getByText('暂无违规项')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should show custom empty message', () => {
|
||||||
|
// render(
|
||||||
|
// <ViolationList
|
||||||
|
// violations={[]}
|
||||||
|
// emptyMessage="AI 未检测到任何问题"
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// expect(screen.getByText('AI 未检测到任何问题')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('evidence preview', () => {
|
||||||
|
it.skip('should show screenshot preview for logo violations', () => {
|
||||||
|
// render(<ViolationList violations={mockViolations} />)
|
||||||
|
//
|
||||||
|
// 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(<ViolationList violations={mockViolations} showContext />)
|
||||||
|
//
|
||||||
|
// expect(screen.getByText('这是最好的产品')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
127
frontend/src/components/ui/Button.test.tsx
Normal file
127
frontend/src/components/ui/Button.test.tsx
Normal file
@ -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(<Button>点击</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveTextContent('点击')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should handle click events', () => {
|
||||||
|
// const handleClick = vi.fn()
|
||||||
|
// render(<Button onClick={handleClick}>点击</Button>)
|
||||||
|
//
|
||||||
|
// fireEvent.click(screen.getByRole('button'))
|
||||||
|
// expect(handleClick).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should be disabled when disabled prop is true', () => {
|
||||||
|
// const handleClick = vi.fn()
|
||||||
|
// render(<Button disabled onClick={handleClick}>点击</Button>)
|
||||||
|
//
|
||||||
|
// const button = screen.getByRole('button')
|
||||||
|
// expect(button).toBeDisabled()
|
||||||
|
//
|
||||||
|
// fireEvent.click(button)
|
||||||
|
// expect(handleClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should show loading state', () => {
|
||||||
|
// render(<Button loading>提交</Button>)
|
||||||
|
//
|
||||||
|
// expect(screen.getByRole('button')).toBeDisabled()
|
||||||
|
// expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('variants', () => {
|
||||||
|
it.skip('should render primary variant by default', () => {
|
||||||
|
// render(<Button>Primary</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('bg-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render secondary variant', () => {
|
||||||
|
// render(<Button variant="secondary">Secondary</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('bg-secondary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render destructive variant', () => {
|
||||||
|
// render(<Button variant="destructive">Delete</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('bg-destructive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render outline variant', () => {
|
||||||
|
// render(<Button variant="outline">Outline</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('border')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render ghost variant', () => {
|
||||||
|
// render(<Button variant="ghost">Ghost</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('hover:bg-accent')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sizes', () => {
|
||||||
|
it.skip('should render default size', () => {
|
||||||
|
// render(<Button>Default</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('h-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render small size', () => {
|
||||||
|
// render(<Button size="sm">Small</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('h-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render large size', () => {
|
||||||
|
// render(<Button size="lg">Large</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('h-12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with icons', () => {
|
||||||
|
it.skip('should render with left icon', () => {
|
||||||
|
// const Icon = () => <span data-testid="icon">icon</span>
|
||||||
|
// render(<Button leftIcon={<Icon />}>With Icon</Button>)
|
||||||
|
//
|
||||||
|
// expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||||
|
// expect(screen.getByText('With Icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render with right icon', () => {
|
||||||
|
// const Icon = () => <span data-testid="icon">icon</span>
|
||||||
|
// render(<Button rightIcon={<Icon />}>With Icon</Button>)
|
||||||
|
//
|
||||||
|
// expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should render icon only button', () => {
|
||||||
|
// const Icon = () => <span data-testid="icon">icon</span>
|
||||||
|
// render(<Button size="icon"><Icon /></Button>)
|
||||||
|
//
|
||||||
|
// expect(screen.getByRole('button')).toHaveClass('h-10 w-10')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it.skip('should have correct role', () => {
|
||||||
|
// render(<Button>Button</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should support aria-label', () => {
|
||||||
|
// render(<Button aria-label="Close dialog">X</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Close dialog')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should indicate loading state to screen readers', () => {
|
||||||
|
// render(<Button loading aria-busy>Loading</Button>)
|
||||||
|
// expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
204
frontend/src/components/video/VideoPlayer.test.tsx
Normal file
204
frontend/src/components/video/VideoPlayer.test.tsx
Normal file
@ -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(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// expect(screen.getByTestId('video-element')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should show loading state initially', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('playback controls', () => {
|
||||||
|
it.skip('should toggle play/pause on click', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// 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(<VideoPlayer src={mockVideoSrc} duration={60000} />)
|
||||||
|
// expect(screen.getByText('00:00 / 01:00')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should update time on seek', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} duration={60000} />)
|
||||||
|
// 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(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// 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(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// const fullscreenButton = screen.getByRole('button', { name: /fullscreen/i })
|
||||||
|
//
|
||||||
|
// fireEvent.click(fullscreenButton)
|
||||||
|
// // 验证全屏 API 被调用
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('violation markers', () => {
|
||||||
|
it.skip('should display violation markers on timeline', () => {
|
||||||
|
// render(
|
||||||
|
// <VideoPlayer
|
||||||
|
// src={mockVideoSrc}
|
||||||
|
// duration={60000}
|
||||||
|
// violations={mockViolations}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// const markers = screen.getAllByTestId('violation-marker')
|
||||||
|
// expect(markers).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should show violation tooltip on marker hover', async () => {
|
||||||
|
// render(
|
||||||
|
// <VideoPlayer
|
||||||
|
// src={mockVideoSrc}
|
||||||
|
// duration={60000}
|
||||||
|
// violations={mockViolations}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <VideoPlayer
|
||||||
|
// src={mockVideoSrc}
|
||||||
|
// duration={60000}
|
||||||
|
// violations={mockViolations}
|
||||||
|
// onSeek={onSeek}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(
|
||||||
|
// <VideoPlayer
|
||||||
|
// src={mockVideoSrc}
|
||||||
|
// duration={60000}
|
||||||
|
// violations={mockViolations}
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// 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(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// const player = screen.getByTestId('video-player')
|
||||||
|
//
|
||||||
|
// fireEvent.keyDown(player, { key: ' ' })
|
||||||
|
// // 验证播放状态切换
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should seek forward with arrow right', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// const player = screen.getByTestId('video-player')
|
||||||
|
//
|
||||||
|
// fireEvent.keyDown(player, { key: 'ArrowRight' })
|
||||||
|
// // 验证前进 5 秒
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should seek backward with arrow left', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// const player = screen.getByTestId('video-player')
|
||||||
|
//
|
||||||
|
// fireEvent.keyDown(player, { key: 'ArrowLeft' })
|
||||||
|
// // 验证后退 5 秒
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('playback rate', () => {
|
||||||
|
it.skip('should allow changing playback speed', () => {
|
||||||
|
// render(<VideoPlayer src={mockVideoSrc} />)
|
||||||
|
// 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(<VideoPlayer src="invalid-url" />)
|
||||||
|
//
|
||||||
|
// await waitFor(() => {
|
||||||
|
// expect(screen.getByText(/加载失败/)).toBeInTheDocument()
|
||||||
|
// })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should provide retry option on error', async () => {
|
||||||
|
// render(<VideoPlayer src="invalid-url" />)
|
||||||
|
//
|
||||||
|
// await waitFor(() => {
|
||||||
|
// expect(screen.getByRole('button', { name: /重试/ })).toBeInTheDocument()
|
||||||
|
// })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
300
frontend/src/hooks/useVideoAudit.test.ts
Normal file
300
frontend/src/hooks/useVideoAudit.test.ts
Normal file
@ -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 }) => (
|
||||||
|
// <QueryClientProvider client={queryClient}>
|
||||||
|
// {children}
|
||||||
|
// </QueryClientProvider>
|
||||||
|
// )
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
176
frontend/src/lib/utils.test.ts
Normal file
176
frontend/src/lib/utils.test.ts
Normal file
@ -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('处理中')
|
||||||
|
})
|
||||||
|
})
|
||||||
154
frontend/tests/mocks/handlers.ts
Normal file
154
frontend/tests/mocks/handlers.ts
Normal file
@ -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)
|
||||||
|
// }),
|
||||||
|
// ]
|
||||||
73
frontend/tests/setup.ts
Normal file
73
frontend/tests/setup.ts
Normal file
@ -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<number> = []
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
40
frontend/vitest.config.ts
Normal file
40
frontend/vitest.config.ts
Normal file
@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user