Compare commits

...

7 Commits

Author SHA1 Message Date
CI Bot 301d07e110 test(wave100): 40 unit tests for 3 application layer modules (milestone)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m12s
AI Code Review / AI Code Review (pull_request) Successful in 1m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m17s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14s
Milestone wave #100 — cleaning up zero-test application modules:
- generated_videos.py: 8 UseCases fully covered (25 tests)
  - ListGeneratedVideosUseCase: 4 tests
  - ListGeneratedVideosPaginatedUseCase: 7 tests (page clamp / page_size clamp / filters)
  - GetGeneratedVideoUseCase: 2 tests
  - ListGeneratedVideosByTaskUseCase: 3 tests
  - GetGeneratedVideoDownloadUrlUseCase: 2 tests
  - UpdateVideoReviewStatusUseCase: 7 tests (3 valid statuses + validation)
  - GetVideosByIdsUseCase: 2 tests
- assets.py: 6 tests (CreateAssetCommand + ListAssets + CreateAsset)
- asset_libraries.py: 5 tests (Command + List + Create)
Total: 40 tests across 3 modules
2026-07-26 20:10:49 +08:00
CI Bot ac01bee33b test(wave99): 35 unit tests for small domain modules
Clean up zero-test small domain modules in one batch:
- EditingMode enum: 4 tests
- TemplateSegment dataclass: 2 tests
- ClipType + TransitionEffect enums: 3 tests
- TemplateClipConfig create + properties: 11 tests (validation + duration range + transition)
- EditTemplateVersion create: 6 tests
- VoiceLibraryItem: 2 tests
- TitleLibraryItem: 4 tests
- RecipeItem: 3 tests
Total: 35 tests across 8 modules
2026-07-26 20:10:46 +08:00
CI Bot f99975363a test(wave98): 31 unit tests for preset_bgm domain module
- PresetBGM dataclass (frozen): 7 tests
- PRESET_BGM_LIBRARY integrity: 6 tests (unique IDs / valid styles / positive duration)
- BGM_STYLES: 2 tests
- get_preset_bgm: 4 tests
- list_preset_bgm_by_style: 6 tests (4 styles + empty + order)
- search_preset_bgm: 8 tests (name/desc/tag/case-insensitive/no match/empty/dup)
Total: 31 tests
2026-07-26 20:10:44 +08:00
CI Bot 2c36522dfb test(wave97): 53 unit tests for quota domain system
- QuotaDimension enum: 3 tests
- QuotaTier: 6 tests (get_limit / is_unlimited)
- QUOTA_TIERS: 4 tests (free/basic/premium + monotonic)
- QuotaWarningLevel: 1 test
- QuotaCheckResult.usage_percent: 6 tests
- QuotaRegistry: 9 tests (init / register / idempotent / tiers)
- QuotaChecker: 18 tests (check / multiple / warning levels / edge cases)
- get_warning_level + global singletons: 6 tests
Total: 53 unit tests for quota.py (270 lines)
2026-07-26 20:10:42 +08:00
CI Bot 6f4bf2e020 test(wave96): 76 unit tests for Job domain model (state machine + lifecycle)
- JobType enum: 3 tests
- JobStatus enum: 2 tests
- TERMINAL_STATUSES: 5 tests
- Job.create: 17 tests (validation + string enum + fields)
- is_terminal / is_retryable: 12 tests
- transition_to (state machine): 16 tests (all valid + invalid transitions)
- mark_running / mark_success / mark_failed / mark_cancelled: 8 tests
- update_progress: 9 tests
- prepare_retry: 4 tests
- to_dict serialization: 3 tests
Total: 76 unit tests for job.py (289 lines)
2026-07-26 20:10:39 +08:00
CI Bot b6c9ddbde2 test(wave95): 82 unit tests for domain entities (Project/Asset/AssetStatus/IngestJob/User/AssetLibrary)
- Project: create validation + access control (is_owner/is_shared_with/can_access) = 13 tests
- AssetLibrary: create validation + kind variants = 6 tests
- AssetStatus: enum values + _missing_ backward compatibility (20+ aliases) = 26 tests
- Asset: create validation + file_type property + tag operations (add/remove/dedup) = 23 tests
- IngestJob: create validation + field stripping = 11 tests
- User: dataclass default values + admin + pro subscription = 3 tests
Total: 82 unit tests for entities.py 292 lines
2026-07-26 20:10:37 +08:00
CI Bot b780bf1563 test(wave94): extract media_validation domain module + 70 unit tests
- Extract safe_parse_fps, is_valid_media, and constants from ingest.py
- New module: packages/domain/media_validation.py
- 70 new unit tests: safe_parse_fps(18) + constants(11) + is_valid_media(41)
- ingest.py: 424 -> 340 lines (-84, -20%)
- Backward compatible: private aliases preserved, original 25 tests pass
2026-07-26 20:10:35 +08:00
9 changed files with 2554 additions and 697 deletions
+8 -92
View File
@@ -14,70 +14,18 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyIngestJobRepository,
)
from packages.domain import Asset, AssetStatus, IngestJobStatus
from packages.domain.media_validation import (
MIN_AUDIO_FILE_SIZE,
MIN_IMAGE_FILE_SIZE,
MIN_VIDEO_FILE_SIZE,
SUPPORTED_VIDEO_CODECS,
is_valid_media as _is_valid_media,
safe_parse_fps as _safe_parse_fps,
)
logger = get_task_logger(__name__)
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS = {
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
def _safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like \"30/1\" or \"30000/1001\"."""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
"""
提取媒体文件的元数据。
@@ -207,38 +155,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
return metadata, success
def _is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: extract_media_metadata 返回的元数据
media_type: 媒体类型
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
logger.info("检测到非白名单视频编码 %s,仍允许 ingested,渲染层会统一转码", codec)
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
@celery_app.task(name="worker.ingest_asset")
def ingest_asset(job_id: str) -> dict:
"""
+110
View File
@@ -0,0 +1,110 @@
"""媒体文件有效性校验与元数据解析工具。
从 worker ingest 任务中抽取的纯逻辑模块,包含:
- FPS 解析:从分数格式字符串(如 30000/1001)安全解析帧率
- 媒体有效性校验:根据元数据判断视频/音频/图片文件是否有效
- 常量定义:最小文件大小、支持的视频编码白名单
"""
from __future__ import annotations
# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体
MIN_VIDEO_FILE_SIZE = 1024 # 1KB
MIN_AUDIO_FILE_SIZE = 100 # 100B
MIN_IMAGE_FILE_SIZE = 100 # 100B
# 支持的视频编码格式(白名单,尽可能放宽)
# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested
SUPPORTED_VIDEO_CODECS: frozenset[str] = frozenset(
{
"h264",
"avc1",
"avc", # H.264 / AVC
"hevc",
"h265",
"hev1",
"hvc1", # H.265 / HEVC
"vp9",
"vp09", # VP9
"av1",
"av01", # AV1
"vp8",
"vp08", # VP8
"mpeg4",
"mp4v", # MPEG-4
"mpeg2video",
"mpg2", # MPEG-2
"wmv2",
"wmv1",
"vc1", # WMV / VC-1
"flv1",
"flv",
"vp6f", # Flash / FLV
"theora",
"ogg", # Theora
"prores",
"prores_ks",
"apcn",
"apch",
"apco",
"apcs",
"ap4h",
"ap4x", # Apple ProRes
"dnxhd",
"dnxhr", # DNxHD / DNxHR
}
)
def safe_parse_fps(fps_str: str) -> float:
"""Safely parse fps from a fraction string like "30/1" or "30000/1001".
Args:
fps_str: FPS 字符串,支持小数格式("30.0")或分数格式("30000/1001"
Returns:
解析得到的帧率浮点数;解析失败或分母为0时返回 0.0
"""
try:
if "/" in fps_str:
num, den = fps_str.split("/", 1)
den_val = float(den)
if den_val == 0:
return 0.0
return float(num) / den_val
return float(fps_str)
except (ValueError, ZeroDivisionError):
return 0.0
def is_valid_media(metadata: dict, media_type: str) -> bool:
"""根据元数据判断文件是否为有效媒体文件。
Args:
metadata: 媒体元数据字典,可能包含 size_bytes / duration / codec / width / height 等
media_type: 媒体类型(video / audio / image
Returns:
True 表示文件有效
"""
size = int(metadata.get("size_bytes", 0))
if media_type == "video":
duration = float(metadata.get("duration", 0))
if size < MIN_VIDEO_FILE_SIZE or duration <= 0:
return False
# 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许
# 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截
codec = str(metadata.get("codec", "")).lower()
if codec and codec not in SUPPORTED_VIDEO_CODECS:
# 非白名单编码仍允许通过,仅记录日志(调用方负责日志)
pass
return True
if media_type == "audio":
duration = float(metadata.get("duration", 0))
return size >= MIN_AUDIO_FILE_SIZE and duration > 0
if media_type == "image":
width = int(metadata.get("width", 0))
height = int(metadata.get("height", 0))
return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0
return False
+499
View File
@@ -0,0 +1,499 @@
"""Application 层零测试模块合集 — 第100波里程碑。
覆盖:
- packages/application/generated_videos.py (8个UseCase)
- packages/application/assets.py (ListAssets + CreateAsset)
- packages/application/asset_libraries.py (ListLibraries + CreateLibrary)
策略: Mock repository,测参数校验 + 委托行为
"""
from unittest.mock import MagicMock
import pytest
from packages.application.asset_libraries import (
CreateAssetLibraryCommand,
CreateAssetLibraryUseCase,
ListAssetLibrariesUseCase,
)
from packages.application.assets import (
CreateAssetCommand,
CreateAssetUseCase,
ListAssetsUseCase,
)
from packages.application.generated_videos import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
GetVideosByIdsUseCase,
ListGeneratedVideosByTaskUseCase,
ListGeneratedVideosPaginatedUseCase,
ListGeneratedVideosUseCase,
UpdateVideoReviewStatusUseCase,
)
from packages.domain import AssetLibraryKind, AssetStatus, ClassificationStatus, GeneratedVideo
# ── generated_videos.py ────────────────────────────────────────────────────────
class TestListGeneratedVideosUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.list_by_project.return_value = [MagicMock(spec=GeneratedVideo)]
use_case = ListGeneratedVideosUseCase(mock_repo)
result = use_case.execute("proj1")
assert len(result) == 1
mock_repo.list_by_project.assert_called_once_with("proj1")
def test_strips_project_id(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
use_case.execute(" proj1 ")
mock_repo.list_by_project.assert_called_once_with("proj1")
def test_empty_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute("")
def test_whitespace_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute(" \t ")
class TestListGeneratedVideosPaginatedUseCase:
def test_default_params(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
result, total = use_case.execute()
assert total == 0
assert result == []
mock_repo.list_paginated.assert_called_once_with(
user_id=None,
project_id=None,
status=None,
review_status=None,
page=1,
page_size=20,
)
def test_page_below_1_clamps_to_1(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page=0)
mock_repo.list_paginated.assert_called_once()
call_kwargs = mock_repo.list_paginated.call_args.kwargs
assert call_kwargs["page"] == 1
def test_negative_page_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page=-5)
assert mock_repo.list_paginated.call_args.kwargs["page"] == 1
def test_page_size_zero_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=0)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20
def test_page_size_over_100_clamps(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=200)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20
def test_page_size_50_ok(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(page_size=50)
assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 50
def test_with_all_filters(self):
mock_repo = MagicMock()
mock_repo.list_paginated.return_value = ([], 0)
use_case = ListGeneratedVideosPaginatedUseCase(mock_repo)
use_case.execute(
user_id="u1",
project_id="p1",
status="completed",
review_status="approved",
page=2,
page_size=10,
)
mock_repo.list_paginated.assert_called_once_with(
user_id="u1",
project_id="p1",
status="completed",
review_status="approved",
page=2,
page_size=10,
)
class TestGetGeneratedVideoUseCase:
def test_found(self):
mock_repo = MagicMock()
expected = MagicMock(spec=GeneratedVideo)
mock_repo.get.return_value = expected
use_case = GetGeneratedVideoUseCase(mock_repo)
result = use_case.execute("vid1")
assert result == expected
mock_repo.get.assert_called_once_with("vid1")
def test_not_found(self):
mock_repo = MagicMock()
mock_repo.get.return_value = None
use_case = GetGeneratedVideoUseCase(mock_repo)
result = use_case.execute("vid1")
assert result is None
class TestListGeneratedVideosByTaskUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.list_by_generation_task.return_value = [MagicMock()]
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
result = use_case.execute("task1")
assert len(result) == 1
mock_repo.list_by_generation_task.assert_called_once_with("task1")
def test_strips_task_id(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
use_case.execute(" task1 ")
mock_repo.list_by_generation_task.assert_called_once_with("task1")
def test_empty_task_id_raises(self):
mock_repo = MagicMock()
use_case = ListGeneratedVideosByTaskUseCase(mock_repo)
with pytest.raises(ValueError, match="generation_task_id 不能为空"):
use_case.execute("")
class TestGetGeneratedVideoDownloadUrlUseCase:
def test_found(self):
mock_repo = MagicMock()
mock_item = MagicMock()
mock_item.file_url = "https://cdn/v.mp4"
mock_repo.get.return_value = mock_item
use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo)
result = use_case.execute("vid1")
assert result == "https://cdn/v.mp4"
def test_not_found_returns_none(self):
mock_repo = MagicMock()
mock_repo.get.return_value = None
use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo)
result = use_case.execute("vid1")
assert result is None
class TestUpdateVideoReviewStatusUseCase:
def test_pending_review(self):
mock_repo = MagicMock()
mock_repo.update_review_status.return_value = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "pending_review")
mock_repo.update_review_status.assert_called_once_with("vid1", "pending_review")
def test_approved(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "approved")
mock_repo.update_review_status.assert_called_once_with("vid1", "approved")
def test_rejected(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute("vid1", "rejected")
mock_repo.update_review_status.assert_called_once_with("vid1", "rejected")
def test_strips_video_id(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
use_case.execute(" vid1 ", "approved")
mock_repo.update_review_status.assert_called_once_with("vid1", "approved")
def test_empty_video_id_raises(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
with pytest.raises(ValueError, match="video_id 不能为空"):
use_case.execute("", "approved")
def test_invalid_status_raises(self):
mock_repo = MagicMock()
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
with pytest.raises(ValueError, match="无效的 review_status"):
use_case.execute("vid1", "invalid_status")
def test_not_found_returns_none(self):
mock_repo = MagicMock()
mock_repo.update_review_status.return_value = None
use_case = UpdateVideoReviewStatusUseCase(mock_repo)
result = use_case.execute("vid1", "approved")
assert result is None
class TestGetVideosByIdsUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.get_by_ids.return_value = [MagicMock(), MagicMock()]
use_case = GetVideosByIdsUseCase(mock_repo)
result = use_case.execute(["id1", "id2", "id3"])
assert len(result) == 2
mock_repo.get_by_ids.assert_called_once_with(["id1", "id2", "id3"])
def test_empty_list(self):
mock_repo = MagicMock()
mock_repo.get_by_ids.return_value = []
use_case = GetVideosByIdsUseCase(mock_repo)
result = use_case.execute([])
assert result == []
mock_repo.get_by_ids.assert_called_once_with([])
# ── assets.py ──────────────────────────────────────────────────────────────────
class TestCreateAssetCommand:
def test_minimal(self):
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="k",
mime_type="video/mp4",
)
assert cmd.project_id == "p1"
assert cmd.library_id == "l1"
assert cmd.name == "test.mp4"
assert cmd.storage_key == "k"
assert cmd.mime_type == "video/mp4"
assert cmd.file_size == 0
assert cmd.status == AssetStatus.UPLOADING
assert cmd.classification_status == ClassificationStatus.PENDING
def test_full(self):
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="k",
mime_type="video/mp4",
metadata={"k": "v"},
file_size=1024,
duration=10.0,
width=1920,
height=1080,
fps=30.0,
codec="h264",
status=AssetStatus.READY,
quality_score=0.9,
uploaded_by_user_id="u1",
)
assert cmd.file_size == 1024
assert cmd.duration == 10.0
assert cmd.status == AssetStatus.READY
assert cmd.quality_score == 0.9
class TestListAssetsUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.find_by_library.return_value = []
use_case = ListAssetsUseCase(mock_repo)
result = use_case.execute("lib1")
assert result == []
mock_repo.find_by_library.assert_called_once_with("lib1")
def test_strips_library_id(self):
mock_repo = MagicMock()
use_case = ListAssetsUseCase(mock_repo)
use_case.execute(" lib1 ")
mock_repo.find_by_library.assert_called_once_with("lib1")
def test_empty_library_id_raises(self):
mock_repo = MagicMock()
use_case = ListAssetsUseCase(mock_repo)
with pytest.raises(ValueError, match="library_id 不能为空"):
use_case.execute("")
class TestCreateAssetUseCase:
def test_creates_asset_via_repo(self):
mock_repo = MagicMock()
mock_repo.create.return_value = MagicMock()
use_case = CreateAssetUseCase(mock_repo)
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="test.mp4",
storage_key="videos/t.mp4",
mime_type="video/mp4",
file_size=1024,
)
result = use_case.execute(cmd)
assert result is not None
mock_repo.create.assert_called_once()
created_asset = mock_repo.create.call_args[0][0]
assert created_asset.project_id == "p1"
assert created_asset.name == "test.mp4"
assert created_asset.file_size == 1024
assert created_asset.status == AssetStatus.UPLOADING
def test_asset_create_validation_propagates(self):
mock_repo = MagicMock()
use_case = CreateAssetUseCase(mock_repo)
cmd = CreateAssetCommand(
project_id="p1",
library_id="l1",
name="",
storage_key="k",
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="素材名称不能为空"):
use_case.execute(cmd)
# ── asset_libraries.py ────────────────────────────────────────────────────────
class TestCreateAssetLibraryCommand:
def test_creation(self):
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="我的库",
kind=AssetLibraryKind.VIDEO,
)
assert cmd.project_id == "p1"
assert cmd.name == "我的库"
assert cmd.kind == AssetLibraryKind.VIDEO
class TestListAssetLibrariesUseCase:
def test_success(self):
mock_repo = MagicMock()
mock_repo.find_by_project.return_value = []
use_case = ListAssetLibrariesUseCase(mock_repo)
result = use_case.execute("p1")
assert result == []
mock_repo.find_by_project.assert_called_once_with("p1")
def test_strips_project_id(self):
mock_repo = MagicMock()
use_case = ListAssetLibrariesUseCase(mock_repo)
use_case.execute(" p1 ")
mock_repo.find_by_project.assert_called_once_with("p1")
def test_empty_project_id_raises(self):
mock_repo = MagicMock()
use_case = ListAssetLibrariesUseCase(mock_repo)
with pytest.raises(ValueError, match="project_id 不能为空"):
use_case.execute("")
class TestCreateAssetLibraryUseCase:
def test_creates_library_via_repo(self):
mock_repo = MagicMock()
mock_repo.create.return_value = MagicMock()
use_case = CreateAssetLibraryUseCase(mock_repo)
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="视频库",
kind=AssetLibraryKind.VIDEO,
)
result = use_case.execute(cmd)
assert result is not None
mock_repo.create.assert_called_once()
created = mock_repo.create.call_args[0][0]
assert created.project_id == "p1"
assert created.name == "视频库"
assert created.kind == AssetLibraryKind.VIDEO
def test_validation_propagates(self):
mock_repo = MagicMock()
use_case = CreateAssetLibraryUseCase(mock_repo)
cmd = CreateAssetLibraryCommand(
project_id="p1",
name="",
kind=AssetLibraryKind.VIDEO,
)
with pytest.raises(ValueError, match="素材库名称不能为空"):
use_case.execute(cmd)
+488
View File
@@ -0,0 +1,488 @@
"""Domain entities 单元测试。"""
from datetime import datetime, timezone
import pytest
from packages.domain.classification import (
AssetLibraryKind,
ClassificationStatus,
IngestJobStatus,
)
from packages.domain.entities import (
Asset,
AssetLibrary,
AssetStatus,
IngestJob,
Project,
User,
)
class TestProjectCreate:
def test_create_success(self):
project = Project.create(owner_user_id="user1", name="我的项目")
assert project.id is not None
assert len(project.id) == 32
assert project.owner_user_id == "user1"
assert project.name == "我的项目"
assert project.description == ""
assert project.shared_users == []
assert isinstance(project.created_at, datetime)
def test_create_with_description(self):
project = Project.create("u1", "Test Project", "A test description")
assert project.description == "A test description"
def test_create_strips_name(self):
project = Project.create("u1", " 带空格的项目 ")
assert project.name == "带空格的项目"
def test_create_strips_description(self):
project = Project.create("u1", "P1", " desc ")
assert project.description == "desc"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create("u1", "")
def test_create_whitespace_name(self):
with pytest.raises(ValueError, match="项目名称不能为空"):
Project.create("u1", " \t ")
def test_create_unique_ids(self):
p1 = Project.create("u1", "P1")
p2 = Project.create("u1", "P2")
assert p1.id != p2.id
class TestProjectAccess:
def test_is_owner_true(self):
project = Project.create("owner1", "P1")
assert project.is_owner("owner1") is True
def test_is_owner_false(self):
project = Project.create("owner1", "P1")
assert project.is_owner("other") is False
def test_is_shared_with_true(self):
project = Project.create("owner1", "P1")
project.shared_users = ["user_a", "user_b"]
assert project.is_shared_with("user_a") is True
assert project.is_shared_with("user_b") is True
def test_is_shared_with_false(self):
project = Project.create("owner1", "P1")
project.shared_users = ["user_a"]
assert project.is_shared_with("user_c") is False
def test_can_access_owner(self):
project = Project.create("owner1", "P1")
assert project.can_access("owner1") is True
def test_can_access_shared_user(self):
project = Project.create("owner1", "P1")
project.shared_users = ["shared_user"]
assert project.can_access("shared_user") is True
def test_cannot_access_other(self):
project = Project.create("owner1", "P1")
assert project.can_access("stranger") is False
def test_empty_shared_users(self):
project = Project.create("owner1", "P1")
assert project.shared_users == []
assert project.is_shared_with("anyone") is False
class TestAssetLibraryCreate:
def test_create_video_library(self):
lib = AssetLibrary.create("proj1", "视频素材库", AssetLibraryKind.VIDEO)
assert lib.id is not None
assert len(lib.id) == 32
assert lib.project_id == "proj1"
assert lib.name == "视频素材库"
assert lib.kind == AssetLibraryKind.VIDEO
assert lib.asset_count == 0
assert lib.total_size == 0
def test_create_voice_library(self):
lib = AssetLibrary.create("proj1", "音乐库", AssetLibraryKind.VOICE)
assert lib.kind == AssetLibraryKind.VOICE
def test_create_image_library(self):
lib = AssetLibrary.create("proj1", "图片库", AssetLibraryKind.IMAGE)
assert lib.kind == AssetLibraryKind.IMAGE
def test_create_strips_name(self):
lib = AssetLibrary.create("p1", " 我的库 ", AssetLibraryKind.VIDEO)
assert lib.name == "我的库"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create("p1", "", AssetLibraryKind.VIDEO)
def test_create_whitespace_name(self):
with pytest.raises(ValueError, match="素材库名称不能为空"):
AssetLibrary.create("p1", " \t ", AssetLibraryKind.VIDEO)
class TestAssetStatusEnum:
def test_basic_values(self):
assert AssetStatus.UPLOADING.value == "uploading"
assert AssetStatus.READY.value == "ready"
assert AssetStatus.PROCESSING.value == "processing"
assert AssetStatus.ERROR.value == "error"
assert AssetStatus.DELETED.value == "deleted"
def test_missing_uploaded_maps_to_ready(self):
assert AssetStatus("uploaded") == AssetStatus.READY
def test_missing_success_maps_to_ready(self):
assert AssetStatus("success") == AssetStatus.READY
def test_missing_ok_maps_to_ready(self):
assert AssetStatus("ok") == AssetStatus.READY
def test_missing_done_maps_to_ready(self):
assert AssetStatus("done") == AssetStatus.READY
def test_missing_complete_maps_to_ready(self):
assert AssetStatus("complete") == AssetStatus.READY
def test_missing_upload_maps_to_uploading(self):
assert AssetStatus("upload") == AssetStatus.UPLOADING
def test_missing_uploading_start_maps_to_uploading(self):
assert AssetStatus("uploading_start") == AssetStatus.UPLOADING
def test_missing_upload_start_maps_to_uploading(self):
assert AssetStatus("upload_start") == AssetStatus.UPLOADING
def test_missing_failed_maps_to_error(self):
assert AssetStatus("failed") == AssetStatus.ERROR
def test_missing_fail_maps_to_error(self):
assert AssetStatus("fail") == AssetStatus.ERROR
def test_missing_err_maps_to_error(self):
assert AssetStatus("err") == AssetStatus.ERROR
def test_missing_process_maps_to_processing(self):
assert AssetStatus("process") == AssetStatus.PROCESSING
def test_missing_running_maps_to_processing(self):
assert AssetStatus("running") == AssetStatus.PROCESSING
def test_missing_run_maps_to_processing(self):
assert AssetStatus("run") == AssetStatus.PROCESSING
def test_missing_unknown_value_falls_back_to_ready(self):
assert AssetStatus("completely_unknown_status") == AssetStatus.READY
def test_missing_empty_string_falls_back_to_ready(self):
assert AssetStatus("") == AssetStatus.READY
def test_missing_case_insensitive(self):
assert AssetStatus("UPLOADED") == AssetStatus.READY
assert AssetStatus("Success") == AssetStatus.READY
assert AssetStatus("FAILED") == AssetStatus.ERROR
def test_missing_with_whitespace(self):
assert AssetStatus(" uploaded ") == AssetStatus.READY
assert AssetStatus("\tfailed\n") == AssetStatus.ERROR
def test_missing_non_string_value(self):
assert AssetStatus(None) == AssetStatus.READY
assert AssetStatus(123) == AssetStatus.READY
def test_known_values_still_work(self):
assert AssetStatus("uploading") == AssetStatus.UPLOADING
assert AssetStatus("ready") == AssetStatus.READY
assert AssetStatus("processing") == AssetStatus.PROCESSING
assert AssetStatus("error") == AssetStatus.ERROR
assert AssetStatus("deleted") == AssetStatus.DELETED
class TestAssetCreate:
def test_create_minimal(self):
asset = Asset.create(
project_id="proj1",
library_id="lib1",
name="test.mp4",
storage_key="videos/test.mp4",
mime_type="video/mp4",
)
assert asset.id is not None
assert len(asset.id) == 32
assert asset.project_id == "proj1"
assert asset.library_id == "lib1"
assert asset.name == "test.mp4"
assert asset.storage_key == "videos/test.mp4"
assert asset.mime_type == "video/mp4"
assert asset.file_size == 0
assert asset.thumbnail_url is None
assert asset.duration is None
assert asset.width is None
assert asset.height is None
assert asset.status == AssetStatus.UPLOADING
assert asset.classification_status == ClassificationStatus.PENDING
assert asset.quality_score is None
assert asset.tag_ids == []
assert isinstance(asset.created_at, datetime)
assert isinstance(asset.updated_at, datetime)
def test_create_with_all_fields(self):
asset = Asset.create(
project_id="proj1",
library_id="lib1",
name="movie.mp4",
storage_key="v/m.mp4",
mime_type="video/mp4",
metadata={"key": "val"},
file_size=1024000,
thumbnail_url="http://cdn/thumb.jpg",
duration=120.5,
width=1920,
height=1080,
fps=30.0,
codec="h264",
status=AssetStatus.READY,
classification_status=ClassificationStatus.COMPLETED,
quality_score=0.85,
uploaded_by_user_id="user1",
file_hash="abc123",
)
assert asset.file_size == 1024000
assert asset.thumbnail_url == "http://cdn/thumb.jpg"
assert asset.duration == 120.5
assert asset.width == 1920
assert asset.height == 1080
assert asset.fps == 30.0
assert asset.codec == "h264"
assert asset.status == AssetStatus.READY
assert asset.classification_status == ClassificationStatus.COMPLETED
assert asset.quality_score == 0.85
assert asset.uploaded_by_user_id == "user1"
assert asset.file_hash == "abc123"
assert asset.metadata == {"key": "val"}
def test_create_strips_name(self):
asset = Asset.create("p1", "l1", " test.mp4 ", "k", "video/mp4")
assert asset.name == "test.mp4"
def test_create_strips_storage_key(self):
asset = Asset.create("p1", "l1", "n", " key.mp4 ", "video/mp4")
assert asset.storage_key == "key.mp4"
def test_create_strips_mime_type(self):
asset = Asset.create("p1", "l1", "n", "k", " video/mp4 ")
assert asset.mime_type == "video/mp4"
def test_create_empty_name(self):
with pytest.raises(ValueError, match="素材名称不能为空"):
Asset.create("p1", "l1", "", "k", "video/mp4")
def test_create_empty_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create("p1", "l1", "n", "", "video/mp4")
def test_create_empty_mime_type(self):
with pytest.raises(ValueError, match="mime_type 不能为空"):
Asset.create("p1", "l1", "n", "k", "")
def test_create_whitespace_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
Asset.create("p1", "l1", "n", " \t ", "video/mp4")
def test_create_none_metadata_defaults_to_empty_dict(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4", metadata=None)
assert asset.metadata == {}
def test_create_unique_ids(self):
a1 = Asset.create("p1", "l1", "n1", "k1", "video/mp4")
a2 = Asset.create("p1", "l1", "n2", "k2", "video/mp4")
assert a1.id != a2.id
class TestAssetFileType:
def test_video_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
assert asset.file_type == "video"
def test_audio_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "audio/mpeg")
assert asset.file_type == "audio"
def test_image_mime(self):
asset = Asset.create("p1", "l1", "n", "k", "image/jpeg")
assert asset.file_type == "image"
def test_simple_mime_no_slash(self):
asset = Asset.create("p1", "l1", "n", "k", "application")
assert asset.file_type == "application"
class TestAssetTags:
def test_add_tag(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("tag1")
assert "tag1" in asset.tag_ids
assert len(asset.tag_ids) == 1
def test_add_tag_strips(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag(" tag_trim ")
assert "tag_trim" in asset.tag_ids
def test_add_tag_duplicate_prevented(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("tag1")
asset.add_tag("tag1")
assert asset.tag_ids.count("tag1") == 1
assert len(asset.tag_ids) == 1
def test_add_tag_empty(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag("")
def test_add_tag_whitespace(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" \t ")
def test_add_multiple_tags(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.add_tag("t2")
asset.add_tag("t3")
assert asset.tag_ids == ["t1", "t2", "t3"]
def test_remove_tag(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.add_tag("t2")
asset.remove_tag("t1")
assert asset.tag_ids == ["t2"]
def test_remove_nonexistent_tag_idempotent(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
# 删除不存在的标签不报错
asset.remove_tag("nonexistent")
assert asset.tag_ids == ["t1"]
def test_remove_tag_strips(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
asset.remove_tag(" t1 ")
assert asset.tag_ids == []
def test_add_tag_updates_updated_at(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
old_time = asset.updated_at
asset.add_tag("t1")
assert asset.updated_at >= old_time
def test_remove_tag_updates_updated_at(self):
asset = Asset.create("p1", "l1", "n", "k", "video/mp4")
asset.add_tag("t1")
old_time = asset.updated_at
asset.remove_tag("t1")
assert asset.updated_at >= old_time
class TestIngestJobCreate:
def test_create_success(self):
job = IngestJob.create(
project_id="proj1",
library_id="lib1",
storage_key="videos/test.mp4",
)
assert job.id is not None
assert len(job.id) == 32
assert job.project_id == "proj1"
assert job.library_id == "lib1"
assert job.storage_key == "videos/test.mp4"
assert job.status == IngestJobStatus.PENDING
assert job.error_message == ""
assert job.result_asset_id == ""
assert job.file_hash == ""
def test_create_with_hash(self):
job = IngestJob.create("p1", "l1", "k", file_hash="abcdef123456")
assert job.file_hash == "abcdef123456"
def test_create_strips_project_id(self):
job = IngestJob.create(" p1 ", "l1", "k")
assert job.project_id == "p1"
def test_create_strips_library_id(self):
job = IngestJob.create("p1", " l1 ", "k")
assert job.library_id == "l1"
def test_create_strips_storage_key(self):
job = IngestJob.create("p1", "l1", " k ")
assert job.storage_key == "k"
def test_create_strips_file_hash(self):
job = IngestJob.create("p1", "l1", "k", file_hash=" hash ")
assert job.file_hash == "hash"
def test_create_empty_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create("", "l1", "k")
def test_create_empty_library_id(self):
with pytest.raises(ValueError, match="library_id 不能为空"):
IngestJob.create("p1", "", "k")
def test_create_empty_storage_key(self):
with pytest.raises(ValueError, match="storage_key 不能为空"):
IngestJob.create("p1", "l1", "")
def test_create_whitespace_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
IngestJob.create(" \t ", "l1", "k")
def test_create_unique_ids(self):
j1 = IngestJob.create("p1", "l1", "k1")
j2 = IngestJob.create("p1", "l1", "k2")
assert j1.id != j2.id
class TestUserDataclass:
def test_default_values(self):
user = User(id="u1", email="test@example.com", display_name="Test User")
assert user.id == "u1"
assert user.email == "test@example.com"
assert user.display_name == "Test User"
assert user.username == ""
assert user.password_hash == ""
assert user.email_verified is False
assert user.subscription_plan == "free"
assert user.subscription_status == "active"
assert user.max_projects == 3
assert user.max_storage_gb == 10
assert user.used_storage_gb == 0.0
assert user.is_admin is False
assert user.wechat_openid is None
assert user.phone is None
assert user.phone_verified is False
assert isinstance(user.created_at, datetime)
def test_admin_user(self):
user = User(id="admin", email="admin@test.com", display_name="Admin", is_admin=True)
assert user.is_admin is True
def test_pro_subscription(self):
user = User(
id="u1",
email="u@t.com",
display_name="U",
subscription_plan="pro",
max_storage_gb=100,
)
assert user.subscription_plan == "pro"
assert user.max_storage_gb == 100
+391
View File
@@ -0,0 +1,391 @@
"""Domain 小模块合集单元测试。
覆盖零测试的小 domain 模块:
- EditingMode 枚举
- Template / TemplateSegment
- TemplateClipConfig + ClipType + TransitionEffect
- EditTemplateVersion
- VoiceLibraryItem
- TitleLibraryItem
- Recipe / RecipeItem
"""
from datetime import datetime, timezone
import pytest
from packages.domain.editing_mode import EditingMode
from packages.domain.recipe import RecipeItem
from packages.domain.template import TemplateSegment
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
from packages.domain.template_version import EditTemplateVersion
from packages.domain.title_library import TitleLibraryItem
from packages.domain.voice_library import VoiceLibraryItem
class TestEditingMode:
def test_all_modes_exist(self):
assert EditingMode.ONE_TAKE.value == "one_take"
assert EditingMode.PIP.value == "pip"
assert EditingMode.VOICE_OVER.value == "voice_over"
assert EditingMode.VOICE_PIP.value == "voice_pip"
def test_from_string(self):
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
def test_invalid_mode_raises(self):
with pytest.raises(ValueError):
EditingMode("invalid_mode")
def test_is_str_enum(self):
# StrEnum 的值是字符串,可以直接比较
assert EditingMode.ONE_TAKE == "one_take"
class TestTemplateSegment:
def test_create_minimal(self):
seg = TemplateSegment(
id="seg1",
template_id="tpl1",
segment_order=1,
duration_min=5.0,
duration_max=10.0,
)
assert seg.id == "seg1"
assert seg.template_id == "tpl1"
assert seg.segment_order == 1
assert seg.duration_min == 5.0
assert seg.duration_max == 10.0
assert seg.material_type is None
assert isinstance(seg.created_at, datetime)
def test_create_with_material_type(self):
seg = TemplateSegment(
id="seg2",
template_id="tpl1",
segment_order=2,
duration_min=3.0,
duration_max=8.0,
material_type="人物",
)
assert seg.material_type == "人物"
class TestClipType:
def test_basic_types_exist(self):
assert hasattr(ClipType, "MAIN")
assert hasattr(ClipType, "INTRO")
assert hasattr(ClipType, "OUTRO")
assert hasattr(ClipType, "TRANSITION")
def test_values_are_strings(self):
for ct in ClipType:
assert isinstance(ct.value, str)
class TestTransitionEffect:
def test_effects_exist(self):
assert TransitionEffect.CUT.value == "cut"
assert TransitionEffect.FADE.value == "fade"
assert TransitionEffect.DISSOLVE.value == "dissolve"
# 至少有 5 种以上转场效果
assert len(list(TransitionEffect)) >= 5
class TestTemplateClipConfig:
def test_create_minimal(self):
config = TemplateClipConfig.create(
template_id="tpl1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=8.0,
)
assert config.id is not None
assert config.template_id == "tpl1"
assert config.clip_type == ClipType.MAIN
assert config.order == 1
assert config.min_duration == 3.0
assert config.max_duration == 8.0
def test_create_with_string_type(self):
config = TemplateClipConfig.create(
template_id="tpl1",
clip_type="intro",
order=0,
min_duration=2.0,
max_duration=5.0,
)
assert config.clip_type == ClipType.INTRO
def test_has_duration_range_true(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=8.0,
)
assert config.has_duration_range is True
def test_has_duration_range_false_when_both_zero(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert config.has_duration_range is False
def test_default_duration_midpoint(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=4.0,
max_duration=6.0,
)
assert config.default_duration == pytest.approx(5.0)
def test_default_duration_when_only_max(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
max_duration=5.0,
)
assert config.default_duration == 5.0
def test_create_negative_min_duration_raises(self):
with pytest.raises(ValueError, match="min_duration"):
TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=-1.0,
)
def test_create_min_greater_than_max_raises(self):
with pytest.raises(ValueError, match="min_duration.*max_duration"):
TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
min_duration=10.0,
max_duration=5.0,
)
def test_create_empty_template_id_raises(self):
with pytest.raises(ValueError, match="template_id"):
TemplateClipConfig.create(
template_id="",
clip_type=ClipType.MAIN,
order=1,
)
def test_default_transition_is_cut(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
)
assert config.transition_effect == TransitionEffect.CUT
def test_custom_transition_effect(self):
config = TemplateClipConfig.create(
template_id="t1",
clip_type=ClipType.MAIN,
order=1,
transition_effect="fade",
)
assert config.transition_effect == TransitionEffect.FADE
class TestEditTemplateVersion:
def test_create_minimal(self):
version = EditTemplateVersion.create(
template_id="tpl1",
version=1,
)
assert version.id is not None
assert len(version.id) == 32
assert version.template_id == "tpl1"
assert version.version == 1
assert version.config == {}
assert version.clip_configs == []
assert version.published_by == ""
assert version.change_note == ""
assert version.name == ""
assert version.editing_mode == "one_take"
assert isinstance(version.created_at, datetime)
def test_create_with_config_and_clip_configs(self):
version = EditTemplateVersion.create(
template_id="tpl1",
version=2,
config={"layout": "one_take"},
clip_configs=[{"clip_id": "c1", "type": "main"}],
published_by="user1",
change_note="添加了片头效果",
)
assert version.config == {"layout": "one_take"}
assert len(version.clip_configs) == 1
assert version.published_by == "user1"
assert version.change_note == "添加了片头效果"
def test_create_with_name_and_mode(self):
version = EditTemplateVersion.create(
template_id="t1",
version=1,
name="v1.0 正式版",
editing_mode="voice_over",
)
assert version.name == "v1.0 正式版"
assert version.editing_mode == "voice_over"
def test_create_unique_ids(self):
v1 = EditTemplateVersion.create("t1", 1)
v2 = EditTemplateVersion.create("t1", 2)
assert v1.id != v2.id
def test_none_config_defaults_to_empty_dict(self):
version = EditTemplateVersion.create("t1", 1, config=None)
assert version.config == {}
def test_none_clip_configs_defaults_to_empty_list(self):
version = EditTemplateVersion.create("t1", 1, clip_configs=None)
assert version.clip_configs == []
class TestVoiceLibraryItem:
def test_create_minimal(self):
item = VoiceLibraryItem(
id="v1",
user_id="u1",
name="我的配音",
)
assert item.id == "v1"
assert item.user_id == "u1"
assert item.name == "我的配音"
assert item.text == ""
assert item.voice_provider == ""
assert item.duration == 0
assert item.status == "completed"
assert item.tags == []
assert item.project_id is None
assert isinstance(item.created_at, datetime)
def test_create_with_all_fields(self):
item = VoiceLibraryItem(
id="v2",
user_id="u1",
name="产品介绍",
text="欢迎来到我们的产品",
voice_provider="cosyvoice",
voice_id="voice_001",
voice_name="温柔女声",
audio_url="https://cdn/v2.mp3",
duration=30.5,
file_size=102400,
status="processing",
project_id="proj1",
tags=["产品", "介绍"],
)
assert item.text == "欢迎来到我们的产品"
assert item.voice_provider == "cosyvoice"
assert item.voice_id == "voice_001"
assert item.audio_url == "https://cdn/v2.mp3"
assert item.duration == 30.5
assert item.file_size == 102400
assert item.status == "processing"
assert item.project_id == "proj1"
assert item.tags == ["产品", "介绍"]
class TestTitleLibraryItem:
def test_create_minimal(self):
item = TitleLibraryItem(
id="t1",
user_id="u1",
name="爆款标题1",
text="这是一个爆款标题",
)
assert item.id == "t1"
assert item.user_id == "u1"
assert item.name == "爆款标题1"
assert item.text == "这是一个爆款标题"
assert item.category == "default"
assert item.description == ""
assert item.tags == []
assert item.usage_count == 0
assert item.is_active is True
def test_create_with_category(self):
item = TitleLibraryItem(
id="t2",
user_id="u1",
name="美食标题",
text="太好吃了!",
category="美食",
)
assert item.category == "美食"
def test_inactive_item(self):
item = TitleLibraryItem(
id="t3",
user_id="u1",
name="旧标题",
text="旧文案",
is_active=False,
)
assert item.is_active is False
def test_usage_count_increment(self):
item = TitleLibraryItem(
id="t4",
user_id="u1",
name="T",
text="T",
)
item.usage_count += 1
assert item.usage_count == 1
class TestRecipeItem:
def test_create_minimal(self):
item = RecipeItem(
id="ri1",
recipe_id="r1",
item_type="asset",
item_id="asset_001",
)
assert item.id == "ri1"
assert item.recipe_id == "r1"
assert item.item_type == "asset"
assert item.item_id == "asset_001"
assert item.position == 0
assert item.metadata_ == {}
def test_create_with_position_and_metadata(self):
item = RecipeItem(
id="ri2",
recipe_id="r1",
item_type="title",
item_id="title_001",
position=2,
metadata_={"style": "bold"},
)
assert item.position == 2
assert item.metadata_ == {"style": "bold"}
def test_item_types_variety(self):
asset_item = RecipeItem(id="a", recipe_id="r", item_type="asset", item_id="i1")
title_item = RecipeItem(id="t", recipe_id="r", item_type="title", item_id="i2")
voice_item = RecipeItem(id="v", recipe_id="r", item_type="voice", item_id="i3")
assert asset_item.item_type == "asset"
assert title_item.item_type == "title"
assert voice_item.item_type == "voice"
+335 -320
View File
@@ -1,4 +1,6 @@
"""Job 领域单元测试 - job.py"""
"""Job 领域模型单元测试"""
from datetime import datetime, timezone
import pytest
@@ -10,45 +12,45 @@ from packages.domain.job import (
)
class TestJobType:
"""JobType 枚举测试"""
class TestJobTypeEnum:
def test_all_types_exist(self):
assert JobType.VIDEO_COMPOSE.value == "video_compose"
assert JobType.RENDER_EDIT_PLAN.value == "render_edit_plan"
assert JobType.ASSET_INGEST.value == "asset_ingest"
assert JobType.CLASSIFICATION.value == "classification"
assert JobType.VOICE_EXTRACTION.value == "voice_extraction"
assert JobType.GENERATION.value == "generation"
def test_all_types_have_values(self):
"""所有枚举成员都有字符串值"""
for jt in JobType:
assert isinstance(jt.value, str)
assert jt.value
def test_from_string(self):
assert JobType("video_compose") == JobType.VIDEO_COMPOSE
assert JobType("generation") == JobType.GENERATION
def test_str_enum_behavior(self):
"""是 str 枚举"""
assert JobType.VIDEO_COMPOSE == "video_compose"
assert isinstance(JobType.VIDEO_COMPOSE, str)
def test_known_types_exist(self):
"""核心任务类型都存在"""
assert JobType.VIDEO_COMPOSE
assert JobType.RENDER_EDIT_PLAN
assert JobType.ASSET_INGEST
assert JobType.CLASSIFICATION
assert JobType.GENERATION
def test_invalid_type_raises(self):
with pytest.raises(ValueError):
JobType("invalid_type")
class TestJobStatus:
"""JobStatus 枚举测试"""
class TestJobStatusEnum:
def test_all_statuses_exist(self):
assert JobStatus.PENDING.value == "pending"
assert JobStatus.RUNNING.value == "running"
assert JobStatus.SUCCESS.value == "success"
assert JobStatus.FAILED.value == "failed"
assert JobStatus.CANCELLED.value == "cancelled"
def test_all_statuses_have_values(self):
for js in JobStatus:
assert isinstance(js.value, str)
assert js.value
def test_from_string(self):
assert JobStatus("pending") == JobStatus.PENDING
assert JobStatus("success") == JobStatus.SUCCESS
def test_str_enum_behavior(self):
assert JobStatus.PENDING == "pending"
assert isinstance(JobStatus.PENDING, str)
def test_terminal_statuses(self):
"""终态集合包含成功/失败/取消"""
class TestTerminalStatuses:
def test_success_is_terminal(self):
assert JobStatus.SUCCESS in TERMINAL_STATUSES
def test_failed_is_terminal(self):
assert JobStatus.FAILED in TERMINAL_STATUSES
def test_cancelled_is_terminal(self):
assert JobStatus.CANCELLED in TERMINAL_STATUSES
def test_pending_not_terminal(self):
@@ -59,372 +61,376 @@ class TestJobStatus:
class TestJobCreate:
"""Job.create 工厂方法测试"""
def test_create_basic(self):
"""基本创建"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
)
assert job.id
assert len(job.id) == 32 # uuid4 hex
assert job.project_id == "proj-1"
def test_create_minimal(self):
job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE)
assert job.id is not None
assert len(job.id) == 32
assert job.project_id == "proj1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
assert job.current_stage == ""
assert job.payload == {}
assert job.result == {}
assert job.error_message == ""
assert job.retry_count == 0
assert job.max_retries == 3
assert job.created_at
assert job.updated_at
assert job.celery_task_id == ""
assert job.source_id == ""
assert job.created_by_user_id == ""
assert job.started_at is None
assert job.completed_at is None
assert isinstance(job.created_at, datetime)
assert isinstance(job.updated_at, datetime)
def test_create_with_string_job_type(self):
"""用字符串创建任务类型"""
job = Job.create(
project_id="proj-1",
job_type="video_compose",
)
def test_create_with_enum_type(self):
job = Job.create("p1", JobType.GENERATION)
assert job.job_type == JobType.GENERATION
def test_create_with_string_type(self):
job = Job.create("p1", "video_compose")
assert job.job_type == JobType.VIDEO_COMPOSE
def test_create_invalid_string_job_type_raises(self):
"""无效的任务类型字符串抛 ValueError"""
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="proj-1", job_type="invalid_type")
def test_create_empty_project_id_raises(self):
"""空 project_id 抛 ValueError"""
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_create_with_payload(self):
"""带 payload 创建"""
payload = {"video_id": "v1", "quality": "1080p"}
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload=payload,
)
payload = {"edit_plan_id": "plan123", "resolution": "1080p"}
job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=payload)
assert job.payload == payload
def test_create_with_source_id(self):
"""带 source_id 创建"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
source_id="plan-123",
)
assert job.source_id == "plan-123"
def test_create_with_created_by(self):
"""带创建人"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
created_by_user_id="user-1",
)
assert job.created_by_user_id == "user-1"
def test_create_with_custom_max_retries(self):
"""自定义最大重试次数"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
max_retries=5,
)
assert job.max_retries == 5
def test_create_project_id_stripped(self):
"""project_id 会被 strip"""
job = Job.create(
project_id=" proj-1 ",
job_type=JobType.VIDEO_COMPOSE,
)
assert job.project_id == "proj-1"
def test_create_source_id_stripped(self):
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
source_id=" src-1 ",
)
assert job.source_id == "src-1"
def test_create_created_by_stripped(self):
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
created_by_user_id=" user-1 ",
)
assert job.created_by_user_id == "user-1"
def test_create_none_payload_defaults_to_empty_dict(self):
"""payload=None 时默认为空 dict"""
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload=None,
)
def test_create_with_none_payload(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=None)
assert job.payload == {}
def test_create_with_source_id(self):
job = Job.create("p1", JobType.GENERATION, source_id="gen123")
assert job.source_id == "gen123"
class TestJobIsTerminal:
"""is_terminal 属性测试"""
def test_create_with_user_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id="user1")
assert job.created_by_user_id == "user1"
def test_create_with_custom_max_retries(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5)
assert job.max_retries == 5
def test_create_strips_project_id(self):
job = Job.create(" proj1 ", JobType.VIDEO_COMPOSE)
assert job.project_id == "proj1"
def test_create_strips_source_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, source_id=" src1 ")
assert job.source_id == "src1"
def test_create_strips_user_id(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id=" u1 ")
assert job.created_by_user_id == "u1"
def test_create_empty_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create("", JobType.VIDEO_COMPOSE)
def test_create_whitespace_project_id(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(" \t ", JobType.VIDEO_COMPOSE)
def test_create_invalid_job_type_string(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create("p1", "invalid_type")
def test_create_unique_ids(self):
j1 = Job.create("p1", JobType.VIDEO_COMPOSE)
j2 = Job.create("p1", JobType.VIDEO_COMPOSE)
assert j1.id != j2.id
class TestIsTerminal:
def test_pending_not_terminal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
assert job.is_terminal is False
def test_running_not_terminal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.is_terminal is False
def test_success_is_terminal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.is_terminal is True
def test_failed_is_terminal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.is_terminal is True
def test_cancelled_is_terminal(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.is_terminal is True
class TestJobTransitions:
"""状态转换测试"""
class TestIsRetryable:
def test_pending_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
assert job.is_retryable is False
def test_running_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.is_retryable is False
def test_success_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_retryable is False
def test_failed_within_limit_is_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("error")
assert job.is_retryable is True
def test_failed_at_limit_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("error")
job.retry_count = 3 # 已达到上限
assert job.is_retryable is False
def test_failed_over_limit_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.retry_count = 5
job.status = JobStatus.FAILED
assert job.is_retryable is False
def test_zero_max_retries_not_retryable(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0)
job.status = JobStatus.FAILED
assert job.is_retryable is False
class TestTransitionTo:
def test_pending_to_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
assert job.started_at is not None
def test_pending_to_success(self):
"""pending 可以直接到 success(快速成功)"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
assert job.completed_at is not None
def test_pending_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_running_to_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
assert job.completed_at is not None
def test_running_to_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
assert job.completed_at is not None
def test_running_to_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_failed_to_pending_retry(self):
"""失败后可以回到 pending(重试)"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_invalid_transition_raises(self):
"""非法状态转换抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
# pending 不能直接到 failed
def test_pending_to_failed_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.FAILED)
def test_success_to_pending_raises(self):
"""成功后不能回到 pending"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_running_to_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_running_to_failed(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
def test_running_to_cancelled(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_running_to_pending_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.PENDING)
def test_failed_to_pending(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
# 注意:_VALID_TRANSITIONS 中 FAILED → PENDING 是允许的
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_success_to_anything_invalid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
with pytest.raises(ValueError):
job.transition_to(JobStatus.PENDING)
job.transition_to(JobStatus.FAILED)
with pytest.raises(ValueError):
job.transition_to(JobStatus.RUNNING)
def test_transition_with_string_status(self):
"""用字符串做状态转换"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_transition_invalid_string_raises(self):
"""无效状态字符串抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_transition_with_invalid_string(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
def test_transition_updates_updated_at(self):
"""状态转换更新 updated_at"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time
time.sleep(0.001)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
old_time = job.updated_at
job.transition_to(JobStatus.RUNNING)
assert job.updated_at >= old_updated
assert job.updated_at >= old_time
def test_started_at_only_set_once(self):
"""started_at 只在第一次 RUNNING 时设置"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.transition_to(JobStatus.RUNNING)
first_started = job.started_at
job.transition_to(JobStatus.SUCCESS)
# 回到 pending 再 running(模拟重试场景,但started_at是None时才设置)
# 注意:正常重试是通过 prepare_retry 重置的
assert first_started is not None
first_start = job.started_at
# 再次 RUNNING 不合法,但我们测试 started_at 在多次 running→success→retry→running 时的行为
# 先失败重试
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
job.started_at = None # 模拟 prepare_retry 的重置
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
assert job.started_at != first_start
class TestJobMarkMethods:
"""便捷标记方法测试"""
def test_mark_running(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running("合成中")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "合成中"
def test_mark_running_no_stage(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
class TestMarkRunning:
def test_mark_running_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
assert job.status == JobStatus.RUNNING
assert job.current_stage == ""
assert job.started_at is not None
def test_mark_success(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"output_url": "http://..."})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.result == {"output_url": "http://..."}
def test_mark_running_with_stage(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running(stage="下载素材")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "下载素材"
def test_mark_success_no_result(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_mark_running_empty_stage_unchanged(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.current_stage = "已有阶段"
job.mark_running() # 不传 stage
assert job.current_stage == "已有阶段"
class TestMarkSuccess:
def test_mark_success_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.status == JobStatus.SUCCESS
assert job.result == {}
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.completed_at is not None
def test_mark_failed(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_mark_success_with_result(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
result = {"video_url": "https://...", "duration": 30}
job.mark_success(result=result)
assert job.result == result
def test_mark_success_without_result(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
original_result = job.result.copy()
job.mark_success()
assert job.result == original_result # 不变
class TestMarkFailed:
def test_mark_failed_basic(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("网络超时")
assert job.status == JobStatus.FAILED
assert job.error_message == "网络超时"
assert job.current_stage == "失败"
assert job.completed_at is not None
def test_mark_cancelled(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_mark_failed_empty_message(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("")
assert job.error_message == ""
class TestMarkCancelled:
def test_mark_cancelled_from_pending(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
def test_mark_cancelled_from_running(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
class TestJobProgress:
"""进度更新测试"""
def test_update_progress(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(50.0, "渲染中")
class TestUpdateProgress:
def test_update_progress_valid(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(50.0)
assert job.progress == 50.0
assert job.current_stage == "渲染中"
def test_update_progress_zero(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(0.0)
assert job.progress == 0.0
def test_update_progress_100(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_update_progress_hundred(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(100.0)
assert job.progress == 100.0
def test_update_progress_negative_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_update_progress_negative(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(-1.0)
def test_update_progress_over_100_raises(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_update_progress_over_100(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(101.0)
def test_update_progress_without_stage(self):
"""不传 stage 时不修改 current_stage"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.current_stage = "初始阶段"
job.update_progress(30.0)
def test_update_progress_with_stage(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.update_progress(30.0, stage="渲染中")
assert job.progress == 30.0
assert job.current_stage == "初始阶段"
assert job.current_stage == "渲染中"
def test_update_progress_updates_updated_at(self):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
old_updated = job.updated_at
import time
time.sleep(0.001)
def test_update_progress_without_stage_unchanged(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.current_stage = "原阶段"
job.update_progress(50.0)
assert job.updated_at >= old_updated
assert job.current_stage == "原阶段"
def test_update_progress_updates_timestamp(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
old_time = job.updated_at
job.update_progress(25.0)
assert job.updated_at >= old_time
class TestJobRetry:
"""重试逻辑测试"""
def test_is_retryable_failed_within_limit(self):
"""失败且未超过重试上限时可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("错误")
assert job.is_retryable is True
def test_is_retryable_failed_at_limit(self):
"""达到重试上限时不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
job.mark_running()
job.mark_failed("错误")
job.retry_count = 1
assert job.is_retryable is False
def test_is_retryable_pending_false(self):
"""pending 状态不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert job.is_retryable is False
def test_is_retryable_success_false(self):
"""成功状态不可重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
assert job.is_retryable is False
def test_prepare_retry(self):
"""准备重试"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
class TestPrepareRetry:
def test_prepare_retry_success(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("网络错误")
job.celery_task_id = "task-123"
job.prepare_retry()
@@ -437,38 +443,41 @@ class TestJobRetry:
assert job.completed_at is None
assert job.celery_task_id == ""
def test_prepare_retry_not_retryable_raises(self):
"""不可重试时抛 ValueError"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=0)
def test_prepare_retry_increments_count(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5)
job.mark_running()
job.mark_failed("错误")
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
job.mark_failed("err")
def test_prepare_retry_increments_correctly(self):
"""多次重试计数正确"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("错误1")
job.prepare_retry()
assert job.retry_count == 1
# 再次失败重试
job.mark_running()
job.mark_failed("错误2")
job.mark_failed("err2")
job.prepare_retry()
assert job.retry_count == 2
def test_prepare_retry_not_retryable_raises(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0)
job.mark_running()
job.mark_failed("err")
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
class TestJobToDict:
"""to_dict 序列化测试"""
def test_prepare_retry_wrong_status_raises(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
def test_to_dict_contains_all_fields(self):
class TestToDict:
def test_to_dict_structure(self):
job = Job.create(
project_id="p1",
job_type=JobType.VIDEO_COMPOSE,
payload={"key": "value"},
source_id="src-1",
created_by_user_id="user-1",
"p1",
JobType.VIDEO_COMPOSE,
payload={"key": "val"},
source_id="src1",
created_by_user_id="u1",
)
d = job.to_dict()
assert d["id"] == job.id
@@ -476,33 +485,39 @@ class TestJobToDict:
assert d["job_type"] == "video_compose"
assert d["status"] == "pending"
assert d["progress"] == 0.0
assert d["payload"] == {"key": "value"}
assert d["source_id"] == "src-1"
assert d["created_by_user_id"] == "user-1"
assert d["current_stage"] == ""
assert d["payload"] == {"key": "val"}
assert d["result"] == {}
assert d["error_message"] == ""
assert d["retry_count"] == 0
assert d["max_retries"] == 3
assert d["celery_task_id"] == ""
assert d["source_id"] == "src1"
assert d["created_by_user_id"] == "u1"
assert d["is_retryable"] is False
def test_to_dict_datetime_fields_are_strings(self):
"""时间字段序列化为 ISO 字符串"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert isinstance(d["created_at"], str)
assert isinstance(d["updated_at"], str)
def test_to_dict_none_datetime_fields(self):
"""未设置的时间字段为 None"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert d["started_at"] is None
assert d["completed_at"] is None
assert d["created_at"] is not None
assert d["updated_at"] is not None
def test_to_dict_after_success(self):
"""成功后 to_dict 状态正确"""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success({"url": "http://..."})
job = Job.create("p1", JobType.VIDEO_COMPOSE)
job.mark_running("渲染")
job.mark_success({"url": "https://..."})
d = job.to_dict()
assert d["status"] == "success"
assert d["progress"] == 100.0
assert d["result"] == {"url": "http://..."}
assert d["is_retryable"] is False
assert d["started_at"] is not None
assert d["completed_at"] is not None
assert isinstance(d["started_at"], str)
assert isinstance(d["completed_at"], str)
def test_to_dict_after_failed(self):
job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3)
job.mark_running()
job.mark_failed("timeout")
d = job.to_dict()
assert d["status"] == "failed"
assert d["error_message"] == "timeout"
assert d["is_retryable"] is True
+299
View File
@@ -0,0 +1,299 @@
"""media_validation 领域模块单元测试。"""
import pytest
from packages.domain.media_validation import (
MIN_AUDIO_FILE_SIZE,
MIN_IMAGE_FILE_SIZE,
MIN_VIDEO_FILE_SIZE,
SUPPORTED_VIDEO_CODECS,
is_valid_media,
safe_parse_fps,
)
class TestSafeParseFpsBasic:
def test_integer_fps(self):
assert safe_parse_fps("30") == 30.0
def test_decimal_fps(self):
assert safe_parse_fps("29.97") == pytest.approx(29.97)
def test_fraction_simple(self):
assert safe_parse_fps("30/1") == 30.0
def test_fraction_ntsc(self):
assert safe_parse_fps("30000/1001") == pytest.approx(29.97002997)
def test_fraction_pal(self):
assert safe_parse_fps("25/1") == 25.0
def test_fraction_24fps_cine(self):
assert safe_parse_fps("24000/1001") == pytest.approx(23.976023976)
def test_zero_fps(self):
assert safe_parse_fps("0") == 0.0
def test_zero_fraction(self):
assert safe_parse_fps("0/1") == 0.0
class TestSafeParseFpsEdgeCases:
def test_zero_denominator(self):
assert safe_parse_fps("30/0") == 0.0
def test_empty_string(self):
assert safe_parse_fps("") == 0.0
def test_garbage_string(self):
assert safe_parse_fps("not_a_number") == 0.0
def test_multiple_slashes(self):
# split("/", 1) 只切第一个,后面的作为 den 的一部分会解析失败
assert safe_parse_fps("30/1/2") == 0.0
def test_negative_fps(self):
assert safe_parse_fps("-30") == -30.0
def test_negative_fraction(self):
assert safe_parse_fps("-30/1") == -30.0
def test_very_high_fps(self):
assert safe_parse_fps("240/1") == 240.0
def test_fraction_float_num(self):
assert safe_parse_fps("29.97/1") == pytest.approx(29.97)
def test_fraction_float_den(self):
assert safe_parse_fps("30/1.001") == pytest.approx(29.97002997)
def test_whitespace_in_string(self):
# float(" 30 ") 能解析,所以应该返回 30.0
assert safe_parse_fps(" 30 ") == 30.0
class TestMinFileSizeConstants:
def test_min_video_size_is_1kb(self):
assert MIN_VIDEO_FILE_SIZE == 1024
def test_min_audio_size(self):
assert MIN_AUDIO_FILE_SIZE == 100
def test_min_image_size(self):
assert MIN_IMAGE_FILE_SIZE == 100
class TestSupportedVideoCodecs:
def test_h264_family_present(self):
assert "h264" in SUPPORTED_VIDEO_CODECS
assert "avc1" in SUPPORTED_VIDEO_CODECS
assert "avc" in SUPPORTED_VIDEO_CODECS
def test_h265_family_present(self):
assert "hevc" in SUPPORTED_VIDEO_CODECS
assert "h265" in SUPPORTED_VIDEO_CODECS
assert "hev1" in SUPPORTED_VIDEO_CODECS
assert "hvc1" in SUPPORTED_VIDEO_CODECS
def test_vp9_av1_present(self):
assert "vp9" in SUPPORTED_VIDEO_CODECS
assert "vp09" in SUPPORTED_VIDEO_CODECS
assert "av1" in SUPPORTED_VIDEO_CODECS
assert "av01" in SUPPORTED_VIDEO_CODECS
def test_vp8_present(self):
assert "vp8" in SUPPORTED_VIDEO_CODECS
assert "vp08" in SUPPORTED_VIDEO_CODECS
def test_mpeg_family_present(self):
assert "mpeg4" in SUPPORTED_VIDEO_CODECS
assert "mp4v" in SUPPORTED_VIDEO_CODECS
assert "mpeg2video" in SUPPORTED_VIDEO_CODECS
def test_prores_family_present(self):
assert "prores" in SUPPORTED_VIDEO_CODECS
assert "apcn" in SUPPORTED_VIDEO_CODECS
assert "apch" in SUPPORTED_VIDEO_CODECS
def test_unknown_codec_not_present(self):
assert "unknown_codec_xyz" not in SUPPORTED_VIDEO_CODECS
def test_codecs_count_reasonable(self):
# 白名单应该有足够多的编码格式
assert len(SUPPORTED_VIDEO_CODECS) >= 30
class TestIsValidMediaVideo:
def test_valid_video(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_video_too_small(self):
metadata = {"size_bytes": 500, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_exact_min_size(self):
metadata = {"size_bytes": 1024, "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_video_zero_duration(self):
metadata = {"size_bytes": 5000, "duration": 0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_negative_duration(self):
metadata = {"size_bytes": 5000, "duration": -1.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_missing_size_default_zero(self):
metadata = {"duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_missing_duration_default_zero(self):
metadata = {"size_bytes": 5000, "codec": "h264"}
assert is_valid_media(metadata, "video") is False
def test_video_unknown_codec_still_valid(self):
# 非白名单编码仍允许通过(渲染层统一转码)
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "some_unknown_codec"}
assert is_valid_media(metadata, "video") is True
def test_video_missing_codec_still_valid(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "video") is True
def test_video_codec_case_insensitive(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "H264"}
assert is_valid_media(metadata, "video") is True
def test_video_empty_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": ""}
assert is_valid_media(metadata, "video") is True
def test_video_hevc_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "hevc"}
assert is_valid_media(metadata, "video") is True
def test_video_vp9_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "vp9"}
assert is_valid_media(metadata, "video") is True
def test_video_av1_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "av1"}
assert is_valid_media(metadata, "video") is True
def test_video_prores_codec(self):
metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "prores"}
assert is_valid_media(metadata, "video") is True
def test_video_empty_metadata(self):
assert is_valid_media({}, "video") is False
class TestIsValidMediaAudio:
def test_valid_audio(self):
metadata = {"size_bytes": 5000, "duration": 30.0}
assert is_valid_media(metadata, "audio") is True
def test_audio_too_small(self):
metadata = {"size_bytes": 50, "duration": 30.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_exact_min_size(self):
metadata = {"size_bytes": 100, "duration": 10.0}
assert is_valid_media(metadata, "audio") is True
def test_audio_zero_duration(self):
metadata = {"size_bytes": 5000, "duration": 0}
assert is_valid_media(metadata, "audio") is False
def test_audio_negative_duration(self):
metadata = {"size_bytes": 5000, "duration": -1.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_missing_size(self):
metadata = {"duration": 10.0}
assert is_valid_media(metadata, "audio") is False
def test_audio_missing_duration(self):
metadata = {"size_bytes": 5000}
assert is_valid_media(metadata, "audio") is False
def test_audio_with_codec_info(self):
metadata = {"size_bytes": 5000, "duration": 30.0, "codec": "aac"}
assert is_valid_media(metadata, "audio") is True
def test_audio_empty_metadata(self):
assert is_valid_media({}, "audio") is False
class TestIsValidMediaImage:
def test_valid_image(self):
metadata = {"size_bytes": 5000, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is True
def test_image_too_small(self):
metadata = {"size_bytes": 50, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_exact_min_size(self):
metadata = {"size_bytes": 100, "width": 100, "height": 100}
assert is_valid_media(metadata, "image") is True
def test_image_zero_width(self):
metadata = {"size_bytes": 5000, "width": 0, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_zero_height(self):
metadata = {"size_bytes": 5000, "width": 1920, "height": 0}
assert is_valid_media(metadata, "image") is False
def test_image_negative_dimensions(self):
metadata = {"size_bytes": 5000, "width": -1, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_missing_width(self):
metadata = {"size_bytes": 5000, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_missing_height(self):
metadata = {"size_bytes": 5000, "width": 1920}
assert is_valid_media(metadata, "image") is False
def test_image_missing_size(self):
metadata = {"width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_image_small_but_valid(self):
metadata = {"size_bytes": 100, "width": 1, "height": 1}
assert is_valid_media(metadata, "image") is True
def test_image_empty_metadata(self):
assert is_valid_media({}, "image") is False
class TestIsValidMediaUnknownType:
def test_unknown_type_returns_false(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "unknown") is False
def test_empty_type_returns_false(self):
metadata = {"size_bytes": 5000, "duration": 10.0}
assert is_valid_media(metadata, "") is False
def test_text_type_returns_false(self):
metadata = {"size_bytes": 5000}
assert is_valid_media(metadata, "text") is False
class TestIsValidMediaSizeTypes:
def test_size_as_string(self):
# int("5000") 能解析
metadata = {"size_bytes": "5000", "duration": 10.0, "codec": "h264"}
assert is_valid_media(metadata, "video") is True
def test_size_as_none(self):
# int(None) 会 TypeError,但 metadata.get 返回 0 默认值
metadata = {"size_bytes": None, "duration": 10.0, "codec": "h264"}
# int(None) 会抛 TypeError
with pytest.raises(TypeError):
is_valid_media(metadata, "video")
+201
View File
@@ -0,0 +1,201 @@
"""Preset BGM 预设背景音乐单元测试。"""
import pytest
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
class TestPresetBGMDataclass:
def test_creation_required_fields(self):
bgm = PresetBGM(id="test_001", name="Test BGM", style="upbeat", duration=120.0)
assert bgm.id == "test_001"
assert bgm.name == "Test BGM"
assert bgm.style == "upbeat"
assert bgm.duration == 120.0
assert bgm.artist == ""
assert bgm.description == ""
assert bgm.tags == []
assert bgm.audio_url == ""
def test_creation_all_fields(self):
bgm = PresetBGM(
id="test_002",
name="Full BGM",
style="relax",
duration=180.5,
artist="Artist Name",
description="A test description",
tags=["tag1", "tag2"],
audio_url="https://cdn/test.mp3",
)
assert bgm.artist == "Artist Name"
assert bgm.description == "A test description"
assert bgm.tags == ["tag1", "tag2"]
assert bgm.audio_url == "https://cdn/test.mp3"
def test_frozen_immutable(self):
bgm = PresetBGM(id="t1", name="T", style="upbeat", duration=60.0)
with pytest.raises(Exception): # FrozenInstanceError
bgm.name = "new name"
def test_equality(self):
bgm1 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0)
bgm2 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0)
assert bgm1 == bgm2
def test_inequality(self):
bgm1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0)
bgm2 = PresetBGM(id="b", name="B", style="upbeat", duration=60.0)
assert bgm1 != bgm2
def test_frozen_with_list_field_not_hashable(self):
# 包含 list 字段的 frozen dataclass 仍然不可哈希(list 不可哈希)
bgm = PresetBGM(id="h1", name="H", style="upbeat", duration=60.0, tags=["a"])
with pytest.raises(TypeError, match="unhashable"):
hash(bgm)
class TestPresetBGMLibrary:
def test_library_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_library_has_entries(self):
assert len(PRESET_BGM_LIBRARY) >= 10
def test_all_have_unique_ids(self):
ids = [bgm.id for bgm in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_have_valid_styles(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.style in BGM_STYLES
def test_all_have_positive_duration(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0
def test_all_have_non_empty_name(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.name.strip() != ""
class TestBGMStyles:
def test_styles_dict_keys(self):
assert "upbeat" in BGM_STYLES
assert "relax" in BGM_STYLES
assert "tech" in BGM_STYLES
assert "commerce" in BGM_STYLES
assert "emotional" in BGM_STYLES
assert "cinematic" in BGM_STYLES
def test_styles_have_chinese_names(self):
for key, value in BGM_STYLES.items():
assert isinstance(value, str)
assert len(value) > 0
class TestGetPresetBGM:
def test_get_existing(self):
bgm = get_preset_bgm("bgm_upbeat_001")
assert bgm is not None
assert bgm.id == "bgm_upbeat_001"
assert bgm.name == "阳光清晨"
assert bgm.style == "upbeat"
def test_get_nonexistent(self):
assert get_preset_bgm("nonexistent_id") is None
def test_get_empty_string(self):
assert get_preset_bgm("") is None
def test_get_returns_same_object(self):
bgm1 = get_preset_bgm("bgm_relax_001")
bgm2 = get_preset_bgm("bgm_relax_001")
assert bgm1 is bgm2 # 同一实例(引用同一列表中的对象)
class TestListPresetBGMByStyle:
def test_list_upbeat(self):
results = list_preset_bgm_by_style("upbeat")
assert len(results) >= 3
for bgm in results:
assert bgm.style == "upbeat"
def test_list_relax(self):
results = list_preset_bgm_by_style("relax")
assert len(results) >= 3
for bgm in results:
assert bgm.style == "relax"
def test_list_tech(self):
results = list_preset_bgm_by_style("tech")
assert len(results) >= 2
for bgm in results:
assert bgm.style == "tech"
def test_list_commerce(self):
results = list_preset_bgm_by_style("commerce")
assert len(results) >= 2
for bgm in results:
assert bgm.style == "commerce"
def test_list_empty_style(self):
results = list_preset_bgm_by_style("nonexistent_style")
assert results == []
def test_list_preserves_order(self):
results = list_preset_bgm_by_style("upbeat")
ids = [b.id for b in results]
# 应该按照在列表中的出现顺序排列
assert ids == sorted(ids, key=lambda x: PRESET_BGM_LIBRARY.index(get_preset_bgm(x)))
class TestSearchPresetBGM:
def test_search_by_name(self):
results = search_preset_bgm("阳光")
assert len(results) >= 1
assert any("阳光" in b.name for b in results)
def test_search_by_description(self):
results = search_preset_bgm("钢琴")
assert len(results) >= 1
# 钢琴出现在名称或描述或标签中
found = False
for b in results:
if "钢琴" in b.description or "钢琴" in b.name or "钢琴" in b.tags:
found = True
break
assert found
def test_search_by_tag(self):
results = search_preset_bgm("科技")
assert len(results) >= 1
found_tech = any(b.style == "tech" for b in results)
assert found_tech
def test_search_case_insensitive(self):
results1 = search_preset_bgm("Tech")
results2 = search_preset_bgm("tech")
assert len(results1) == len(results2)
def test_search_no_match(self):
results = search_preset_bgm("zzzzzzzzzzz_nonexistent_keyword")
assert results == []
def test_search_empty_keyword(self):
# 空字符串应该匹配所有(因为空字符串 in 任何字符串都是 True)
results = search_preset_bgm("")
assert len(results) == len(PRESET_BGM_LIBRARY)
def test_search_no_duplicates(self):
# 确保同一个 BGM 不会出现多次
results = search_preset_bgm("电子")
ids = [b.id for b in results]
assert len(ids) == len(set(ids))
+223 -285
View File
@@ -1,13 +1,11 @@
"""Quota 领域层单元测试 - quota.py"""
import math
"""Quota 配额系统单元测试。"""
import pytest
from packages.domain.quota import (
QUOTA_TIERS,
QuotaChecker,
QuotaCheckResult,
QuotaChecker,
QuotaDimension,
QuotaRegistry,
QuotaTier,
@@ -19,103 +17,114 @@ from packages.domain.quota import (
class TestQuotaDimension:
"""QuotaDimension 枚举测试"""
def test_core_dimensions_exist(self):
assert QuotaDimension.STORAGE_GB.value == "storage_gb"
assert QuotaDimension.VIDEOS_PER_MONTH.value == "videos_per_month"
assert QuotaDimension.MAX_CONCURRENT.value == "max_concurrent"
assert QuotaDimension.MAX_TEMPLATES.value == "max_templates"
assert QuotaDimension.MAX_TITLES.value == "max_titles"
assert QuotaDimension.MAX_VOICEOVERS.value == "max_voiceovers"
assert QuotaDimension.AI_VOICE_ENABLED.value == "ai_voice_enabled"
def test_all_dimensions_have_values(self):
"""所有枚举成员都有字符串值"""
def test_extended_dimensions_exist(self):
assert QuotaDimension.AI_VOICE_CREDITS.value == "ai_voice_credits"
assert QuotaDimension.BATCH_EXPORT_ENABLED.value == "batch_export_enabled"
assert QuotaDimension.MULTI_PLATFORM_ENABLED.value == "multi_platform_enabled"
assert QuotaDimension.DEDUP_REPORT_ENABLED.value == "dedup_report_enabled"
def test_all_dimensions_are_strings(self):
for dim in QuotaDimension:
assert isinstance(dim.value, str)
assert dim.value
def test_dimension_count(self):
"""配额维度数量 >= 内置维度"""
# 至少有 storage_gb, videos_per_month, max_concurrent, max_templates 等
assert len(QuotaDimension) >= 7
def test_str_enum_behavior(self):
"""是 str 枚举,可直接当字符串用"""
assert QuotaDimension.STORAGE_GB == "storage_gb"
assert isinstance(QuotaDimension.STORAGE_GB, str)
class TestQuotaTier:
"""QuotaTier 测试"""
def test_get_limit_defined(self):
"""已定义的维度返回正确值"""
tier = QuotaTier(name="test", limits={"storage": 10, "videos": 5})
assert tier.get_limit("storage") == 10
tier = QuotaTier(name="test", limits={"storage_gb": 10, "videos": 5})
assert tier.get_limit("storage_gb") == 10
assert tier.get_limit("videos") == 5
def test_get_limit_undefined_returns_zero(self):
"""未定义的维度返回 0"""
tier = QuotaTier(name="test", limits={"storage": 10})
assert tier.get_limit("unknown") == 0
tier = QuotaTier(name="test", limits={"storage_gb": 10})
assert tier.get_limit("unknown_dim") == 0
def test_is_unlimited_true(self):
"""不限量判断 - inf"""
def test_is_unlimited_false_for_finite(self):
tier = QuotaTier(name="test", limits={"storage_gb": 10})
assert tier.is_unlimited("storage_gb") is False
def test_is_unlimited_true_for_inf(self):
tier = QuotaTier(name="test", limits={"templates": float("inf")})
assert tier.is_unlimited("templates") is True
def test_is_unlimited_false(self):
"""限量判断"""
tier = QuotaTier(name="test", limits={"storage": 10})
assert tier.is_unlimited("storage") is False
def test_is_unlimited_undefined_returns_true(self):
"""未定义的维度默认 infis_unlimited 返回 True"""
def test_is_unlimited_undefined(self):
tier = QuotaTier(name="test", limits={})
# get_limit 用 dict.get 默认 0,但 is_unlimited 用 dict.get 默认 inf
# 未定义的维度,limits.get 返回默认 inf,所以 is_unlimited 返回 True
assert tier.is_unlimited("unknown") is True
def test_empty_limits(self):
tier = QuotaTier(name="empty")
assert tier.limits == {}
assert tier.name == "empty"
class TestQuotaTiers:
"""内置套餐配额测试"""
def test_three_tiers_exist(self):
"""三个套餐等级都存在"""
assert "free" in QUOTA_TIERS
assert "basic" in QUOTA_TIERS
assert "premium" in QUOTA_TIERS
def test_free_tier_storage(self):
"""free 套餐 2GB 存储"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.STORAGE_GB) == 2
def test_free_tier_limits(self):
free = QUOTA_TIERS["free"]
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
assert free.get_limit("max_concurrent") == 3
assert free.get_limit("max_templates") == 3
assert free.get_limit("max_titles") == 50
assert free.get_limit("max_voiceovers") == 10
assert free.get_limit("ai_voice_enabled") == 0
def test_basic_tier_storage(self):
"""basic 套餐 20GB 存储"""
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.STORAGE_GB) == 20
def test_basic_tier_limits(self):
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("storage_gb") == 20
assert basic.get_limit("videos_per_month") == 30
assert basic.get_limit("max_concurrent") == 10
assert basic.get_limit("max_templates") == 15
assert basic.get_limit("max_titles") == 500
assert basic.get_limit("max_voiceovers") == 100
assert basic.get_limit("ai_voice_enabled") == 1
assert basic.get_limit("ai_voice_credits") == 100
assert basic.get_limit("batch_export_enabled") == 1
def test_premium_tier_storage(self):
"""premium 套餐 100GB 存储"""
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.STORAGE_GB) == 100
def test_premium_tier_limits(self):
premium = QUOTA_TIERS["premium"]
assert premium.get_limit("storage_gb") == 100
assert premium.get_limit("videos_per_month") == 100
assert premium.get_limit("max_concurrent") == 20
assert premium.is_unlimited("max_templates") is True
assert premium.get_limit("ai_voice_enabled") == 1
assert premium.get_limit("ai_voice_credits") == 500
assert premium.get_limit("batch_export_enabled") == 1
assert premium.get_limit("multi_platform_enabled") == 1
assert premium.get_limit("dedup_report_enabled") == 1
def test_free_no_ai_voice(self):
"""free 套餐没有 AI 配音"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 0
def test_basic_has_ai_voice(self):
"""basic 套餐有 AI 配音"""
assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 1
def test_premium_templates_unlimited(self):
"""premium 套餐模板不限量"""
assert QUOTA_TIERS["premium"].is_unlimited(QuotaDimension.MAX_TEMPLATES) is True
def test_free_videos_per_month(self):
"""free 每月 5 个视频"""
assert QUOTA_TIERS["free"].get_limit(QuotaDimension.VIDEOS_PER_MONTH) == 5
def test_premium_multi_platform_enabled(self):
"""premium 支持多平台发布"""
assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.MULTI_PLATFORM_ENABLED) == 1
def test_tier_increase_monotonic(self):
free = QUOTA_TIERS["free"]
basic = QUOTA_TIERS["basic"]
premium = QUOTA_TIERS["premium"]
# 高级套餐应该 >= 低级套餐的所有限制
for dim in [
"storage_gb",
"videos_per_month",
"max_concurrent",
"max_titles",
"max_voiceovers",
"ai_voice_credits",
]:
assert basic.get_limit(dim) >= free.get_limit(dim)
assert premium.get_limit(dim) >= basic.get_limit(dim)
class TestQuotaWarningLevel:
"""告警级别常量测试"""
def test_level_values(self):
"""四个告警级别都有定义"""
def test_levels_exist(self):
assert QuotaWarningLevel.NORMAL == "normal"
assert QuotaWarningLevel.WARNING == "warning"
assert QuotaWarningLevel.CRITICAL == "critical"
@@ -123,302 +132,231 @@ class TestQuotaWarningLevel:
class TestQuotaCheckResult:
"""QuotaCheckResult 测试"""
def test_usage_percent_normal(self):
"""正常使用百分比计算"""
result = QuotaCheckResult(
allowed=True,
dimension="storage",
dimension="storage_gb",
limit=100,
used=30,
remaining=70,
warning_level=QuotaWarningLevel.NORMAL,
used=50,
remaining=50,
warning_level="normal",
)
assert result.usage_percent == 30.0
assert result.usage_percent == 50.0
def test_usage_percent_capped_at_100(self):
"""超过 100% 时截断为 100%"""
def test_usage_percent_exceeded(self):
result = QuotaCheckResult(
allowed=False,
dimension="storage",
limit=100,
used=150,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
allowed=False, dimension="d", limit=100, used=150, remaining=0, warning_level="exceeded"
)
assert result.usage_percent == 100.0
assert result.usage_percent == 100.0 # min(100, 150%)
def test_usage_percent_zero_used(self):
result = QuotaCheckResult(allowed=True, dimension="d", limit=100, used=0, remaining=100, warning_level="normal")
assert result.usage_percent == 0.0
def test_usage_percent_zero_limit_with_usage(self):
"""limit=0 但有使用量,返回 100%"""
result = QuotaCheckResult(
allowed=False,
dimension="storage",
limit=0,
used=5,
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
result = QuotaCheckResult(allowed=False, dimension="d", limit=0, used=10, remaining=0, warning_level="exceeded")
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_no_usage(self):
"""limit=0 且无使用量,返回 0%"""
result = QuotaCheckResult(
allowed=True,
dimension="storage",
limit=0,
used=0,
remaining=0,
warning_level=QuotaWarningLevel.NORMAL,
)
result = QuotaCheckResult(allowed=True, dimension="d", limit=0, used=0, remaining=0, warning_level="normal")
assert result.usage_percent == 0.0
def test_usage_percent_unlimited(self):
"""不限量时使用百分比为 0"""
result = QuotaCheckResult(
allowed=True,
dimension="templates",
dimension="d",
limit=float("inf"),
used=50,
used=1000,
remaining=float("inf"),
warning_level=QuotaWarningLevel.NORMAL,
warning_level="normal",
)
assert result.usage_percent == 0.0
class TestQuotaRegistry:
"""QuotaRegistry 测试"""
def test_initial_dimensions(self):
"""初始化时内置维度已注册"""
registry = QuotaRegistry()
dims = registry.list_dimensions()
assert QuotaDimension.STORAGE_GB in dims
assert QuotaDimension.VIDEOS_PER_MONTH in dims
reg = QuotaRegistry()
dims = reg.list_dimensions()
assert "storage_gb" in dims
assert "videos_per_month" in dims
assert len(dims) == len(QuotaDimension)
def test_initial_tiers(self):
"""初始化时三个套餐已注册"""
registry = QuotaRegistry()
tiers = registry.list_tiers()
def test_list_tiers(self):
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "free" in tiers
assert "basic" in tiers
assert "premium" in tiers
def test_register_new_dimension(self):
"""注册新的配额维度"""
registry = QuotaRegistry()
registry.register_dimension("custom_dim", "自定义维度")
dims = registry.list_dimensions()
assert "custom_dim" in dims
assert dims["custom_dim"] == "自定义维度"
def test_register_dimension_idempotent(self):
"""重复注册是幂等的"""
registry = QuotaRegistry()
registry.register_dimension("custom", "描述1")
registry.register_dimension("custom", "描述2")
# 保留第一次注册的描述
assert registry.list_dimensions()["custom"] == "描述1"
def test_register_with_default_limits(self):
"""注册时指定各套餐的默认限制"""
registry = QuotaRegistry()
registry.register_dimension(
"custom",
"自定义",
default_limits={"free": 1, "basic": 10, "premium": 100},
)
assert registry.get_limit("free", "custom") == 1
assert registry.get_limit("basic", "custom") == 10
assert registry.get_limit("premium", "custom") == 100
def test_register_without_default_limits_defaults_to_zero(self):
"""不指定默认限制时各套餐该维度为 0"""
registry = QuotaRegistry()
registry.register_dimension("custom_no_limit", "自定义")
assert registry.get_limit("free", "custom_no_limit") == 0
assert registry.get_limit("basic", "custom_no_limit") == 0
def test_register_default_limits_ignores_unknown_plan(self):
"""默认限制中未知的套餐名被忽略"""
registry = QuotaRegistry()
registry.register_dimension(
"custom",
"自定义",
default_limits={"nonexistent": 999},
)
# 不报错,但也不会创建新套餐
assert registry.get_tier("nonexistent") is None
assert len(tiers) == 3
def test_get_tier_existing(self):
"""获取存在的套餐"""
registry = QuotaRegistry()
tier = registry.get_tier("free")
reg = QuotaRegistry()
tier = reg.get_tier("free")
assert tier is not None
assert tier.name == "free"
def test_get_tier_nonexistent(self):
"""获取不存在的套餐返回 None"""
registry = QuotaRegistry()
assert registry.get_tier("enterprise") is None
def test_get_tier_unknown(self):
reg = QuotaRegistry()
assert reg.get_tier("unknown_plan") is None
def test_get_limit_existing(self):
"""获取存在的套餐和维度的限制"""
registry = QuotaRegistry()
assert registry.get_limit("free", QuotaDimension.STORAGE_GB) == 2
def test_get_limit_known(self):
reg = QuotaRegistry()
assert reg.get_limit("free", "storage_gb") == 2
assert reg.get_limit("premium", "storage_gb") == 100
def test_get_limit_nonexistent_plan(self):
"""不存在的套餐返回 0"""
registry = QuotaRegistry()
assert registry.get_limit("unknown", QuotaDimension.STORAGE_GB) == 0
def test_get_limit_unknown_plan(self):
reg = QuotaRegistry()
assert reg.get_limit("unknown", "storage_gb") == 0
def test_list_dimensions_returns_copy(self):
"""list_dimensions 返回副本,修改不影响内部"""
registry = QuotaRegistry()
dims = registry.list_dimensions()
dims["fake"] = "fake"
assert "fake" not in registry.list_dimensions()
def test_register_new_dimension(self):
reg = QuotaRegistry()
reg.register_dimension("new_feature", "新功能", default_limits={"free": 0, "basic": 1, "premium": 5})
dims = reg.list_dimensions()
assert "new_feature" in dims
assert dims["new_feature"] == "新功能"
assert reg.get_limit("free", "new_feature") == 0
assert reg.get_limit("basic", "new_feature") == 1
assert reg.get_limit("premium", "new_feature") == 5
def test_list_tiers_returns_all_three(self):
"""列出所有套餐"""
registry = QuotaRegistry()
tiers = registry.list_tiers()
assert len(tiers) == 3
assert set(tiers) == {"free", "basic", "premium"}
def test_register_dimension_idempotent(self):
reg = QuotaRegistry()
reg.register_dimension("storage_gb", "should not change", default_limits={"free": 999})
# 已经存在的不覆盖
assert reg.get_limit("free", "storage_gb") == 2
def test_register_without_defaults(self):
reg = QuotaRegistry()
reg.register_dimension("new_dim", "描述")
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
def test_register_partial_limits(self):
reg = QuotaRegistry()
reg.register_dimension("partial", "partial", default_limits={"premium": 42})
assert reg.get_limit("free", "partial") == 0 # 未设置的保持 0
assert reg.get_limit("premium", "partial") == 42
class TestQuotaChecker:
"""QuotaChecker 测试"""
def test_check_under_limit_allowed(self):
"""使用量低于限制,允许"""
def test_check_within_limit(self):
checker = QuotaChecker()
result = checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
result = checker.check("free", "storage_gb", 1)
assert result.allowed is True
assert result.remaining == 1.0
assert result.warning_level == QuotaWarningLevel.NORMAL
assert result.limit == 2
assert result.used == 1
assert result.remaining == 1
assert result.dimension == "storage_gb"
def test_check_at_limit_not_allowed(self):
"""使用量等于限制,不允许(used < limit 判定)"""
def test_check_exceeded(self):
checker = QuotaChecker()
result = checker.check("free", QuotaDimension.STORAGE_GB, 2.0)
result = checker.check("free", "storage_gb", 3)
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == QuotaWarningLevel.EXCEEDED
assert result.warning_level == "exceeded"
def test_check_over_limit(self):
"""使用量超过限制"""
def test_check_exact_limit_not_allowed(self):
# used < limit 才 allowed,等于不算
checker = QuotaChecker()
result = checker.check("free", QuotaDimension.STORAGE_GB, 3.0)
result = checker.check("free", "storage_gb", 2)
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_warning_level_80_percent(self):
"""80% 触发 WARNING"""
def test_check_unlimited(self):
checker = QuotaChecker()
# 100GB 的 80% = 80GB
result = checker.check("premium", QuotaDimension.STORAGE_GB, 80.0)
assert result.warning_level == QuotaWarningLevel.WARNING
result = checker.check("premium", "max_templates", 999999)
assert result.allowed is True
assert result.remaining == float("inf")
assert result.warning_level == "normal"
def test_check_warning_level_95_percent(self):
"""95% 触发 CRITICAL"""
def test_check_warning_level_normal(self):
checker = QuotaChecker()
result = checker.check("premium", QuotaDimension.STORAGE_GB, 95.0)
assert result.warning_level == QuotaWarningLevel.CRITICAL
result = checker.check("free", "storage_gb", 1) # 50%
assert result.warning_level == "normal"
def test_check_warning_level_warning(self):
checker = QuotaChecker()
# 80% <= used < 95%
result = checker.check("free", "max_templates", 2.5) # 2.5/3 = 83%
assert result.warning_level == "warning"
def test_check_warning_level_critical(self):
checker = QuotaChecker()
# 95% <= used < 100%
result = checker.check("free", "max_templates", 2.9) # 2.9/3 = 97%
assert result.warning_level == "critical"
def test_check_warning_level_exceeded(self):
"""100% 及以上触发 EXCEEDED"""
checker = QuotaChecker()
result = checker.check("premium", QuotaDimension.STORAGE_GB, 100.0)
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_check_unlimited_always_allowed(self):
"""不限量的维度始终允许"""
checker = QuotaChecker()
result = checker.check("premium", QuotaDimension.MAX_TEMPLATES, 9999)
assert result.allowed is True
assert math.isinf(result.remaining)
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan_zero_limit(self):
"""未知套餐限制为 0,used=0 时不允许(0 < 0 为 False"""
checker = QuotaChecker()
result = checker.check("unknown", QuotaDimension.STORAGE_GB, 0)
assert result.limit == 0
assert result.allowed is False
result = checker.check("free", "storage_gb", 5) # 250%
assert result.warning_level == "exceeded"
def test_check_multiple(self):
"""批量检查多个维度"""
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{
QuotaDimension.STORAGE_GB: 1.0,
QuotaDimension.VIDEOS_PER_MONTH: 3,
},
{"storage_gb": 1, "max_templates": 2, "max_titles": 10},
)
assert len(results) == 2
assert len(results) == 3
assert results[0].dimension == "storage_gb"
assert results[1].dimension == "max_templates"
assert results[2].dimension == "max_titles"
assert all(r.allowed for r in results)
dims = {r.dimension for r in results}
assert QuotaDimension.STORAGE_GB in dims
assert QuotaDimension.VIDEOS_PER_MONTH in dims
def test_check_with_custom_registry(self):
"""使用自定义注册表"""
registry = QuotaRegistry()
registry.register_dimension("custom", "自定义", default_limits={"free": 5})
checker = QuotaChecker(registry)
result = checker.check("free", "custom", 3)
assert result.allowed is True
assert result.limit == 5
def test_check_zero_limit(self):
checker = QuotaChecker()
result = checker.check("free", "ai_voice_enabled", 0)
# limit=0, used=0: used < limit 为 False → allowed=False
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == "normal"
def test_compute_warning_level_zero_limit_no_usage(self):
"""limit=0, used=0 → NORMAL"""
level = QuotaChecker._compute_warning_level(0, 0)
assert level == QuotaWarningLevel.NORMAL
def test_compute_warning_level_normal(self):
assert QuotaChecker._compute_warning_level(50, 100) == "normal"
assert QuotaChecker._compute_warning_level(79, 100) == "normal"
def test_compute_warning_level_warning_boundary(self):
assert QuotaChecker._compute_warning_level(80, 100) == "warning"
assert QuotaChecker._compute_warning_level(94, 100) == "warning"
def test_compute_warning_level_critical_boundary(self):
assert QuotaChecker._compute_warning_level(95, 100) == "critical"
assert QuotaChecker._compute_warning_level(99, 100) == "critical"
def test_compute_warning_level_exceeded(self):
assert QuotaChecker._compute_warning_level(100, 100) == "exceeded"
assert QuotaChecker._compute_warning_level(150, 100) == "exceeded"
def test_compute_warning_level_unlimited(self):
assert QuotaChecker._compute_warning_level(9999, float("inf")) == "normal"
def test_compute_warning_level_zero_limit_with_usage(self):
"""limit=0, used>0 → EXCEEDED"""
level = QuotaChecker._compute_warning_level(1, 0)
assert level == QuotaWarningLevel.EXCEEDED
assert QuotaChecker._compute_warning_level(1, 0) == "exceeded"
def test_compute_warning_level_zero_limit_no_usage(self):
assert QuotaChecker._compute_warning_level(0, 0) == "normal"
def test_compute_warning_level_negative_limit(self):
"""limit<0 视同 0 处理"""
level = QuotaChecker._compute_warning_level(1, -1)
assert level == QuotaWarningLevel.EXCEEDED
# limit <= 0 且 used=0 → NORMAL
assert QuotaChecker._compute_warning_level(0, -1) == "normal"
class TestGetWarningLevel:
"""get_warning_level 便捷函数测试"""
def test_normal(self):
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_warning(self):
assert get_warning_level(85, 100) == QuotaWarningLevel.WARNING
def test_critical(self):
assert get_warning_level(97, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded(self):
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited(self):
assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL
def test_convenience_function(self):
assert get_warning_level(50, 100) == "normal"
assert get_warning_level(99, 100) == "critical"
assert get_warning_level(100, 100) == "exceeded"
assert get_warning_level(0, 0) == "normal"
assert get_warning_level(1, 0) == "exceeded"
class TestGlobalSingletons:
"""全局单例测试"""
def test_quota_registry_is_instance(self):
assert isinstance(quota_registry, QuotaRegistry)
def test_quota_checker_is_instance(self):
assert isinstance(quota_checker, QuotaChecker)
def test_global_checker_uses_global_registry(self):
"""全局 checker 使用全局 registry"""
# 验证能正常工作
result = quota_checker.check("free", QuotaDimension.STORAGE_GB, 1.0)
def test_global_checker_works(self):
result = quota_checker.check("free", "storage_gb", 1)
assert result.allowed is True