Compare commits

...

34 Commits

Author SHA1 Message Date
xiaoxia 28a2322f73 test(wave210): 验证码服务单测补全 +70测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
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 - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m50s
AI Code Review / AI Code Review (pull_request) Successful in 3m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Unit Tests (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m7s
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 Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m31s
CI/CD Pipeline / CI Gate (pull_request) Successful in 17s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
覆盖范围:
- generate: 成功/自定义code/自定义TTL/参数校验/频控(冷却+每日上限)
- verify: 成功/错误/不存在/过期/已使用/尝试次数/consume开关
- validate_phone: 各种合法/非法号码格式
- normalize_phone: +86前缀/空格处理
- validate_email: 各种合法/非法邮箱格式
- VerificationCode实体: is_expired/is_used/is_valid/生命周期

70 test cases, 8 test classes
2026-07-30 07:20:16 +08:00
xiaoxia 0f27bfa999 refactor(titles): 拆分 useTitleLibrary 为4个子Hook(174→58行, -67%) (#1156)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 9m54s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m14s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Waiting to run
CI/CD Pipeline / Staging API Integration Tests (push) Waiting to run
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Waiting to run
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 01:19:17 +08:00
xiaoxia f15bc2f2a2 refactor(products): 拆分 useProductList 为3个子Hook(202→91行, -55%) (#1155)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 01:15:19 +08:00
xiaoxia 2273fb329f test(wave208): 密码哈希与验证模块单测 +50测 (#1175)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 01:13:58 +08:00
xiaoxia 4bd3da73e3 fix(ci): pr_auto_scan修复崩溃bug + 统一merge门禁为CI Gate
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m17s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m24s
CI/CD Pipeline / Unit Tests (push) Successful in 9m36s
CI/CD Pipeline / Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- api_request: 修复HTTPError空响应体导致JSON解析崩溃
- REQUIRED_CONTEXTS_FULL: 改为只检查CI Gate,与pr-automation/分支保护保持一致
- FRONTEND_ONLY_CONTEXT: 改为CI Gate,内部自动处理跳过逻辑
2026-07-30 00:41:43 +08:00
xiaoxia 7674a04a33 fix(ci): CI Gate添加integration-tests必填检查,防止集成测试失败仍被合并
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:40:52 +08:00
xiaoxia 9d97e8aacc refactor(assets): 拆分 batchOperations 为4个子Hook(238→8行, -97%) (#1168)
CI/CD Pipeline / Check if frontend-only change (push) Waiting to run
CI/CD Pipeline / Validate - Code Quality (push) Waiting to run
CI/CD Pipeline / Validate - Type Check (mypy) (push) Waiting to run
CI/CD Pipeline / Validate - Migration (alembic) (push) Waiting to run
CI/CD Pipeline / Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Blocked by required conditions
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Web Image (push) Blocked by required conditions
CI/CD Pipeline / PR Build Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-30 00:27:41 +08:00
xiaoxia 89639a6d3e test(wave206): InMemory仓储单测补全 +72测 (#1173)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:11 +08:00
xiaoxia c4144d9e3f test(wave205): path_security 单测重构与补全 +46测 (#1172)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:05 +08:00
xiaoxia de201436ea test(wave204): text_splitter + pagination 单测补全 +37测 (#1171)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:26:00 +08:00
xiaoxia 77a49e3365 test(wave201): concat_engine_pure 单测补全 +85测 (#1167)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:42 +08:00
xiaoxia d7e362a637 test(wave199): sticker_engine_pure 单测补全 +141测 (#1165)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:30 +08:00
xiaoxia 028c6613ce test(shared): wave198 ai_service 单测 +41 (#1164)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:24 +08:00
xiaoxia 696cdda87b refactor(products): VideoPlayer 改用 useVideoPlayer Hook 消除重复代码(199→123行, -38%) (#1163)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:18 +08:00
xiaoxia a85167a529 refactor(generate): 拆分 useGenerateVideo 核心逻辑(211→154行, -27%) (#1162)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:12 +08:00
xiaoxia e0b95e69d4 test(domain): wave197 asset/asset_library 兼容层 +12 (#1161)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:07 +08:00
xiaoxia 63d4048354 test(domain): wave196 小模块批量单测 +74 (#1160)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:25:01 +08:00
xiaoxia e811516c6e test(domain): wave195 verification_code 单测 +27 (#1159)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:55 +08:00
xiaoxia f9a106f36b test(domain): wave194 tts_config 单测 +52 (#1158)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:49 +08:00
xiaoxia e902fbd65e test(domain): wave193 video_share 单测 +43 (#1157)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:43 +08:00
xiaoxia e8352227af test(wave192): media_validation 媒体校验 +56测 (#1154)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:27 +08:00
xiaoxia ef7596739c test(wave190): preset_voices 预置音色 +35测 (#1152)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:16 +08:00
xiaoxia c2329f3f98 test(wave189): preset_bgm 预设BGM库 +44测 (#1151)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:10 +08:00
xiaoxia 0a0914322c test(wave188): video_concat 视频拼接配置 +58测 (#1150)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:24:04 +08:00
xiaoxia 042cae7a61 test(wave187): speed_config 调速配置 +77测 (#1148)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:59 +08:00
xiaoxia be885eb8f0 test(wave186): subtitle 字幕时间轴模型 +52测 (#1147)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:53 +08:00
xiaoxia fd61800be9 test(wave185): audio_track_config 多轨道音频配置 +80测 (#1146)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:47 +08:00
xiaoxia 183681c07d test(wave184): intro_outro_config 片头片尾配置 +59测 (#1145)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:41 +08:00
xiaoxia 8d906bac72 test(wave183): voice_presets 配音音色预设 +59测 (#1143)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:30 +08:00
xiaoxia 8f925e6c65 test(wave182): config_schemas 配置Schema +110测 (#1142)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:23 +08:00
xiaoxia 9732cc51ee refactor(generate): 拆分 useGenerateFormState 为4个子Hook(309→196行, -37%) (#1141)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-30 00:23:17 +08:00
xiaoxia 815b3758a7 refactor(tasks): 拆分 TaskTable 列定义和空状态到子模块(190→72行, -62%) (#1140)
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m37s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Unit Tests (push) Successful in 9m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m7s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
2026-07-30 00:22:45 +08:00
xiaoxia eb73eeab69 refactor(assets): 拆分 batchOperations 为4个独立Hook(238→4行, -98%) (#1149)
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m14s
CI/CD Pipeline / Unit Tests (push) Successful in 8m34s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 19m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m42s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 35s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 25s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m15s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 9m16s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-29 22:21:52 +08:00
xiaoxia 2325ffbc57 fix(ci): 修复pr_auto_scan.py的typo - base_ref字段名应为ref而非re
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m36s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m11s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m35s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 28s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m59s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 修复base_ref获取字段名错误(re->ref),导致所有PR被误判为目标分支不匹配而跳过
- 修复dry-run模式打印缺少f前缀的小bug
2026-07-29 22:20:24 +08:00
74 changed files with 11972 additions and 4492 deletions
+1
View File
@@ -1777,6 +1777,7 @@ jobs:
# 后端检查
REQUIRED_BACKEND=(
"unit-tests:$RESULT_UNIT_TESTS"
"integration-tests:$RESULT_INTEGRATION"
)
# 前端检查
@@ -0,0 +1,4 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -0,0 +1,58 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -0,0 +1,86 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -0,0 +1,4 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -0,0 +1,58 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -0,0 +1,40 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -0,0 +1,53 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -0,0 +1,86 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -1,238 +1,8 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { SmartViewType } from "../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "./constants"
/* ── 批量删除 ── */
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
/* ── 批量打标签 ── */
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
/* ── 批量改分类 ── */
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
/* ── 批量智能标记 ── */
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
/**
* @deprecated 请从 ./batch/ 目录导入子模块
* 保持向后兼容,re-export 所有批量操作 Hook
*/
export { useBatchDelete } from "./batch/useBatchDelete"
export { useBatchTag } from "./batch/useBatchTag"
export { useBatchClassify } from "./batch/useBatchClassify"
export { useBatchMark } from "./batch/useBatchMark"
@@ -12,7 +12,7 @@ import {
useBatchTag,
useBatchClassify,
useBatchMark,
} from "./asset-operations/batchOperations"
} from "./asset-operations/batch-operations"
import type { BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../components/BatchMarkModal"
@@ -0,0 +1,76 @@
import type { UseGenerateVideoProps } from "./types"
import { buildVoiceConfig } from "./voiceConfig"
/**
* 构建 updateEditPlan 的 payload
* 从 props 中提取需要的字段,组装成 API 所需的 config 结构
*/
export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
const {
titleSettings,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
return {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing" as const,
}
}
/**
* 生成前置校验
* 返回错误信息,通过则返回 null
*/
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
if (!titleSettings.title.trim()) {
return "请先选择或输入标题"
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
return "请至少选择一个素材"
}
if (voiceMode === "clone" && !selectedClonedVoice) {
return "请先选择一个克隆音色"
}
return null
}
@@ -1,309 +0,0 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState, useEffect, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo, TitleConfig } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import { getEditPlan } from "@/api/template-editor"
import type { CoverConfig } from "../../editing-planner/types"
import { getEditingTemplates } from "@/api/editing-planner"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../constants"
import type { TitleSettings } from "../types"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates])
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* ── 预置音色 API ── */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr])
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -0,0 +1,198 @@
/**
* GeneratePage 表单状态管理
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
*/
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { GeneratedVideo } from "@/api/template-editor"
import type { EditingTemplate } from "@/api/editing-planner"
import type { CoverConfig } from "../../../editing-planner/types"
import type { PresetVoiceItem } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../../constants"
import type { TitleSettings } from "../../types"
import { useTemplateSelection } from "./useTemplateSelection"
import { useTitleCoverSync } from "./useTitleCoverSync"
import { useVoiceState } from "./useVoiceState"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
export interface GenerateFormState {
/* 步骤 */
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
/* 模板 */
selectedTemplate: string
setSelectedTemplate: (id: string) => void
userTemplates: EditingTemplate[]
/* 素材 */
selectedMaterials: string[]
setSelectedMaterials: (ids: string[]) => void
materialMode: "manual" | "auto"
setMaterialMode: (mode: "manual" | "auto") => void
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
/* 封面 */
coverSettings: CoverConfig
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
/* 配音 */
selectedVoice: string
setSelectedVoice: (id: string) => void
voiceMode: "preset" | "custom" | "clone"
setVoiceMode: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
setSelectedClonedVoice: (id: string) => void
presetVoices: PresetVoiceItem[]
/* 克隆弹窗 */
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* 生成数量 */
generateCount: number
setGenerateCount: (n: number) => void
/* 高级设置 */
videoRatio: string
duration: number
style: string
autoSubtitles: boolean
bgm: boolean
/* URL 参数 */
editPlanId: string | null
planConfigStr: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
previewModalOpen: boolean
setPreviewModalOpen: (open: boolean) => void
}
export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板选择 ── */
const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection()
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* ── 模板切换时同步标题/封面 ── */
useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
})
/* ── 配音状态 ── */
const {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
} = useVoiceState()
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("16:9")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 预览弹窗 ── */
const [previewVideo, setPreviewVideo] = useState<GeneratedVideo | null>(null)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
/* ── 从 URL / 编辑计划加载配置 ── */
usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
})
return {
currentStep,
setCurrentStep,
selectedTemplate,
setSelectedTemplate,
userTemplates,
selectedMaterials,
setSelectedMaterials,
materialMode,
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
titleSettings,
setTitleSettings,
coverSettings,
setCoverSettings,
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
cloneModalOpen,
setCloneModalOpen,
generateCount,
setGenerateCount,
videoRatio,
duration,
style,
autoSubtitles,
bgm,
editPlanId,
planConfigStr,
previewVideo,
setPreviewVideo,
previewModalOpen,
setPreviewModalOpen,
}
}
@@ -0,0 +1,110 @@
import { useEffect } from "react"
import type { CoverConfig } from "../../../editing-planner/types"
import type { TitleSettings } from "../../types"
import type { TitleConfig } from "@/api/template-editor"
import { getEditPlan } from "@/api/template-editor"
interface UsePlanConfigLoaderOptions {
editPlanId: string | null
planConfigStr: string | null
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
setSelectedMaterials: (ids: string[]) => void
}
/**
* 从 URL 参数或编辑计划 ID 加载表单配置
*/
export function usePlanConfigLoader({
editPlanId,
planConfigStr,
setTitleSettings,
setCoverSettings,
setSelectedMaterials,
}: UsePlanConfigLoaderOptions) {
/** 解析 plan_config 并自动填充表单 */
useEffect(() => {
if (!planConfigStr) return
try {
const config = JSON.parse(planConfigStr) as {
title_config?: {
content?: string
ai_auto_select?: boolean
position?: string
font_preset?: string
font_size?: number
font_color?: string
}
subtitle_config?: { enabled?: boolean }
bgm_config?: { enabled?: boolean; music_id?: string }
mode?: string
total_duration?: number
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev: TitleSettings) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
.map((s) => s.media_asset_id)
.filter((id): id is string => !!id)
if (assetIds.length > 0) {
setSelectedMaterials(assetIds)
}
}
} catch (err) {
console.warn("解析 plan_config 失败:", err)
}
}, [planConfigStr, setTitleSettings, setSelectedMaterials])
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
useEffect(() => {
if (!editPlanId || planConfigStr) return
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev: TitleSettings) => ({ ...prev, title: plan.name }))
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
} catch (err) {
console.warn("加载模板草稿配置失败:", err)
}
}
loadPlanConfig()
}, [editPlanId, planConfigStr, setTitleSettings, setCoverSettings, setSelectedMaterials])
}
@@ -0,0 +1,22 @@
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { getEditingTemplates } from "@/api/editing-planner"
import type { EditingTemplate } from "@/api/editing-planner"
export function useTemplateSelection() {
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery<EditingTemplate[]>({
queryKey: ["generate-templates"],
queryFn: () => getEditingTemplates(),
staleTime: 60_000,
})
/* 模板加载完成后自动选中第一个 */
useEffect(() => {
if (userTemplates.length > 0 && !selectedTemplate) {
setSelectedTemplate(userTemplates[0].id)
}
}, [userTemplates, selectedTemplate])
return { selectedTemplate, setSelectedTemplate, userTemplates }
}
@@ -0,0 +1,47 @@
import { useEffect } from "react"
import type { TitleSettings } from "../../types"
import type { CoverConfig } from "../../../editing-planner/types"
import type { EditingTemplate } from "@/api/editing-planner"
interface UseTitleCoverSyncOptions {
selectedTemplate: string
userTemplates: EditingTemplate[]
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
setCoverSettings: (settings: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
/**
* 当选中模板变化时,自动同步标题和封面配置
*/
export function useTitleCoverSync({
selectedTemplate,
userTemplates,
setTitleSettings,
setCoverSettings,
}: UseTitleCoverSyncOptions) {
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev: TitleSettings) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev: CoverConfig) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
}
}, [selectedTemplate, userTemplates, setTitleSettings, setCoverSettings])
}
@@ -0,0 +1,30 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import type { PresetVoiceItem } from "@/api/voices"
import { fetchPresetVoices } from "@/api/voices"
export function useVoiceState() {
const [selectedVoice, setSelectedVoice] = useState("")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [selectedClonedVoice, setSelectedClonedVoice] = useState("")
/* 预置音色 API */
const { data: presetVoicesData } = useQuery({
queryKey: ["preset-voices"],
queryFn: fetchPresetVoices,
})
const presetVoices: PresetVoiceItem[] = useMemo(
() => presetVoicesData?.items ?? [],
[presetVoicesData],
)
return {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
}
}
@@ -9,27 +9,11 @@ import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-ed
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
import { buildVoiceConfig } from "./generate-video/voiceConfig"
import { buildEditPlanPayload, validateGenerateInputs } from "./generate-video/buildPayload"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
export function useGenerateVideo(props: UseGenerateVideoProps) {
const {
titleSettings,
selectedTemplate,
selectedMaterials,
materialMode,
smartSelectedIds,
voiceMode,
selectedVoice,
selectedClonedVoice,
coverSettings,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
generateCount,
} = props
const { selectedTemplate } = props
/* ── 生成状态 ── */
const [generating, setGenerating] = useState(false)
@@ -58,16 +42,9 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
/* ── 生成视频 ── */
const generate = useCallback(async () => {
if (!titleSettings.title.trim()) {
message.warning("请先选择或输入标题")
return
}
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (voiceMode === "clone" && !selectedClonedVoice) {
message.warning("请先选择一个克隆音色")
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
message.warning(errorMsg)
return
}
@@ -78,41 +55,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
const voiceConfig = buildVoiceConfig({
voiceMode,
selectedVoice,
selectedClonedVoice,
})
const payload = buildEditPlanPayload(props)
// 获取或创建草稿
await getEditPlan(selectedTemplate)
// 更新草稿内容 + 切换到 editing 状态
await updateEditPlan(selectedTemplate, {
name: titleSettings.title.trim(),
config: {
asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
status: "editing",
})
await updateEditPlan(selectedTemplate, payload)
await generateEditPlan(selectedTemplate)
startPolling()
@@ -125,25 +74,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [
titleSettings,
selectedMaterials,
selectedVoice,
voiceMode,
selectedClonedVoice,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
selectedTemplate,
generateCount,
materialMode,
coverSettings,
smartSelectedIds,
clearTimer,
startPolling,
])
}, [props, selectedTemplate, clearTimer, startPolling])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from "react"
import React, { useEffect } from "react"
import {
VideoCameraOutlined,
PlayCircleOutlined,
@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui"
import type { ProductItem } from "../types"
import { formatTime, formatSize } from "../utils"
import { useVideoPlayer } from "../hooks/useVideoPlayer"
interface VideoPlayerProps {
product: ProductItem
@@ -27,52 +28,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onShare,
onViewDetail,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(product.duration)
const {
videoRef,
progressRef,
isPlaying,
currentTime,
duration,
progress,
togglePlay,
handleSeek,
} = useVideoPlayer()
const hasVideo = !!product.videoUrl
/** 播放/暂停 */
const handlePlayPause = useCallback(() => {
const video = videoRef.current
if (!video) return
if (isPlaying) {
video.pause()
} else {
video.play().catch(() => {})
}
setIsPlaying(!isPlaying)
}, [isPlaying])
/** 视频事件监听 */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onTime = () => setCurrentTime(video.currentTime)
const onDur = () => setDuration(video.duration || product.duration)
const onEnd = () => setIsPlaying(false)
video.addEventListener("timeupdate", onTime)
video.addEventListener("loadedmetadata", onDur)
video.addEventListener("ended", onEnd)
return () => {
video.removeEventListener("timeupdate", onTime)
video.removeEventListener("loadedmetadata", onDur)
video.removeEventListener("ended", onEnd)
}
}, [product.duration])
/** 进度条点击 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * duration
setCurrentTime(newTime)
if (videoRef.current) videoRef.current.currentTime = newTime
}
const displayDuration = duration || product.duration
/** ESC 关闭 */
useEffect(() => {
@@ -83,8 +51,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener("keydown", handleKey)
}, [onClose])
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="xx-player-overlay" onClick={onClose}>
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
@@ -114,7 +80,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* 播放/暂停按钮 */}
<button className="xx-player-play-btn" onClick={handlePlayPause}>
<button className="xx-player-play-btn" onClick={togglePlay}>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -125,12 +91,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* 进度条 */}
<div className="xx-player-progress-wrap">
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
<div ref={progressRef} className="xx-player-progress" onClick={handleSeek}>
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
</div>
<div className="xx-player-time">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
<span>{formatTime(displayDuration)}</span>
</div>
</div>
</div>
@@ -0,0 +1,93 @@
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import { mapApiProduct } from "../../utils"
import { useProductFiltering } from "./useProductFiltering"
import { useBatchSelection } from "./useBatchSelection"
export type { Filters } from "./useProductFiltering"
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(
() =>
(Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct).sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
}),
[apiProducts],
)
/* 筛选 */
const {
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
} = useProductFiltering(products)
/* 批量选择 */
const {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
} = useBatchSelection(filteredProducts)
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
filterDuration,
setFilterDuration,
filterProject,
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -0,0 +1,52 @@
import { useState, useCallback } from "react"
import type { ProductItem } from "../../types"
export const useBatchSelection = (filteredProducts: ProductItem[]) => {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const batchMode = selectedIds.size > 0
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = useCallback(() => {
if (allSelected) {
// 仅取消选中当前可见的项,保留筛选外的选中状态
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.delete(p.id))
return next
})
} else {
// 选中所有当前可见项
setSelectedIds((prev) => {
const next = new Set(prev)
filteredProducts.forEach((p) => next.add(p.id))
return next
})
}
}, [allSelected, filteredProducts])
const handleToggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
return {
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
@@ -1,8 +1,5 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
import type { ProductItem } from "../types"
import { mapApiProduct } from "../utils"
import type { ProductItem } from "../../types"
/** 筛选选项类型 */
export interface Filters {
@@ -27,32 +24,7 @@ const getProjectOptions = (products: ProductItem[]) =>
label: name as string,
}))
export const useProductList = () => {
/* ── 获取成品列表 ── */
const {
data: apiProducts = [],
isLoading,
isError,
error,
refetch,
} = useQuery<ApiProductItem[], Error>({
queryKey: ["products"],
queryFn: () => getProducts(),
staleTime: 30_000,
})
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(() => {
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
// 按创建时间倒序(最新的在最前面),无时间的排最后
return list.sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
}, [apiProducts])
/* 筛选 */
export const useProductFiltering = (products: ProductItem[]) => {
const [searchText, setSearchText] = useState("")
const [filterStatus, setFilterStatus] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
@@ -60,12 +32,6 @@ export const useProductList = () => {
const [filterProject, setFilterProject] = useState<string>("all")
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
/* 批量选择 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
/* 派生数据 */
const batchMode = selectedIds.size > 0
const filteredProducts = useMemo(() => {
let list = products
@@ -142,42 +108,7 @@ export const useProductList = () => {
const projectOptions = useMemo(() => getProjectOptions(products), [products])
/* 全选 */
const allSelected =
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
const handleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
}
}
/* 切换单个选择 */
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const clearSelection = () => setSelectedIds(new Set())
return {
// 数据
products,
filteredProducts,
isLoading,
isError,
error,
refetch,
// 筛选
searchText,
setSearchText,
filterStatus,
@@ -190,13 +121,7 @@ export const useProductList = () => {
setFilterProject,
filterReviewStatus,
setFilterReviewStatus,
filteredProducts,
projectOptions,
// 批量选择
selectedIds,
batchMode,
allSelected,
handleSelectAll,
handleToggleSelect,
clearSelection,
}
}
+5 -123
View File
@@ -1,11 +1,8 @@
import React from "react"
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
import { formatDuration, formatTime } from "../utils"
import { Table } from "antd"
import type { TaskItem } from "@/api/tasks"
import { TaskErrorDetail } from "./TaskErrorDetail"
import { useTaskTableColumns, TaskEmptyState } from "./task-table"
interface TaskTableProps {
dataSource: TaskItem[]
@@ -24,7 +21,6 @@ interface TaskTableProps {
/**
* 任务列表表格
* 含列定义、分页、展开行
*/
export const TaskTable: React.FC<TaskTableProps> = ({
dataSource,
@@ -40,116 +36,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
onRetry,
onViewDetail,
}) => {
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
const columns = useTaskTableColumns({ retryLoading, onRetry, onViewDetail })
return (
<Table
@@ -178,12 +65,7 @@ export const TaskTable: React.FC<TaskTableProps> = ({
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
emptyText: <TaskEmptyState />,
}}
/>
)
@@ -0,0 +1,9 @@
import React from "react"
import { ClockCircleOutlined } from "@ant-design/icons"
export const TaskEmptyState: React.FC = () => (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
)
+2
View File
@@ -0,0 +1,2 @@
export { useTaskTableColumns } from "./useTaskTableColumns"
export { TaskEmptyState } from "./TaskEmptyState"
@@ -0,0 +1,128 @@
import { Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../../constants"
import { formatDuration, formatTime } from "../../utils"
interface UseTaskTableColumnsOptions {
retryLoading: boolean
onRetry: (id: string) => void
onViewDetail: (record: TaskItem) => void
}
export function useTaskTableColumns({
retryLoading,
onRetry,
onViewDetail,
}: UseTaskTableColumnsOptions): ColumnsType<TaskItem> {
return [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
}
@@ -1,174 +0,0 @@
import { useMemo, useState, useCallback } from "react"
import { message } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
export const useTitleLibrary = () => {
const queryClient = useQueryClient()
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,55 @@
import { useTitleFilters } from "./useTitleFilters"
import { useTitleMutations } from "./useTitleMutations"
import { useTitleData } from "./useTitleData"
import { useTitleActions } from "./useTitleActions"
export const useTitleLibrary = () => {
/* 数据获取与派生 */
const { titles, categories, activeCatId, activeCategory, setActiveCatId } = useTitleData()
/* 筛选 */
const {
searchText,
filterType,
filterIndustry,
filterFrequency,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
filteredTitles,
} = useTitleFilters(titles, categories, activeCatId, activeCategory)
/* CRUD mutations */
const { createMutation, updateMutation, deleteMutation } = useTitleMutations()
/* 操作 handlers */
const { handleToggleFavorite, handleCopy, handleDelete } = useTitleActions(deleteMutation)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -0,0 +1,34 @@
import { useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleData } from "../../types/titleLibrary"
import { copyToClipboard } from "../../utils/titleLibrary"
export const useTitleActions = (
deleteMutation: UseMutationResult<void, Error, string, unknown>,
) => {
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
},
[deleteMutation],
)
return { handleToggleFavorite, handleCopy, handleDelete }
}
@@ -0,0 +1,47 @@
import { useMemo, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { getTitles } from "@/api/titles"
import type { TitleData, CategoryItem } from "../../types/titleLibrary"
import { toTitleData } from "../../utils/titleLibrary"
import { ALL_CATEGORY_ID } from "../../constants/titleLibrary"
export const useTitleData = () => {
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
const activeCategory = categories.find((c) => c.id === activeCatId)
return {
titles,
categories,
activeCatId,
activeCategory,
setActiveCatId,
}
}
@@ -0,0 +1,77 @@
import { useMemo, useState } from "react"
import type { TitleData, CategoryItem, Frequency } from "../../types/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../../constants/titleLibrary"
export const useTitleFilters = (
titles: TitleData[],
_categories: CategoryItem[],
activeCatId: string,
activeCategory: CategoryItem | undefined,
) => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
return {
searchText,
setSearchText,
filterType,
setFilterType,
filterIndustry,
setFilterIndustry,
filterFrequency,
setFilterFrequency,
filteredTitles,
}
}
@@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createTitle, updateTitle, deleteTitle } from "@/api/titles"
export const useTitleMutations = () => {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
message.success("标题已删除")
},
onError: () => message.error("删除标题失败"),
})
return { createMutation, updateMutation, deleteMutation }
}
@@ -36,6 +36,10 @@ import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
import "@/pages/assets/hooks/asset-operations/batch/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch/useBatchMark"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
@@ -38,7 +38,13 @@ describe("GeneratePage module smoke test", () => {
})
import "@/pages/generate/hooks/useGenerateVideo"
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
import "@/pages/generate/hooks/useGenerateFormState"
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
import "@/pages/generate/hooks/useGenerateFormState/useTitleCoverSync"
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
import "@/pages/generate/hooks/useGenerateFormState/usePlanConfigLoader"
import "@/pages/generate/hooks/generate-video/types"
import "@/pages/generate/hooks/generate-video/phase"
import "@/pages/generate/hooks/generate-video/voiceConfig"
import "@/pages/generate/hooks/generate-video/errorUtils"
import "@/pages/generate/hooks/generate-video/buildPayload"
@@ -21,7 +21,10 @@ import "@/pages/products/components/VideoPlayer"
// Hooks
import "@/pages/products/hooks/useProductList"
import "@/pages/products/hooks/useProductList/useProductFiltering"
import "@/pages/products/hooks/useProductList/useBatchSelection"
import "@/pages/products/hooks/useProductActions"
import "@/pages/products/hooks/useVideoPlayer"
describe("ProductLibrary module smoke test", () => {
it("should load all product modules", () => {
@@ -0,0 +1,17 @@
/**
* Tasks 模块 smoke test
* 建立依赖链,确保 vitest related 能匹配到 tasks 目录下的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/tasks/components/TaskTable"
import "@/pages/tasks/components/task-table/useTaskTableColumns"
import "@/pages/tasks/components/task-table/TaskEmptyState"
import "@/pages/tasks/components/TaskFilterBar"
import "@/pages/tasks/components/TaskErrorDetail"
describe("Tasks module smoke test", () => {
it("should load all task modules", () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,29 @@
/**
* TitleLibrary 模块 smoke test
* 建立完整依赖链,确保 vitest related 模式能匹配到
* titles 目录下所有文件的改动
*/
import { describe, it, expect } from "vitest"
// 主组件
import "@/pages/titles/TitleLibrary"
// Hooks
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleLibrary/useTitleData"
import "@/pages/titles/hooks/useTitleLibrary/useTitleFilters"
import "@/pages/titles/hooks/useTitleLibrary/useTitleMutations"
import "@/pages/titles/hooks/useTitleLibrary/useTitleActions"
// 类型与常量
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
// 工具函数
import "@/pages/titles/utils/titleLibrary"
describe("TitleLibrary module smoke test", () => {
it("should load all title modules", () => {
expect(true).toBe(true)
})
})
@@ -23,6 +23,23 @@ class InMemoryUserRepository(UserRepository):
def save(self, user: User) -> None:
"""保存用户"""
# 如果是更新,先清理旧索引
old = self._users.get(user.id)
if old:
self._email_index.pop(old.email.lower(), None)
if old.username:
self._username_index.pop(old.username.lower(), None)
if old.email_verification_token:
self._verification_token_index.pop(old.email_verification_token, None)
if old.password_reset_token:
self._reset_token_index.pop(old.password_reset_token, None)
if old.wechat_openid:
self._wechat_openid_index.pop(old.wechat_openid, None)
if old.wechat_unionid:
self._wechat_unionid_index.pop(old.wechat_unionid, None)
if old.phone:
self._phone_index.pop(old.phone, None)
self._users[user.id] = user
self._email_index[user.email.lower()] = user.id
if user.username:
+13 -10
View File
@@ -32,7 +32,13 @@ def api_request(token, repo, endpoint, method="GET", data=None):
resp = urllib.request.urlopen(req, context=ctx)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
body = e.read().decode()
if body:
try:
return json.loads(body), e.code
except json.JSONDecodeError:
return {"error": body}, e.code
return {"error": str(e)}, e.code
def get_open_prs(token, repo, base="develop"):
@@ -269,13 +275,9 @@ def main():
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
"CI/CD Pipeline / PR Build API Image (pull_request)",
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
"CI/CD Pipeline / PR Build Web Image (pull_request)",
# 统一使用CI Gate作为合并门禁(与pr-automation和分支保护保持一致)
# CI Gate内部已包含: 代码质量/类型检查/迁移检查/单测/集成测试/前端Lint/前端单测/构建/AI审查
"CI/CD Pipeline / CI Gate (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
@@ -284,7 +286,8 @@ def main():
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
"CI/CD Pipeline / Frontend Lint (pull_request)",
# 纯前端PR也用CI Gate统一判断,内部自动跳过后端相关检查
"CI/CD Pipeline / CI Gate (pull_request)",
]
# 获取所有open PR
@@ -301,7 +304,7 @@ def main():
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("re", "")
base_ref = pr.get("base", {}).get("ref", "")
# 跳过draft
if pr.get("draft"):
+61
View File
@@ -0,0 +1,61 @@
"""asset / asset_library 兼容层单元测试."""
from __future__ import annotations
from domain.asset import Asset, AssetStatus, AssetType, ClassificationStatus
from domain.asset_library import AssetLibrary, AssetLibraryKind, LibraryKind
class TestAssetType:
"""AssetType 常量类测试."""
def test_video_value(self):
assert AssetType.VIDEO == "video"
def test_image_value(self):
assert AssetType.IMAGE == "image"
def test_audio_value(self):
assert AssetType.AUDIO == "audio"
def test_three_types(self):
assert AssetType.VIDEO
assert AssetType.IMAGE
assert AssetType.AUDIO
class TestAssetReexports:
"""asset.py 重导出测试."""
def test_asset_reexported(self):
# Asset 类从 entities 转发,确认可访问
assert Asset is not None
def test_asset_status_reexported(self):
assert AssetStatus is not None
def test_classification_status_reexported(self):
assert ClassificationStatus is not None
class TestLibraryKind:
"""LibraryKind 常量类测试."""
def test_video_value(self):
assert LibraryKind.VIDEO == AssetLibraryKind.VIDEO
def test_voice_value(self):
assert LibraryKind.VOICE == AssetLibraryKind.VOICE
def test_image_value(self):
assert LibraryKind.IMAGE == AssetLibraryKind.IMAGE
class TestAssetLibraryReexports:
"""asset_library.py 重导出测试."""
def test_asset_library_reexported(self):
assert AssetLibrary is not None
def test_asset_library_kind_reexported(self):
assert AssetLibraryKind is not None
+521
View File
@@ -0,0 +1,521 @@
"""audio_track_config 多轨道音频配置单测."""
import pytest
from domain.audio_track_config import (
ALLOWED_AUDIO_EXTENSIONS,
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
TRACK_TYPE_AMBIENT,
TRACK_TYPE_BGM,
TRACK_TYPE_MAIN,
TRACK_TYPE_SFX,
TRACK_TYPE_VOICEOVER,
AudioTrack,
MultiTrackMixConfig,
clamp_volume,
is_valid_audio_extension,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_track_type_constants(self):
assert TRACK_TYPE_MAIN == "main"
assert TRACK_TYPE_BGM == "bgm"
assert TRACK_TYPE_VOICEOVER == "voiceover"
assert TRACK_TYPE_SFX == "sfx"
assert TRACK_TYPE_AMBIENT == "ambient"
def test_max_tracks(self):
assert MAX_AUDIO_TRACKS == 8
def test_default_volumes(self):
assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3
assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0
assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7
assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2
def test_allowed_extensions(self):
assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS
assert ".wav" in ALLOWED_AUDIO_EXTENSIONS
assert ".aac" in ALLOWED_AUDIO_EXTENSIONS
assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS
assert ".flac" in ALLOWED_AUDIO_EXTENSIONS
assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS
assert ".wma" in ALLOWED_AUDIO_EXTENSIONS
# ── AudioTrack ───────────────────────────────────────────────────────────────
class TestAudioTrackDefaults:
"""AudioTrack 默认值"""
def test_default_values(self):
t = AudioTrack()
assert t.track_id == ""
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == 1.0
assert t.fade_in == 0.0
assert t.fade_out == 0.0
assert t.start_time == 0.0
assert t.duration == 0.0
assert t.enabled is True
def test_custom_track(self):
t = AudioTrack(
track_id="bgm_001",
track_type=TRACK_TYPE_BGM,
audio_path="/music/bgm.mp3",
volume=0.5,
fade_in=1.5,
fade_out=2.0,
start_time=3.0,
duration=30.0,
enabled=False,
)
assert t.track_id == "bgm_001"
assert t.track_type == TRACK_TYPE_BGM
assert t.audio_path == "/music/bgm.mp3"
assert t.volume == 0.5
assert t.fade_in == 1.5
assert t.start_time == 3.0
assert t.duration == 30.0
assert t.enabled is False
class TestAudioTrackFromDict:
"""AudioTrack.from_dict"""
def test_empty_dict(self):
t = AudioTrack.from_dict({})
assert t.track_type == TRACK_TYPE_SFX
assert t.audio_path == ""
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX]
assert t.enabled is True
def test_full_dict(self):
t = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.8,
"fade_in": 1.0,
"fade_out": 2.0,
"start_time": 5.0,
"duration": 60.0,
"enabled": True,
}
)
assert t.track_id == "t1"
assert t.track_type == "bgm"
assert t.volume == 0.8
assert t.fade_in == 1.0
assert t.duration == 60.0
def test_volume_clamped_to_zero(self):
t = AudioTrack.from_dict({"volume": -0.5})
assert t.volume == 0.0
def test_volume_clamped_to_two(self):
t = AudioTrack.from_dict({"volume": 3.0})
assert t.volume == 2.0
def test_invalid_volume_falls_back_to_default(self):
t = AudioTrack.from_dict({"track_type": "bgm", "volume": "abc"})
assert t.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM]
def test_invalid_fade_in_falls_back(self):
t = AudioTrack.from_dict({"fade_in": "bad"})
assert t.fade_in == 0.0
def test_negative_fade_in_clamped(self):
t = AudioTrack.from_dict({"fade_in": -1.0})
assert t.fade_in == 0.0
def test_invalid_fade_out_falls_back(self):
t = AudioTrack.from_dict({"fade_out": None})
assert t.fade_out == 0.0
def test_negative_start_time_clamped(self):
t = AudioTrack.from_dict({"start_time": -5.0})
assert t.start_time == 0.0
def test_invalid_duration_falls_back(self):
t = AudioTrack.from_dict({"duration": "long"})
assert t.duration == 0.0
def test_bgm_default_volume(self):
t = AudioTrack.from_dict({"track_type": "bgm"})
assert t.volume == 0.3
def test_main_default_volume(self):
t = AudioTrack.from_dict({"track_type": "main"})
assert t.volume == 1.0
def test_voiceover_default_volume(self):
t = AudioTrack.from_dict({"track_type": "voiceover"})
assert t.volume == 1.0
def test_ambient_default_volume(self):
t = AudioTrack.from_dict({"track_type": "ambient"})
assert t.volume == 0.2
def test_unknown_type_default_volume(self):
t = AudioTrack.from_dict({"track_type": "unknown_type"})
assert t.volume == 1.0
def test_enabled_false(self):
t = AudioTrack.from_dict({"enabled": False})
assert t.enabled is False
class TestAudioTrackValidate:
"""AudioTrack.validate"""
def test_empty_path_invalid(self):
t = AudioTrack(audio_path="")
valid, msg = t.validate()
assert valid is False
assert "audio_path" in msg
def test_valid_track(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.5)
valid, msg = t.validate()
assert valid is True
assert msg == ""
def test_volume_below_zero_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=-0.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_above_two_invalid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.1)
valid, msg = t.validate()
assert valid is False
assert "volume" in msg
def test_volume_zero_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=0.0)
valid, _ = t.validate()
assert valid is True
def test_volume_two_valid(self):
t = AudioTrack(audio_path="/a.mp3", volume=2.0)
valid, _ = t.validate()
assert valid is True
def test_negative_fade_in_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_in=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_in" in msg
def test_negative_fade_out_invalid(self):
t = AudioTrack(audio_path="/a.mp3", fade_out=-1.0)
valid, msg = t.validate()
assert valid is False
assert "fade_out" in msg
def test_negative_start_time_invalid(self):
t = AudioTrack(audio_path="/a.mp3", start_time=-0.5)
valid, msg = t.validate()
assert valid is False
assert "start_time" in msg
def test_negative_duration_invalid(self):
t = AudioTrack(audio_path="/a.mp3", duration=-1.0)
valid, msg = t.validate()
assert valid is False
assert "duration" in msg
class TestAudioTrackIsEffective:
"""AudioTrack.is_effective 属性"""
def test_enabled_with_path_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=True)
assert t.is_effective is True
def test_disabled_not_effective(self):
t = AudioTrack(audio_path="/a.mp3", enabled=False)
assert t.is_effective is False
def test_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=True)
assert t.is_effective is False
def test_disabled_no_path_not_effective(self):
t = AudioTrack(audio_path="", enabled=False)
assert t.is_effective is False
# ── MultiTrackMixConfig ──────────────────────────────────────────────────────
class TestMultiTrackMixConfigDefaults:
"""MultiTrackMixConfig 默认值"""
def test_default_values(self):
c = MultiTrackMixConfig()
assert c.tracks == []
assert c.master_volume == 1.0
assert c.normalize is True
assert c.max_output_volume == 1.5
def test_custom_config(self):
t1 = AudioTrack(track_id="t1", audio_path="/a.mp3")
c = MultiTrackMixConfig(
tracks=[t1],
master_volume=0.8,
normalize=False,
max_output_volume=2.0,
)
assert len(c.tracks) == 1
assert c.master_volume == 0.8
assert c.normalize is False
assert c.max_output_volume == 2.0
class TestMultiTrackFromConfigDict:
"""MultiTrackMixConfig.from_config_dict"""
def test_none_returns_default(self):
c = MultiTrackMixConfig.from_config_dict(None)
assert len(c.tracks) == 0
assert c.master_volume == 1.0
def test_empty_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict({})
assert len(c.tracks) == 0
def test_non_dict_returns_default(self):
c = MultiTrackMixConfig.from_config_dict("not a dict")
assert len(c.tracks) == 0
def test_single_track(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/bgm.mp3", "volume": 0.5},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
assert c.tracks[0].volume == 0.5
def test_multiple_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "main", "audio_path": "/main.wav"},
{"track_id": "t2", "track_type": "bgm", "audio_path": "/bgm.mp3"},
{"track_id": "t3", "track_type": "sfx", "audio_path": "/sfx.wav"},
],
}
)
assert len(c.tracks) == 3
assert c.tracks[0].track_type == "main"
assert c.tracks[1].track_type == "bgm"
assert c.tracks[2].track_type == "sfx"
def test_skip_disabled_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
{"track_id": "t3", "audio_path": "/c.mp3"},
],
}
)
assert len(c.tracks) == 2
ids = [t.track_id for t in c.tracks]
assert "t1" in ids
assert "t2" not in ids
assert "t3" in ids
def test_skip_no_path_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
}
)
assert len(c.tracks) == 1
assert c.tracks[0].track_id == "t1"
def test_skip_non_dict_tracks(self):
c = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not a dict",
123,
None,
],
}
)
assert len(c.tracks) == 1
def test_master_volume_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": 3.0})
assert c.master_volume == 2.0
def test_master_volume_negative_clamped(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": -1.0})
assert c.master_volume == 0.0
def test_invalid_master_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"master_volume": "high"})
assert c.master_volume == 1.0
def test_normalize_false(self):
c = MultiTrackMixConfig.from_config_dict({"normalize": False})
assert c.normalize is False
def test_max_output_volume_custom(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0})
assert c.max_output_volume == 2.0
def test_invalid_max_output_volume_falls_back(self):
c = MultiTrackMixConfig.from_config_dict({"max_output_volume": "big"})
assert c.max_output_volume == 1.5
def test_tracks_not_list_ignored(self):
c = MultiTrackMixConfig.from_config_dict({"tracks": "not a list"})
assert len(c.tracks) == 0
class TestMultiTrackProperties:
"""MultiTrackMixConfig 属性方法"""
def _make_config(self):
return MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "m1", "track_type": "main", "audio_path": "/m.wav"},
{"track_id": "b1", "track_type": "bgm", "audio_path": "/b1.mp3"},
{"track_id": "b2", "track_type": "bgm", "audio_path": "/b2.mp3", "enabled": False},
{"track_id": "s1", "track_type": "sfx", "audio_path": "/s.wav"},
{"track_id": "x", "track_type": "ambient", "audio_path": ""},
],
}
)
def test_has_effect_true(self):
c = self._make_config()
assert c.has_effect is True
def test_has_effect_false(self):
c = MultiTrackMixConfig()
assert c.has_effect is False
def test_effective_track_count(self):
c = self._make_config()
# m1 + b1 + s1 = 3个有效(b2禁用,x无路径)
assert c.effective_track_count == 3
def test_main_tracks(self):
c = self._make_config()
mains = c.main_tracks
assert len(mains) == 1
assert mains[0].track_id == "m1"
def test_bgm_tracks(self):
c = self._make_config()
bgms = c.bgm_tracks
assert len(bgms) == 1 # 只有b1有效
assert bgms[0].track_id == "b1"
def test_empty_tracks(self):
c = MultiTrackMixConfig()
assert c.effective_track_count == 0
assert c.main_tracks == []
assert c.bgm_tracks == []
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestIsValidAudioExtension:
"""is_valid_audio_extension 函数"""
def test_mp3(self):
assert is_valid_audio_extension("song.mp3") is True
def test_wav(self):
assert is_valid_audio_extension("sound.wav") is True
def test_aac(self):
assert is_valid_audio_extension("audio.aac") is True
def test_ogg(self):
assert is_valid_audio_extension("music.ogg") is True
def test_flac(self):
assert is_valid_audio_extension("lossless.flac") is True
def test_m4a(self):
assert is_valid_audio_extension("apple.m4a") is True
def test_wma(self):
assert is_valid_audio_extension("windows.wma") is True
def test_uppercase_extension(self):
assert is_valid_audio_extension("SONG.MP3") is True
def test_mixed_case_extension(self):
assert is_valid_audio_extension("song.Mp3") is True
def test_mp4_not_valid(self):
assert is_valid_audio_extension("video.mp4") is False
def test_txt_not_valid(self):
assert is_valid_audio_extension("notes.txt") is False
def test_no_extension(self):
assert is_valid_audio_extension("README") is False
def test_full_path(self):
assert is_valid_audio_extension("/home/user/music/song.mp3") is True
class TestClampVolume:
"""clamp_volume 函数"""
def test_within_range(self):
assert clamp_volume(0.5) == 0.5
def test_exact_min(self):
assert clamp_volume(0.0) == 0.0
def test_exact_max(self):
assert clamp_volume(2.0) == 2.0
def test_below_min(self):
assert clamp_volume(-1.0) == 0.0
def test_above_max(self):
assert clamp_volume(3.0) == 2.0
def test_custom_bounds(self):
assert clamp_volume(5.0, min_vol=1.0, max_vol=10.0) == 5.0
def test_custom_below_min(self):
assert clamp_volume(0.5, min_vol=1.0) == 1.0
def test_custom_above_max(self):
assert clamp_volume(15.0, max_vol=10.0) == 10.0
+159
View File
@@ -0,0 +1,159 @@
"""Auth ports (ABC接口) 单元测试.
验证抽象接口定义正确:不能直接实例化,子类必须实现所有抽象方法。
"""
from __future__ import annotations
from abc import ABC
import pytest
from domain.auth.email_service import EmailServicePort
from domain.auth.jwt_service import JWTServicePort
from domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
from domain.auth.session_store import SessionStorePort
from domain.auth.sms_service import SmsService
class TestSessionStorePort:
"""SessionStorePort 接口测试."""
def test_is_abstract(self):
assert issubclass(SessionStorePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SessionStorePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SessionStorePort.__abstractmethods__
expected = {
"save_session",
"get_session",
"get_session_by_refresh_token",
"get_refresh_token",
"update_last_active",
"delete_session",
"get_user_sessions",
"delete_all_user_sessions",
"session_exists",
}
assert expected.issubset(abstract_methods)
def test_concrete_subclass_works(self):
class ConcreteStore(SessionStorePort):
def save_session(self, **kwargs): # type: ignore[override]
return True
def get_session(self, session_id): # type: ignore[override]
return None
def get_session_by_refresh_token(self, token): # type: ignore[override]
return None
def get_refresh_token(self, session_id): # type: ignore[override]
return None
def update_last_active(self, session_id): # type: ignore[override]
return True
def delete_session(self, session_id): # type: ignore[override]
return True
def get_user_sessions(self, user_id): # type: ignore[override]
return []
def delete_all_user_sessions(self, user_id): # type: ignore[override]
return 0
def session_exists(self, session_id): # type: ignore[override]
return False
store = ConcreteStore()
assert isinstance(store, SessionStorePort)
assert store.session_exists("s1") is False
assert store.delete_session("s1") is True
class TestEmailServicePort:
"""EmailServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(EmailServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
EmailServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = EmailServicePort.__abstractmethods__
expected = {"send_email", "send_verification_email", "send_password_reset_email"}
assert expected.issubset(abstract_methods)
class TestJWTServicePort:
"""JWTServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(JWTServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
JWTServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = JWTServicePort.__abstractmethods__
expected = {
"create_access_token",
"create_refresh_token",
"verify_token",
"verify_access_token",
"verify_refresh_token",
}
assert expected.issubset(abstract_methods)
class TestPasswordHasherPort:
"""PasswordHasherPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordHasherPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordHasherPort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = PasswordHasherPort.__abstractmethods__
expected = {"hash_password", "verify_password", "needs_rehash"}
assert expected.issubset(abstract_methods)
class TestPasswordValidatorPort:
"""PasswordValidatorPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordValidatorPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordValidatorPort() # type: ignore[misc]
def test_has_validate_method(self):
assert "validate" in PasswordValidatorPort.__abstractmethods__
class TestSmsService:
"""SmsService 接口测试."""
def test_is_abstract(self):
assert issubclass(SmsService, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SmsService() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SmsService.__abstractmethods__
expected = {"send_verification_code", "send_template_sms"}
assert expected.issubset(abstract_methods)
+139
View File
@@ -0,0 +1,139 @@
"""bgm_utils 单元测试."""
from __future__ import annotations
from domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfigEmptyInputs:
"""空输入测试."""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
def test_user_empty_returns_template_copy(self):
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, {})
assert result == {"enabled": True, "volume": 0.5}
# 返回的是副本不是同一个对象
assert result is not template
def test_template_empty_returns_user_copy(self):
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == {"enabled": False, "volume": 0.8}
assert result is not user
def test_user_none_returns_template(self):
template = {"enabled": True}
result = merge_bgm_config(template, None) # type: ignore[arg-type]
assert result == template
def test_template_none_returns_user(self):
user = {"enabled": True}
result = merge_bgm_config(None, user) # type: ignore[arg-type]
assert result == user
class TestMergeBgmConfigBasicMerge:
"""基础合并测试."""
def test_user_overrides_template_field(self):
template = {"volume": 0.5, "fade_in": 1.0}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["fade_in"] == 1.0
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 2.0
def test_all_fields_overridden(self):
template = {"enabled": True, "volume": 0.5, "track_id": "t1"}
user = {"enabled": False, "volume": 1.0, "track_id": "t2"}
result = merge_bgm_config(template, user)
assert result == {"enabled": False, "volume": 1.0, "track_id": "t2"}
class TestMergeBgmConfigEnabledSpecial:
"""enabled 特殊处理测试."""
def test_user_no_enabled_keeps_template_enabled_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_no_enabled_keeps_template_enabled_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_explicit_enabled_true_overrides_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_explicit_enabled_false_overrides_template_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_template_no_enabled_user_no_enabled(self):
template = {"volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert "enabled" not in result
assert result["volume"] == 0.8
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
class TestMergeBgmConfigDoesNotMutate:
"""不修改原字典测试."""
def test_template_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
original = dict(template)
user = {"volume": 0.8, "fade": 1.0}
merge_bgm_config(template, user)
assert template == original
def test_user_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
original = dict(user)
merge_bgm_config(template, user)
assert user == original
class TestMergeBgmConfigNestedDict:
"""嵌套字典合并测试(简单合并,非深合并)."""
def test_nested_dict_user_overrides(self):
template = {"effects": {"fade_in": 1.0, "fade_out": 1.0}}
user = {"effects": {"fade_in": 2.0}}
result = merge_bgm_config(template, user)
# 简单合并,用户effects整个覆盖模板的
assert result["effects"] == {"fade_in": 2.0}
def test_nested_dict_preserved_when_no_user_override(self):
template = {"effects": {"fade_in": 1.0}}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["effects"] == {"fade_in": 1.0}
+825
View File
@@ -0,0 +1,825 @@
"""config_schemas 模块单测.
覆盖:枚举类型、各子配置模型、完整Schema模型、normalize工具函数。
"""
import copy
import pytest
from domain.config_schemas import (
DEFAULT_EDIT_PLAN_CONFIG,
DEFAULT_EDIT_TEMPLATE_CONFIG,
BGMConfig,
BGMSource,
CoverConfig,
CoverType,
EditPlanConfigSchema,
EditTemplateConfigSchema,
ExportConfig,
FilterConfig,
ShadowConfig,
StrokeConfig,
SubtitleConfig,
TextAnimation,
TextPosition,
TitleConfig,
normalize_plan_config,
normalize_template_config,
)
from pydantic import ValidationError
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
class TestCoverType:
"""CoverType 枚举"""
def test_enum_values(self):
assert CoverType.AI_FRAME.value == "ai_frame"
assert CoverType.MANUAL.value == "manual"
assert CoverType.UPLOAD.value == "upload"
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
def test_is_str_enum(self):
assert isinstance(CoverType.AI_FRAME, str)
assert CoverType.AI_FRAME == "ai_frame"
def test_from_string(self):
assert CoverType("ai_frame") == CoverType.AI_FRAME
assert CoverType("manual") == CoverType.MANUAL
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
CoverType("invalid")
class TestTextPosition:
"""TextPosition 枚举"""
def test_enum_values(self):
assert TextPosition.TOP.value == "top"
assert TextPosition.CENTER.value == "center"
assert TextPosition.BOTTOM.value == "bottom"
def test_from_string(self):
assert TextPosition("top") == TextPosition.TOP
assert TextPosition("bottom") == TextPosition.BOTTOM
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
TextPosition("left")
class TestTextAnimation:
"""TextAnimation 枚举"""
def test_enum_values(self):
assert TextAnimation.NONE.value == "none"
assert TextAnimation.FADE_IN.value == "fade_in"
assert TextAnimation.SLIDE_UP.value == "slide_up"
assert TextAnimation.SLIDE_DOWN.value == "slide_down"
assert TextAnimation.SCALE.value == "scale"
def test_from_string(self):
assert TextAnimation("fade_in") == TextAnimation.FADE_IN
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
TextAnimation("bounce")
class TestBGMSource:
"""BGMSource 枚举"""
def test_enum_values(self):
assert BGMSource.LIBRARY.value == "library"
assert BGMSource.UPLOAD.value == "upload"
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
def test_from_string(self):
assert BGMSource("library") == BGMSource.LIBRARY
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
BGMSource("spotify")
# ── StrokeConfig / ShadowConfig ──────────────────────────────────────────────
class TestStrokeConfig:
"""StrokeConfig 描边配置"""
def test_default_values(self):
s = StrokeConfig()
assert s.enabled is False
assert s.color == "#000000"
assert s.width == 1
def test_custom_values(self):
s = StrokeConfig(enabled=True, color="#ff0000", width=5)
assert s.enabled is True
assert s.color == "#ff0000"
assert s.width == 5
def test_width_min_boundary(self):
s = StrokeConfig(width=1)
assert s.width == 1
def test_width_max_boundary(self):
s = StrokeConfig(width=10)
assert s.width == 10
def test_width_below_min_raises(self):
with pytest.raises(ValidationError):
StrokeConfig(width=0)
def test_width_above_max_raises(self):
with pytest.raises(ValidationError):
StrokeConfig(width=11)
class TestShadowConfig:
"""ShadowConfig 阴影配置"""
def test_default_values(self):
s = ShadowConfig()
assert s.enabled is False
assert s.blur == 4
assert s.offset_x == 2
assert s.offset_y == 2
def test_custom_values(self):
s = ShadowConfig(enabled=True, blur=10, offset_x=5, offset_y=5)
assert s.enabled is True
assert s.blur == 10
assert s.offset_x == 5
assert s.offset_y == 5
def test_blur_min_boundary(self):
s = ShadowConfig(blur=0)
assert s.blur == 0
def test_blur_max_boundary(self):
s = ShadowConfig(blur=20)
assert s.blur == 20
def test_blur_above_max_raises(self):
with pytest.raises(ValidationError):
ShadowConfig(blur=21)
# ── CoverConfig ──────────────────────────────────────────────────────────────
class TestCoverConfig:
"""CoverConfig 封面配置"""
def test_default_values(self):
c = CoverConfig()
assert c.type == CoverType.AI_FRAME
assert c.image_url == ""
assert c.frame_time is None
def test_manual_type_with_frame_time(self):
c = CoverConfig(type=CoverType.MANUAL, frame_time=5.5)
assert c.type == CoverType.MANUAL
assert c.frame_time == 5.5
def test_upload_type_with_image_url(self):
c = CoverConfig(type=CoverType.UPLOAD, image_url="https://example.com/cover.jpg")
assert c.type == CoverType.UPLOAD
assert c.image_url == "https://example.com/cover.jpg"
def test_frame_time_negative_raises(self):
with pytest.raises(ValidationError):
CoverConfig(frame_time=-1.0)
def test_frame_time_zero_valid(self):
c = CoverConfig(frame_time=0.0)
assert c.frame_time == 0.0
def test_from_dict_with_string_enum(self):
c = CoverConfig(**{"type": "ai_regenerate", "image_url": ""})
assert c.type == CoverType.AI_REGENERATE
# ── TitleConfig ───────────────────────────────────────────────────────────────
class TestTitleConfig:
"""TitleConfig 标题配置"""
def test_default_values(self):
t = TitleConfig()
assert t.enabled is True
assert t.ai_auto is True
assert t.text == ""
assert t.position == TextPosition.TOP
assert t.font == "思源黑体"
assert t.color == "#ffffff"
assert t.size == 48
assert t.bold is True
assert t.italic is False
assert isinstance(t.stroke, StrokeConfig)
assert isinstance(t.shadow, ShadowConfig)
def test_custom_title(self):
t = TitleConfig(
enabled=True,
ai_auto=False,
text="我的视频标题",
position=TextPosition.CENTER,
font="微软雅黑",
color="#000000",
size=36,
bold=False,
italic=True,
)
assert t.text == "我的视频标题"
assert t.position == TextPosition.CENTER
assert t.size == 36
assert t.bold is False
assert t.italic is True
def test_size_min_boundary(self):
t = TitleConfig(size=12)
assert t.size == 12
def test_size_max_boundary(self):
t = TitleConfig(size=120)
assert t.size == 120
def test_size_below_min_raises(self):
with pytest.raises(ValidationError):
TitleConfig(size=11)
def test_size_above_max_raises(self):
with pytest.raises(ValidationError):
TitleConfig(size=121)
def test_stroke_nested_config(self):
t = TitleConfig(stroke={"enabled": True, "color": "#ff0000", "width": 3})
assert t.stroke.enabled is True
assert t.stroke.color == "#ff0000"
assert t.stroke.width == 3
def test_shadow_nested_config(self):
t = TitleConfig(shadow={"enabled": True, "blur": 8, "offset_x": 3, "offset_y": 3})
assert t.shadow.enabled is True
assert t.shadow.blur == 8
# ── SubtitleConfig ────────────────────────────────────────────────────────────
class TestSubtitleConfig:
"""SubtitleConfig 字幕配置"""
def test_default_values(self):
s = SubtitleConfig()
assert s.enabled is True
assert s.position == TextPosition.BOTTOM
assert s.font == "思源黑体"
assert s.color == "#ffffff"
assert s.size == 24
assert s.animation == TextAnimation.FADE_IN
assert s.auto_generated is False
assert s.language == ""
assert s.max_chars_per_line == 20
assert s.min_chars_per_segment == 8
def test_custom_subtitle(self):
s = SubtitleConfig(
enabled=False,
position=TextPosition.TOP,
size=32,
animation=TextAnimation.SLIDE_UP,
auto_generated=True,
language="zh",
max_chars_per_line=30,
min_chars_per_segment=10,
)
assert s.enabled is False
assert s.position == TextPosition.TOP
assert s.size == 32
assert s.animation == TextAnimation.SLIDE_UP
assert s.auto_generated is True
assert s.language == "zh"
def test_size_min_boundary(self):
s = SubtitleConfig(size=12)
assert s.size == 12
def test_size_max_boundary(self):
s = SubtitleConfig(size=60)
assert s.size == 60
def test_size_below_min_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(size=11)
def test_max_chars_min_boundary(self):
s = SubtitleConfig(max_chars_per_line=8)
assert s.max_chars_per_line == 8
def test_max_chars_max_boundary(self):
s = SubtitleConfig(max_chars_per_line=40)
assert s.max_chars_per_line == 40
def test_max_chars_out_of_range_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(max_chars_per_line=41)
def test_min_chars_min_boundary(self):
s = SubtitleConfig(min_chars_per_segment=2)
assert s.min_chars_per_segment == 2
def test_min_chars_max_boundary(self):
s = SubtitleConfig(min_chars_per_segment=20)
assert s.min_chars_per_segment == 20
def test_min_chars_out_of_range_raises(self):
with pytest.raises(ValidationError):
SubtitleConfig(min_chars_per_segment=1)
# ── BGMConfig ─────────────────────────────────────────────────────────────────
class TestBGMConfig:
"""BGMConfig BGM配置"""
def test_default_values(self):
b = BGMConfig()
assert b.enabled is False
assert b.source == BGMSource.LIBRARY
assert b.asset_id == ""
assert b.preset_id == ""
assert b.audio_url == ""
assert b.volume == 0.3
assert b.fade_in == 0.0
assert b.fade_out == 0.0
assert b.loop_enabled is True
assert b.sidechain_enabled is False
assert b.sidechain_ratio == 0.3
assert b.sidechain_attack == 0.02
assert b.sidechain_release == 0.5
assert b.sidechain_threshold == -25.0
def test_custom_bgm(self):
b = BGMConfig(
enabled=True,
source=BGMSource.UPLOAD,
asset_id="bgm_123",
volume=0.5,
fade_in=2.0,
fade_out=3.0,
sidechain_enabled=True,
sidechain_ratio=0.5,
)
assert b.enabled is True
assert b.source == BGMSource.UPLOAD
assert b.volume == 0.5
assert b.sidechain_enabled is True
assert b.sidechain_ratio == 0.5
def test_volume_range(self):
b = BGMConfig(volume=0.0)
assert b.volume == 0.0
b = BGMConfig(volume=1.0)
assert b.volume == 1.0
def test_volume_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(volume=-0.1)
with pytest.raises(ValidationError):
BGMConfig(volume=1.1)
def test_fade_in_range(self):
b = BGMConfig(fade_in=30.0)
assert b.fade_in == 30.0
def test_fade_in_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(fade_in=31.0)
def test_sidechain_attack_min(self):
b = BGMConfig(sidechain_attack=0.001)
assert b.sidechain_attack == 0.001
def test_sidechain_attack_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(sidechain_attack=0.0001)
def test_sidechain_threshold_range(self):
b = BGMConfig(sidechain_threshold=-60.0)
assert b.sidechain_threshold == -60.0
b = BGMConfig(sidechain_threshold=0.0)
assert b.sidechain_threshold == 0.0
def test_sidechain_threshold_out_of_range_raises(self):
with pytest.raises(ValidationError):
BGMConfig(sidechain_threshold=-61.0)
with pytest.raises(ValidationError):
BGMConfig(sidechain_threshold=1.0)
# ── ExportConfig ──────────────────────────────────────────────────────────────
class TestExportConfig:
"""ExportConfig 导出配置"""
def test_default_values(self):
e = ExportConfig()
assert e.resolution == "1080x1920"
assert e.fps == 30
assert e.video_bitrate == 8000
assert e.audio_bitrate == 128
assert e.format == "mp4"
assert e.quality_preset == "balanced"
assert e.watermark_enabled is False
assert e.watermark_text == ""
def test_custom_export(self):
e = ExportConfig(
resolution="720x1280",
fps=60,
video_bitrate=5000,
audio_bitrate=192,
format="mov",
quality_preset="high",
watermark_enabled=True,
watermark_text="我的水印",
)
assert e.resolution == "720x1280"
assert e.fps == 60
assert e.format == "mov"
assert e.watermark_enabled is True
def test_fps_min_boundary(self):
e = ExportConfig(fps=15)
assert e.fps == 15
def test_fps_max_boundary(self):
e = ExportConfig(fps=60)
assert e.fps == 60
def test_fps_out_of_range_raises(self):
with pytest.raises(ValidationError):
ExportConfig(fps=14)
with pytest.raises(ValidationError):
ExportConfig(fps=61)
def test_video_bitrate_range(self):
e = ExportConfig(video_bitrate=1000)
assert e.video_bitrate == 1000
e = ExportConfig(video_bitrate=20000)
assert e.video_bitrate == 20000
def test_video_bitrate_out_of_range_raises(self):
with pytest.raises(ValidationError):
ExportConfig(video_bitrate=999)
with pytest.raises(ValidationError):
ExportConfig(video_bitrate=20001)
def test_audio_bitrate_range(self):
e = ExportConfig(audio_bitrate=64)
assert e.audio_bitrate == 64
e = ExportConfig(audio_bitrate=320)
assert e.audio_bitrate == 320
# ── FilterConfig ──────────────────────────────────────────────────────────────
class TestFilterConfig:
"""FilterConfig 滤镜配置"""
def test_default_values(self):
f = FilterConfig()
assert f.enabled is False
assert f.preset_id == "filter_none"
assert f.intensity == 100
assert f.brightness == 0.0
assert f.contrast == 1.0
assert f.saturation == 1.0
assert f.warmth == 0.0
def test_custom_filter(self):
f = FilterConfig(
enabled=True,
preset_id="vintage",
intensity=50,
brightness=0.3,
contrast=1.5,
saturation=2.0,
warmth=-0.5,
)
assert f.enabled is True
assert f.preset_id == "vintage"
assert f.intensity == 50
assert f.brightness == 0.3
def test_intensity_range(self):
f = FilterConfig(intensity=0)
assert f.intensity == 0
f = FilterConfig(intensity=100)
assert f.intensity == 100
def test_intensity_out_of_range_raises(self):
with pytest.raises(ValidationError):
FilterConfig(intensity=-1)
with pytest.raises(ValidationError):
FilterConfig(intensity=101)
def test_brightness_range(self):
f = FilterConfig(brightness=-1.0)
assert f.brightness == -1.0
f = FilterConfig(brightness=1.0)
assert f.brightness == 1.0
def test_contrast_range(self):
f = FilterConfig(contrast=0.0)
assert f.contrast == 0.0
f = FilterConfig(contrast=2.0)
assert f.contrast == 2.0
def test_saturation_range(self):
f = FilterConfig(saturation=0.0)
assert f.saturation == 0.0
f = FilterConfig(saturation=3.0)
assert f.saturation == 3.0
def test_warmth_range(self):
f = FilterConfig(warmth=-1.0)
assert f.warmth == -1.0
f = FilterConfig(warmth=1.0)
assert f.warmth == 1.0
def test_brightness_out_of_range_raises(self):
with pytest.raises(ValidationError):
FilterConfig(brightness=-1.1)
with pytest.raises(ValidationError):
FilterConfig(brightness=1.1)
# ── 完整 Schema 模型 ──────────────────────────────────────────────────────────
class TestEditPlanConfigSchema:
"""EditPlanConfigSchema 完整计划配置"""
def test_default_values(self):
s = EditPlanConfigSchema()
assert isinstance(s.cover, CoverConfig)
assert isinstance(s.title, TitleConfig)
assert isinstance(s.subtitle, SubtitleConfig)
assert isinstance(s.bgm, BGMConfig)
assert isinstance(s.export, ExportConfig)
assert isinstance(s.filter, FilterConfig)
assert s.editing_mode == "one_take"
def test_partial_update_via_dict(self):
s = EditPlanConfigSchema(
**{
"cover": {"type": "manual", "frame_time": 10.0},
"title": {"text": "自定义标题", "size": 60},
"editing_mode": "template",
}
)
assert s.cover.type == CoverType.MANUAL
assert s.cover.frame_time == 10.0
assert s.title.text == "自定义标题"
assert s.title.size == 60
assert s.editing_mode == "template"
def test_full_config_dict_roundtrip(self):
data = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
data["title"]["text"] = "测试标题"
data["bgm"]["enabled"] = True
s = EditPlanConfigSchema(**data)
assert s.title.text == "测试标题"
assert s.bgm.enabled is True
# 默认字段保留
assert s.subtitle.size == 24
assert s.export.fps == 30
def test_invalid_subfield_raises(self):
with pytest.raises(ValidationError):
EditPlanConfigSchema(**{"title": {"size": 999}})
class TestEditTemplateConfigSchema:
"""EditTemplateConfigSchema 模板配置"""
def test_default_values(self):
s = EditTemplateConfigSchema()
assert isinstance(s.cover, CoverConfig)
assert s.editing_mode == "one_take"
assert s.transition_enabled is True
def test_custom_transition_enabled(self):
s = EditTemplateConfigSchema(transition_enabled=False)
assert s.transition_enabled is False
def test_has_all_plan_fields(self):
s = EditTemplateConfigSchema()
assert hasattr(s, "cover")
assert hasattr(s, "title")
assert hasattr(s, "subtitle")
assert hasattr(s, "bgm")
assert hasattr(s, "export")
assert hasattr(s, "filter")
assert hasattr(s, "editing_mode")
assert hasattr(s, "transition_enabled")
# ── 默认值常量 ────────────────────────────────────────────────────────────────
class TestDefaultConfigs:
"""默认配置常量"""
def test_default_plan_config_structure(self):
assert "cover" in DEFAULT_EDIT_PLAN_CONFIG
assert "title" in DEFAULT_EDIT_PLAN_CONFIG
assert "subtitle" in DEFAULT_EDIT_PLAN_CONFIG
assert "bgm" in DEFAULT_EDIT_PLAN_CONFIG
assert "export" in DEFAULT_EDIT_PLAN_CONFIG
assert "filter" in DEFAULT_EDIT_PLAN_CONFIG
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
def test_default_template_config_extra_field(self):
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
def test_template_config_inherits_plan(self):
# 模板配置应该包含计划配置的所有字段
for key in DEFAULT_EDIT_PLAN_CONFIG:
assert key in DEFAULT_EDIT_TEMPLATE_CONFIG
def test_defaults_are_valid_for_schema(self):
# 默认值应该能通过 schema 校验
plan = EditPlanConfigSchema(**DEFAULT_EDIT_PLAN_CONFIG)
assert plan.editing_mode == "one_take"
template = EditTemplateConfigSchema(**DEFAULT_EDIT_TEMPLATE_CONFIG)
assert template.transition_enabled is True
def test_mutation_does_not_affect_original(self):
# 修改返回的 dict 不应该影响常量
d = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
d["cover"]["type"] = "upload"
assert DEFAULT_EDIT_PLAN_CONFIG["cover"]["type"] == "ai_frame"
# ── normalize_plan_config ────────────────────────────────────────────────────
class TestNormalizePlanConfig:
"""normalize_plan_config 工具函数"""
def test_none_returns_full_defaults(self):
result = normalize_plan_config(None)
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
def test_empty_dict_returns_defaults(self):
result = normalize_plan_config({})
assert result == copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
def test_partial_cover_update(self):
result = normalize_plan_config({"cover": {"type": "manual"}})
assert result["cover"]["type"] == "manual"
# 其他 cover 字段保留默认
assert result["cover"]["image_url"] == ""
assert result["cover"]["frame_time"] is None
def test_partial_title_update(self):
result = normalize_plan_config({"title": {"text": "我的标题", "size": 36}})
assert result["title"]["text"] == "我的标题"
assert result["title"]["size"] == 36
assert result["title"]["font"] == "思源黑体"
def test_partial_subtitle_update(self):
result = normalize_plan_config({"subtitle": {"size": 28}})
assert result["subtitle"]["size"] == 28
assert result["subtitle"]["position"] == "bottom"
def test_partial_bgm_update(self):
result = normalize_plan_config({"bgm": {"enabled": True, "volume": 0.5}})
assert result["bgm"]["enabled"] is True
assert result["bgm"]["volume"] == 0.5
assert result["bgm"]["source"] == "library"
def test_editing_mode_update(self):
result = normalize_plan_config({"editing_mode": "template"})
assert result["editing_mode"] == "template"
def test_extra_fields_preserved(self):
result = normalize_plan_config({"generation_task_id": "task_123", "custom_field": "value"})
assert result["generation_task_id"] == "task_123"
assert result["custom_field"] == "value"
# 标准字段也保留
assert result["editing_mode"] == "one_take"
def test_combined_update(self):
result = normalize_plan_config(
{
"cover": {"type": "upload", "image_url": "http://x.com/c.jpg"},
"title": {"text": "标题", "size": 60},
"bgm": {"enabled": True},
"editing_mode": "smart",
"extra_key": "extra_value",
}
)
assert result["cover"]["type"] == "upload"
assert result["title"]["text"] == "标题"
assert result["bgm"]["enabled"] is True
assert result["editing_mode"] == "smart"
assert result["extra_key"] == "extra_value"
def test_non_dict_section_ignored(self):
result = normalize_plan_config({"cover": "not_a_dict"})
# cover 应该还是默认值
assert result["cover"]["type"] == "ai_frame"
def test_editing_mode_non_string_ignored(self):
result = normalize_plan_config({"editing_mode": 123})
assert result["editing_mode"] == "one_take"
def test_does_not_mutate_input(self):
raw = {"cover": {"type": "manual"}, "extra": "value"}
raw_copy = copy.deepcopy(raw)
normalize_plan_config(raw)
assert raw == raw_copy
def test_does_not_mutate_defaults(self):
original = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
normalize_plan_config({"cover": {"type": "upload"}})
assert DEFAULT_EDIT_PLAN_CONFIG == original
# ── normalize_template_config ─────────────────────────────────────────────────
class TestNormalizeTemplateConfig:
"""normalize_template_config 工具函数"""
def test_none_returns_full_defaults(self):
result = normalize_template_config(None)
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
def test_empty_dict_returns_defaults(self):
result = normalize_template_config({})
assert result == copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
assert result["transition_enabled"] is True
def test_partial_sections(self):
result = normalize_template_config(
{
"title": {"text": "模板标题"},
"bgm": {"enabled": True},
}
)
assert result["title"]["text"] == "模板标题"
assert result["bgm"]["enabled"] is True
def test_transition_enabled_update(self):
result = normalize_template_config({"transition_enabled": False})
assert result["transition_enabled"] is False
def test_transition_enabled_non_bool_ignored(self):
result = normalize_template_config({"transition_enabled": "yes"})
assert result["transition_enabled"] is True
def test_editing_mode_update(self):
result = normalize_template_config({"editing_mode": "story"})
assert result["editing_mode"] == "story"
def test_extra_fields_preserved(self):
result = normalize_template_config({"template_version": "v2", "author": "test"})
assert result["template_version"] == "v2"
assert result["author"] == "test"
assert result["transition_enabled"] is True
def test_combined_update(self):
result = normalize_template_config(
{
"cover": {"type": "ai_regenerate"},
"subtitle": {"size": 20},
"transition_enabled": False,
"editing_mode": "vlog",
"tags": ["travel", "food"],
}
)
assert result["cover"]["type"] == "ai_regenerate"
assert result["subtitle"]["size"] == 20
assert result["transition_enabled"] is False
assert result["editing_mode"] == "vlog"
assert result["tags"] == ["travel", "food"]
def test_does_not_mutate_defaults(self):
original = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
normalize_template_config({"transition_enabled": False})
assert DEFAULT_EDIT_TEMPLATE_CONFIG == original
+63
View File
@@ -0,0 +1,63 @@
"""EmailConfig 单元测试."""
from __future__ import annotations
import pytest
from domain.auth.email_service import EmailConfig
class TestEmailConfigDefaults:
"""默认值测试."""
def test_default_values(self):
config = EmailConfig()
assert config.smtp_host == "smtp.gmail.com"
assert config.smtp_port == 587
assert config.smtp_user == ""
assert config.smtp_password == ""
assert config.from_email == ""
assert config.from_name == "小虾 SaaS"
assert config.use_tls is True
def test_custom_construction(self):
config = EmailConfig(
smtp_host="smtp.example.com",
smtp_port=465,
smtp_user="user@example.com",
smtp_password="secret",
from_email="no-reply@example.com",
from_name="Example App",
use_tls=False,
)
assert config.smtp_host == "smtp.example.com"
assert config.smtp_port == 465
assert config.smtp_user == "user@example.com"
assert config.smtp_password == "secret"
assert config.from_email == "no-reply@example.com"
assert config.from_name == "Example App"
assert config.use_tls is False
def test_is_dataclass(self):
# 可重复创建相同配置
c1 = EmailConfig(smtp_host="h.com", smtp_port=25)
c2 = EmailConfig(smtp_host="h.com", smtp_port=25)
assert c1 == c2
class TestEmailConfigEquality:
"""相等性测试."""
def test_equal_same_values(self):
c1 = EmailConfig()
c2 = EmailConfig()
assert c1 == c2
def test_not_equal_different_host(self):
c1 = EmailConfig(smtp_host="a.com")
c2 = EmailConfig(smtp_host="b.com")
assert c1 != c2
def test_not_equal_different_port(self):
c1 = EmailConfig(smtp_port=587)
c2 = EmailConfig(smtp_port=465)
assert c1 != c2
+107
View File
@@ -0,0 +1,107 @@
"""domain exceptions 单元测试."""
from __future__ import annotations
import pytest
from domain.exceptions import (
DomainError,
NotFoundError,
QuotaExceededError,
ValidationError,
)
class TestDomainError:
"""DomainError 基类测试."""
def test_is_exception(self):
assert issubclass(DomainError, Exception)
def test_raise_and_catch(self):
with pytest.raises(DomainError):
raise DomainError("something went wrong")
def test_message(self):
err = DomainError("test message")
assert str(err) == "test message"
def test_empty_message(self):
err = DomainError()
assert str(err) == ""
class TestNotFoundError:
"""NotFoundError 测试."""
def test_is_domain_error(self):
assert issubclass(NotFoundError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise NotFoundError("user not found")
def test_raise_and_catch_specific(self):
with pytest.raises(NotFoundError):
raise NotFoundError("user not found")
def test_message(self):
err = NotFoundError("resource not found")
assert str(err) == "resource not found"
class TestValidationError:
"""ValidationError 测试."""
def test_is_domain_error(self):
assert issubclass(ValidationError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise ValidationError("invalid input")
def test_raise_and_catch_specific(self):
with pytest.raises(ValidationError):
raise ValidationError("invalid input")
def test_message(self):
err = ValidationError("bad data")
assert str(err) == "bad data"
class TestQuotaExceededError:
"""QuotaExceededError 测试."""
def test_is_domain_error(self):
assert issubclass(QuotaExceededError, DomainError)
def test_constructor_stores_fields(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert err.dimension == "storage"
assert err.limit == 100.0
assert err.used == 150.0
def test_message_format(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert "storage" in str(err)
assert "150.0" in str(err)
assert "100.0" in str(err)
assert "Quota exceeded" in str(err)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_raise_and_catch_specific(self):
with pytest.raises(QuotaExceededError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_int_values(self):
err = QuotaExceededError("count", limit=100, used=150)
assert err.limit == 100
assert err.used == 150
assert "150/100" in str(err)
def test_float_values(self):
err = QuotaExceededError("size", limit=10.5, used=20.3)
assert err.limit == 10.5
assert err.used == 20.3
+593
View File
@@ -0,0 +1,593 @@
"""intro_outro_config 片头片尾配置单测."""
import pytest
from domain.intro_outro_config import (
INTRO_OUTRO_TYPE_FOLLOW,
INTRO_OUTRO_TYPE_NONE,
INTRO_OUTRO_TYPE_TEXT,
INTRO_OUTRO_TYPE_VIDEO,
TRANSITION_FADE,
TRANSITION_SLIDE,
TRANSITION_WIPE,
IntroOutroConfig,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_type_constants(self):
assert INTRO_OUTRO_TYPE_NONE == "none"
assert INTRO_OUTRO_TYPE_VIDEO == "video"
assert INTRO_OUTRO_TYPE_TEXT == "text"
assert INTRO_OUTRO_TYPE_FOLLOW == "follow"
def test_transition_constants(self):
assert TRANSITION_FADE == "fade"
assert TRANSITION_SLIDE == "slide"
assert TRANSITION_WIPE == "wipe"
# ── 默认值与基础属性 ──────────────────────────────────────────────────────────
class TestDefaultConfig:
"""IntroOutroConfig 默认值"""
def test_default_not_enabled(self):
c = IntroOutroConfig()
assert c.enabled is False
def test_default_intro(self):
c = IntroOutroConfig()
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.intro_video_path == ""
assert c.intro_duration == 3.0
assert c.intro_background == "#000000"
assert c.intro_title == ""
assert c.intro_subtitle == ""
assert c.intro_title_color == "white"
assert c.intro_title_size == 48
assert c.intro_subtitle_color == "gray"
assert c.intro_subtitle_size == 24
def test_default_outro(self):
c = IntroOutroConfig()
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_video_path == ""
assert c.outro_duration == 3.0
assert c.outro_background == "#000000"
assert c.outro_title == "感谢观看"
assert c.outro_subtitle == "点赞关注不迷路"
assert c.outro_title_color == "white"
assert c.outro_title_size == 48
assert c.outro_subtitle_color == "gray"
assert c.outro_subtitle_size == 24
def test_default_transition(self):
c = IntroOutroConfig()
assert c.transition_effect == TRANSITION_FADE
assert c.transition_duration == 0.5
# ── from_dict 构造 ───────────────────────────────────────────────────────────
class TestFromDict:
"""from_dict 工厂方法"""
def test_none_returns_default(self):
c = IntroOutroConfig.from_dict(None)
assert c.enabled is False
def test_empty_dict_returns_default(self):
c = IntroOutroConfig.from_dict({})
assert c.enabled is False
def test_enabled_false_returns_default(self):
c = IntroOutroConfig.from_dict({"enabled": False})
assert c.enabled is False
def test_minimal_enabled(self):
c = IntroOutroConfig.from_dict({"enabled": True})
assert c.enabled is True
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
def test_intro_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "video",
"video_path": "/tmp/intro.mp4",
"duration": 5.0,
},
}
)
assert c.intro_type == INTRO_OUTRO_TYPE_VIDEO
assert c.intro_video_path == "/tmp/intro.mp4"
assert c.intro_duration == 5.0
def test_intro_text(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎来到",
"subtitle": "我的频道",
"background": "#ffffff",
"title_color": "black",
"title_size": 64,
"subtitle_color": "darkgray",
"subtitle_size": 32,
},
}
)
assert c.intro_type == INTRO_OUTRO_TYPE_TEXT
assert c.intro_title == "欢迎来到"
assert c.intro_subtitle == "我的频道"
assert c.intro_background == "#ffffff"
assert c.intro_title_size == 64
assert c.intro_subtitle_size == 32
def test_outro_text(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "text",
"title": "再见",
"subtitle": "下次见",
"title_size": 56,
},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_TEXT
assert c.outro_title == "再见"
assert c.outro_subtitle == "下次见"
assert c.outro_title_size == 56
def test_outro_follow_type(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "follow", "title": "关注我"},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_FOLLOW
assert c.outro_title == "关注我"
def test_outro_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 4.0,
},
}
)
assert c.outro_type == INTRO_OUTRO_TYPE_VIDEO
assert c.outro_video_path == "/tmp/outro.mp4"
assert c.outro_duration == 4.0
def test_transition_config(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"transition": "slide",
"transition_duration": 1.0,
}
)
assert c.transition_effect == TRANSITION_SLIDE
assert c.transition_duration == 1.0
def test_video_field_alias(self):
# video 字段兼容(video_path 和 video 都能用)
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "video", "video": "old_path.mp4"},
}
)
assert c.intro_video_path == "old_path.mp4"
def test_video_path_preferred_over_video(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"type": "video", "video_path": "new.mp4", "video": "old.mp4"},
}
)
assert c.intro_video_path == "new.mp4"
def test_invalid_duration_falls_back(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"duration": "abc"},
}
)
assert c.intro_duration == 3.0
def test_invalid_size_falls_back(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {"title_size": "not_a_number"},
}
)
assert c.intro_title_size == 48
def test_none_intro_outro(self):
c = IntroOutroConfig.from_dict({"enabled": True, "intro": None, "outro": None})
assert c.intro_type == INTRO_OUTRO_TYPE_NONE
assert c.outro_type == INTRO_OUTRO_TYPE_NONE
def test_empty_title_defaults_for_outro(self):
# outro title 为空时回退到默认值
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"type": "text", "title": ""},
}
)
assert c.outro_title == "感谢观看"
def test_empty_subtitle_defaults_for_outro(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {"subtitle": ""},
}
)
assert c.outro_subtitle == "点赞关注不迷路"
def test_combined_full_config(self):
c = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "片头标题",
"subtitle": "片头副标题",
"background": "#123456",
"duration": 2.5,
"title_size": 72,
},
"outro": {
"type": "video",
"video_path": "/outro.mp4",
"duration": 4.0,
},
"transition": "wipe",
"transition_duration": 0.8,
}
)
assert c.intro_title == "片头标题"
assert c.intro_duration == 2.5
assert c.outro_type == "video"
assert c.outro_video_path == "/outro.mp4"
assert c.transition_effect == "wipe"
assert c.transition_duration == 0.8
def test_does_not_mutate_input(self):
data = {"enabled": True, "intro": {"type": "text", "title": "test"}}
data_copy = {
"enabled": True,
"intro": {"type": "text", "title": "test"},
}
IntroOutroConfig.from_dict(data)
assert data == data_copy
# ── has_intro / has_outro 属性 ───────────────────────────────────────────────
class TestHasIntroOutro:
"""has_intro / has_outro 属性"""
def test_disabled_no_intro(self):
c = IntroOutroConfig()
assert c.has_intro is False
def test_disabled_no_outro(self):
c = IntroOutroConfig()
assert c.has_outro is False
def test_enabled_none_type_no_intro(self):
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_NONE)
assert c.has_intro is False
def test_video_intro_has_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/x.mp4",
)
assert c.has_intro is True
def test_text_intro_has_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
)
assert c.has_intro is True
def test_video_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="/x.mp4",
)
assert c.has_outro is True
def test_text_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
)
assert c.has_outro is True
def test_follow_outro_has_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="关注",
)
assert c.has_outro is True
def test_follow_type_no_intro(self):
# follow 只是片尾类型,片头不支持
c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_FOLLOW)
assert c.has_intro is False
# ── total_extra_duration ─────────────────────────────────────────────────────
class TestTotalExtraDuration:
"""total_extra_duration 属性"""
def test_disabled_zero(self):
c = IntroOutroConfig()
assert c.total_extra_duration == 0.0
def test_only_intro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=3.0,
)
assert c.total_extra_duration == 3.0
def test_only_outro(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=4.0,
)
assert c.total_extra_duration == 4.0
def test_both_intro_outro(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/i.mp4",
intro_duration=2.5,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="/o.mp4",
outro_duration=3.5,
)
assert c.total_extra_duration == 6.0
def test_zero_duration_not_counted(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=0.0,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=0.0,
)
assert c.total_extra_duration == 0.0
def test_negative_duration_not_counted(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=-1.0,
)
assert c.total_extra_duration == 0.0
# ── validate 校验 ────────────────────────────────────────────────────────────
class TestValidate:
"""validate 方法"""
def test_disabled_always_valid(self):
c = IntroOutroConfig()
valid, msg = c.validate()
assert valid is True
assert msg == ""
def test_none_types_valid(self):
c = IntroOutroConfig(enabled=True)
valid, msg = c.validate()
assert valid is True
def test_invalid_intro_type(self):
c = IntroOutroConfig(enabled=True, intro_type="invalid")
valid, msg = c.validate()
assert valid is False
assert "片头类型" in msg
def test_invalid_outro_type(self):
c = IntroOutroConfig(enabled=True, outro_type="invalid")
valid, msg = c.validate()
assert valid is False
assert "片尾类型" in msg
def test_video_intro_no_path(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="",
)
valid, msg = c.validate()
assert valid is False
assert "video_path" in msg
def test_video_intro_with_path_valid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_VIDEO,
intro_video_path="/path.mp4",
)
valid, _ = c.validate()
assert valid is True
def test_text_intro_no_title(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_text_intro_with_title_valid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
)
valid, _ = c.validate()
assert valid is True
def test_video_outro_no_path(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_VIDEO,
outro_video_path="",
)
valid, msg = c.validate()
assert valid is False
assert "video_path" in msg
def test_text_outro_no_title(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_follow_outro_no_title(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="",
)
valid, msg = c.validate()
assert valid is False
assert "title" in msg
def test_follow_outro_with_title_valid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_FOLLOW,
outro_title="关注",
)
valid, _ = c.validate()
assert valid is True
def test_zero_intro_duration_invalid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=0.0,
)
valid, msg = c.validate()
assert valid is False
assert "片头时长" in msg
def test_negative_outro_duration_invalid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=-1.0,
)
valid, msg = c.validate()
assert valid is False
assert "片尾时长" in msg
def test_negative_transition_duration_invalid(self):
c = IntroOutroConfig(enabled=True, transition_duration=-0.5)
valid, msg = c.validate()
assert valid is False
assert "转场" in msg
def test_zero_transition_valid(self):
c = IntroOutroConfig(enabled=True, transition_duration=0.0)
valid, _ = c.validate()
assert valid is True
def test_zero_title_size_invalid(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_title_size=0,
)
valid, msg = c.validate()
assert valid is False
assert "标题字号" in msg
def test_negative_subtitle_size_invalid(self):
c = IntroOutroConfig(
enabled=True,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_subtitle_size=-1,
)
valid, msg = c.validate()
assert valid is False
assert "副标题字号" in msg
def test_full_valid_config(self):
c = IntroOutroConfig(
enabled=True,
intro_type=INTRO_OUTRO_TYPE_TEXT,
intro_title="Hi",
intro_duration=3.0,
intro_title_size=48,
intro_subtitle_size=24,
outro_type=INTRO_OUTRO_TYPE_TEXT,
outro_title="Bye",
outro_duration=3.0,
outro_title_size=48,
outro_subtitle_size=24,
transition_duration=0.5,
)
valid, msg = c.validate()
assert valid is True
assert msg == ""
+290
View File
@@ -0,0 +1,290 @@
"""media_validation 媒体文件校验单测."""
import pytest
from 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 TestConstants:
"""模块常量"""
def test_min_sizes(self):
assert MIN_VIDEO_FILE_SIZE == 1024
assert MIN_AUDIO_FILE_SIZE == 100
assert MIN_IMAGE_FILE_SIZE == 100
def test_supported_codecs_is_frozenset(self):
assert isinstance(SUPPORTED_VIDEO_CODECS, frozenset)
def test_supported_codecs_includes_common(self):
assert "h264" in SUPPORTED_VIDEO_CODECS
assert "hevc" in SUPPORTED_VIDEO_CODECS
assert "vp9" in SUPPORTED_VIDEO_CODECS
assert "av1" in SUPPORTED_VIDEO_CODECS
assert "mpeg4" in SUPPORTED_VIDEO_CODECS
assert "prores" in SUPPORTED_VIDEO_CODECS
def test_supported_codecs_count(self):
assert len(SUPPORTED_VIDEO_CODECS) >= 20
# ── safe_parse_fps ───────────────────────────────────────────────────────────
class TestSafeParseFps:
"""safe_parse_fps 函数"""
def test_simple_decimal(self):
assert safe_parse_fps("30.0") == 30.0
def test_integer_string(self):
assert safe_parse_fps("24") == 24.0
def test_fraction_format(self):
assert abs(safe_parse_fps("30000/1001") - 29.97) < 0.01
def test_simple_fraction(self):
assert safe_parse_fps("30/1") == 30.0
def test_24fps_fraction(self):
assert safe_parse_fps("24/1") == 24.0
def test_60fps_fraction(self):
assert safe_parse_fps("60000/1001") == pytest.approx(59.94, abs=0.01)
def test_zero_denominator_returns_zero(self):
assert safe_parse_fps("30/0") == 0.0
def test_empty_string_returns_zero(self):
assert safe_parse_fps("") == 0.0
def test_invalid_string_returns_zero(self):
assert safe_parse_fps("invalid") == 0.0
def test_none_numerator_fraction(self):
assert safe_parse_fps("abc/1001") == 0.0
def test_negative_fps(self):
assert safe_parse_fps("-30") == -30.0
def test_very_high_fps(self):
assert safe_parse_fps("240/1") == 240.0
def test_multiple_slashes(self):
# 只按第一个 / 分割
# "30/1/2" → num="30", den="1/2" → float("1/2") 抛异常 → 返回 0
assert safe_parse_fps("30/1/2") == 0.0
def test_float_fraction(self):
result = safe_parse_fps("29.97/1")
assert result == pytest.approx(29.97)
def test_zero_fps(self):
assert safe_parse_fps("0") == 0.0
def test_zero_numerator(self):
assert safe_parse_fps("0/1000") == 0.0
# ── is_valid_media - video ───────────────────────────────────────────────────
class TestIsValidMediaVideo:
"""is_valid_media 视频校验"""
def test_valid_video(self):
metadata = {
"size_bytes": 1024 * 1024, # 1MB
"duration": 10.0,
"codec": "h264",
"width": 1920,
"height": 1080,
}
assert is_valid_media(metadata, "video") is True
def test_small_file_invalid(self):
metadata = {"size_bytes": 100, "duration": 10.0}
assert is_valid_media(metadata, "video") is False
def test_exact_min_size_valid(self):
metadata = {"size_bytes": MIN_VIDEO_FILE_SIZE, "duration": 1.0}
assert is_valid_media(metadata, "video") is True
def test_zero_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 0}
assert is_valid_media(metadata, "video") is False
def test_negative_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": -1.0}
assert is_valid_media(metadata, "video") is False
def test_unsupported_codec_still_valid(self):
# 非白名单编码仍允许通过(不做严格拦截)
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "unknown_codec_xyz",
}
assert is_valid_media(metadata, "video") is True
def test_empty_codec_valid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0, "codec": ""}
assert is_valid_media(metadata, "video") is True
def test_no_codec_valid(self):
metadata = {"size_bytes": 1024 * 1024, "duration": 10.0}
assert is_valid_media(metadata, "video") is True
def test_hevc_codec_valid(self):
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "hevc",
}
assert is_valid_media(metadata, "video") is True
def test_codec_case_insensitive(self):
metadata = {
"size_bytes": 1024 * 1024,
"duration": 10.0,
"codec": "H264",
}
assert is_valid_media(metadata, "video") is True
def test_missing_size_invalid(self):
metadata = {"duration": 10.0}
assert is_valid_media(metadata, "video") is False
def test_missing_duration_invalid(self):
metadata = {"size_bytes": 1024 * 1024}
assert is_valid_media(metadata, "video") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "video") is False
# ── is_valid_media - audio ───────────────────────────────────────────────────
class TestIsValidMediaAudio:
"""is_valid_media 音频校验"""
def test_valid_audio(self):
metadata = {"size_bytes": 1024, "duration": 30.0, "codec": "aac"}
assert is_valid_media(metadata, "audio") is True
def test_small_audio_invalid(self):
metadata = {"size_bytes": 50, "duration": 30.0}
assert is_valid_media(metadata, "audio") is False
def test_exact_min_size_valid(self):
metadata = {"size_bytes": MIN_AUDIO_FILE_SIZE, "duration": 1.0}
assert is_valid_media(metadata, "audio") is True
def test_zero_duration_invalid(self):
metadata = {"size_bytes": 1024, "duration": 0}
assert is_valid_media(metadata, "audio") is False
def test_negative_duration_invalid(self):
metadata = {"size_bytes": 1024, "duration": -5.0}
assert is_valid_media(metadata, "audio") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "audio") is False
def test_very_short_audio_valid(self):
metadata = {"size_bytes": 200, "duration": 0.5}
assert is_valid_media(metadata, "audio") is True
# ── is_valid_media - image ───────────────────────────────────────────────────
class TestIsValidMediaImage:
"""is_valid_media 图片校验"""
def test_valid_image(self):
metadata = {"size_bytes": 1024, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is True
def test_small_image_invalid(self):
metadata = {"size_bytes": 50, "width": 1920, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_exact_min_size_valid(self):
metadata = {
"size_bytes": MIN_IMAGE_FILE_SIZE,
"width": 100,
"height": 100,
}
assert is_valid_media(metadata, "image") is True
def test_zero_width_invalid(self):
metadata = {"size_bytes": 1024, "width": 0, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_zero_height_invalid(self):
metadata = {"size_bytes": 1024, "width": 1920, "height": 0}
assert is_valid_media(metadata, "image") is False
def test_negative_width_invalid(self):
metadata = {"size_bytes": 1024, "width": -1, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_small_image_valid(self):
metadata = {"size_bytes": 200, "width": 10, "height": 10}
assert is_valid_media(metadata, "image") is True
def test_missing_width_invalid(self):
metadata = {"size_bytes": 1024, "height": 1080}
assert is_valid_media(metadata, "image") is False
def test_missing_height_invalid(self):
metadata = {"size_bytes": 1024, "width": 1920}
assert is_valid_media(metadata, "image") is False
def test_empty_metadata_invalid(self):
assert is_valid_media({}, "image") is False
# ── is_valid_media - edge cases ─────────────────────────────────────────────
class TestIsValidMediaEdgeCases:
"""is_valid_media 边界情况"""
def test_invalid_media_type(self):
metadata = {"size_bytes": 1024, "duration": 10.0}
assert is_valid_media(metadata, "document") is False
def test_empty_media_type(self):
metadata = {"size_bytes": 1024}
assert is_valid_media(metadata, "") is False
def test_string_size_converted(self):
metadata = {"size_bytes": "2048", "duration": "5.0"}
assert is_valid_media(metadata, "video") is True
def test_video_size_as_string(self):
metadata = {"size_bytes": "1000000", "duration": "30"}
assert is_valid_media(metadata, "video") is True
def test_invalid_size_string_raises(self):
# int("abc") 会抛 ValueError
metadata = {"size_bytes": "abc", "duration": 10.0}
with pytest.raises(ValueError):
is_valid_media(metadata, "video")
def test_none_size_raises(self):
# int(None) 会抛 TypeError
metadata = {"size_bytes": None, "duration": 10.0}
with pytest.raises(TypeError):
is_valid_media(metadata, "video")
+299
View File
@@ -0,0 +1,299 @@
"""preset_bgm 预设BGM库单测."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
PresetBGM,
get_preset_bgm,
list_preset_bgm_by_style,
search_preset_bgm,
)
# ── PresetBGM dataclass ──────────────────────────────────────────────────────
class TestPresetBGM:
"""PresetBGM dataclass"""
def test_minimal_creation(self):
b = PresetBGM(id="test_001", name="测试音乐", style="upbeat", duration=60.0)
assert b.id == "test_001"
assert b.name == "测试音乐"
assert b.style == "upbeat"
assert b.duration == 60.0
assert b.artist == ""
assert b.description == ""
assert b.tags == []
assert b.audio_url == ""
def test_full_creation(self):
b = PresetBGM(
id="bgm_001",
name="阳光清晨",
style="upbeat",
duration=120.5,
artist="音乐人A",
description="轻快明亮的吉他",
tags=["轻快", "阳光"],
audio_url="https://cdn.example.com/bgm.mp3",
)
assert b.id == "bgm_001"
assert b.artist == "音乐人A"
assert b.description == "轻快明亮的吉他"
assert b.tags == ["轻快", "阳光"]
assert b.audio_url == "https://cdn.example.com/bgm.mp3"
def test_frozen_immutable(self):
b = PresetBGM(id="test", name="Test", style="relax", duration=100.0)
with pytest.raises(FrozenInstanceError):
b.name = "NewName"
def test_equality(self):
b1 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
b2 = PresetBGM(id="same", name="同名", style="upbeat", duration=60.0)
assert b1 == b2
def test_inequality(self):
b1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0)
b2 = PresetBGM(id="b", name="B", style="relax", duration=90.0)
assert b1 != b2
def test_not_hashable_due_to_list_tags(self):
# 包含 list 字段(tags)的 frozen dataclass 不可哈希
b = PresetBGM(id="test", name="Test", style="tech", duration=60.0)
with pytest.raises(TypeError):
hash(b)
# ── PRESET_BGM_LIBRARY 清单 ──────────────────────────────────────────────────
class TestPresetBGMLibrary:
"""预设BGM库清单"""
def test_not_empty(self):
assert len(PRESET_BGM_LIBRARY) > 0
def test_all_are_preset_bgm(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm, PresetBGM)
def test_unique_ids(self):
ids = [b.id for b in PRESET_BGM_LIBRARY]
assert len(ids) == len(set(ids))
def test_all_have_required_fields(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.id != ""
assert bgm.name != ""
assert bgm.style != ""
assert bgm.duration > 0
def test_upbeat_style_count(self):
upbeats = [b for b in PRESET_BGM_LIBRARY if b.style == "upbeat"]
assert len(upbeats) >= 3
def test_relax_style_count(self):
relax = [b for b in PRESET_BGM_LIBRARY if b.style == "relax"]
assert len(relax) >= 3
def test_tech_style_count(self):
tech = [b for b in PRESET_BGM_LIBRARY if b.style == "tech"]
assert len(tech) >= 2
def test_commerce_style_count(self):
commerce = [b for b in PRESET_BGM_LIBRARY if b.style == "commerce"]
assert len(commerce) >= 2
def test_sunny_morning_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_upbeat_001")
assert b.name == "阳光清晨"
assert b.style == "upbeat"
assert b.duration == 120.0
assert "吉他" in b.description
assert "vlog" in b.tags
def test_quiet_time_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_relax_001")
assert b.name == "静谧时光"
assert b.style == "relax"
assert b.duration == 180.0
def test_future_tech_preset(self):
b = next(b for b in PRESET_BGM_LIBRARY if b.id == "bgm_tech_001")
assert b.name == "未来科技"
assert b.style == "tech"
def test_all_durations_positive(self):
for bgm in PRESET_BGM_LIBRARY:
assert bgm.duration > 0
def test_all_tags_are_lists(self):
for bgm in PRESET_BGM_LIBRARY:
assert isinstance(bgm.tags, list)
# ── BGM_STYLES 风格字典 ─────────────────────────────────────────────────────
class TestBGMStyles:
"""BGM_STYLES 风格分类字典"""
def test_styles_exist(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_style_names_chinese(self):
assert BGM_STYLES["upbeat"] == "轻快"
assert BGM_STYLES["relax"] == "治愈"
assert BGM_STYLES["tech"] == "科技"
assert BGM_STYLES["commerce"] == "电商"
assert BGM_STYLES["emotional"] == "情感"
assert BGM_STYLES["cinematic"] == "电影"
def test_library_styles_are_defined(self):
# 库中的所有风格都应该在 BGM_STYLES 中有定义
styles_in_library = {b.style for b in PRESET_BGM_LIBRARY}
for style in styles_in_library:
assert style in BGM_STYLES, f"style {style} not defined in BGM_STYLES"
# ── get_preset_bgm ──────────────────────────────────────────────────────────
class TestGetPresetBGM:
"""get_preset_bgm 函数"""
def test_get_existing(self):
b = get_preset_bgm("bgm_upbeat_001")
assert b is not None
assert b.id == "bgm_upbeat_001"
assert b.name == "阳光清晨"
def test_get_relax(self):
b = get_preset_bgm("bgm_relax_002")
assert b is not None
assert b.style == "relax"
def test_get_nonexistent_returns_none(self):
b = get_preset_bgm("nonexistent_id")
assert b is None
def test_get_empty_string_returns_none(self):
b = get_preset_bgm("")
assert b is None
def test_returns_same_instance(self):
b1 = get_preset_bgm("bgm_upbeat_001")
b2 = get_preset_bgm("bgm_upbeat_001")
assert b1 is b2
# ── list_preset_bgm_by_style ─────────────────────────────────────────────────
class TestListPresetBGMByStyle:
"""list_preset_bgm_by_style 函数"""
def test_upbeat_style(self):
result = list_preset_bgm_by_style("upbeat")
assert len(result) >= 3
for b in result:
assert b.style == "upbeat"
def test_relax_style(self):
result = list_preset_bgm_by_style("relax")
assert len(result) >= 3
for b in result:
assert b.style == "relax"
def test_tech_style(self):
result = list_preset_bgm_by_style("tech")
assert len(result) >= 2
def test_commerce_style(self):
result = list_preset_bgm_by_style("commerce")
assert len(result) >= 2
def test_unknown_style_empty(self):
result = list_preset_bgm_by_style("nonexistent_style")
assert len(result) == 0
def test_empty_string_empty(self):
result = list_preset_bgm_by_style("")
assert len(result) == 0
def test_returns_new_list(self):
# 修改返回值不应影响原始列表
result = list_preset_bgm_by_style("upbeat")
result.clear()
assert len(list_preset_bgm_by_style("upbeat")) >= 3
# ── search_preset_bgm ────────────────────────────────────────────────────────
class TestSearchPresetBGM:
"""search_preset_bgm 函数"""
def test_search_by_name(self):
result = search_preset_bgm("阳光")
assert len(result) >= 1
assert any("阳光" in b.name for b in result)
def test_search_by_description(self):
result = search_preset_bgm("钢琴")
assert len(result) >= 1
# 应该匹配描述里有钢琴的
def test_search_by_tag(self):
result = search_preset_bgm("vlog")
assert len(result) >= 1
assert any("vlog" in b.tags for b in result)
def test_search_tech_keyword(self):
result = search_preset_bgm("科技")
assert len(result) >= 2
def test_search_case_insensitive(self):
r1 = search_preset_bgm("UPBEAT")
r2 = search_preset_bgm("upbeat")
assert len(r1) == len(r2)
def test_search_no_match(self):
result = search_preset_bgm("完全不存在的关键词_xyz123")
assert len(result) == 0
def test_search_empty_string(self):
# 空字符串应该匹配所有(因为 "" in any string 是 True
result = search_preset_bgm("")
assert len(result) == len(PRESET_BGM_LIBRARY)
def test_search_electronic(self):
result = search_preset_bgm("电子")
assert len(result) >= 2
def test_order_preserved(self):
# 搜索结果应该保持原列表顺序
result = search_preset_bgm("bgm")
ids = [b.id for b in result]
all_ids = [b.id for b in PRESET_BGM_LIBRARY]
# 验证相对顺序
pos_in_result = {bgm_id: i for i, bgm_id in enumerate(ids)}
prev_pos = -1
for bgm_id in all_ids:
if bgm_id in pos_in_result:
assert pos_in_result[bgm_id] > prev_pos
prev_pos = pos_in_result[bgm_id]
def test_search_partial_tag_match(self):
# 关键词是标签的子串也能匹配
result = search_preset_bgm("吉他")
assert len(result) >= 1
+245
View File
@@ -0,0 +1,245 @@
"""preset_voices 预置音色配置单测."""
from dataclasses import FrozenInstanceError
import pytest
from domain.preset_voices import (
PRESET_VOICES,
PresetVoice,
get_preset_voice_by_id,
get_preset_voices,
is_preset_voice,
)
# ── PresetVoice dataclass ────────────────────────────────────────────────────
class TestPresetVoice:
"""PresetVoice dataclass"""
def test_minimal_creation(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
)
assert v.voice_id == "test_v1"
assert v.name == "测试音色"
assert v.description == "测试描述"
assert v.gender == "female"
assert v.language == "zh-CN"
assert v.preview_url == ""
assert v.tags is None
def test_full_creation(self):
v = PresetVoice(
voice_id="test_v2",
name="完整音色",
description="完整描述",
gender="male",
language="en-US",
preview_url="https://example.com/preview.mp3",
tags=["沉稳", "男声"],
)
assert v.gender == "male"
assert v.language == "en-US"
assert v.preview_url == "https://example.com/preview.mp3"
assert v.tags == ["沉稳", "男声"]
def test_frozen_immutable(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
with pytest.raises(FrozenInstanceError):
v.name = "NewName"
def test_equality(self):
v1 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
v2 = PresetVoice(voice_id="same", name="同名", description="d", gender="female")
assert v1 == v2
def test_inequality(self):
v1 = PresetVoice(voice_id="a", name="A", description="da", gender="female")
v2 = PresetVoice(voice_id="b", name="B", description="db", gender="male")
assert v1 != v2
def test_to_dict(self):
v = PresetVoice(
voice_id="test_v1",
name="测试音色",
description="测试描述",
gender="female",
language="zh-CN",
preview_url="https://x.com/a.mp3",
tags=["温柔", "女声"],
)
d = v.to_dict()
assert isinstance(d, dict)
assert d["voice_id"] == "test_v1"
assert d["name"] == "测试音色"
assert d["description"] == "测试描述"
assert d["gender"] == "female"
assert d["language"] == "zh-CN"
assert d["preview_url"] == "https://x.com/a.mp3"
assert d["tags"] == ["温柔", "女声"]
def test_to_dict_none_tags_becomes_empty_list(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
d = v.to_dict()
assert d["tags"] == []
assert isinstance(d["tags"], list)
def test_to_dict_has_all_keys(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
d = v.to_dict()
assert set(d.keys()) == {
"voice_id",
"name",
"description",
"gender",
"language",
"preview_url",
"tags",
}
def test_slots_no_extra_attrs(self):
v = PresetVoice(voice_id="test", name="Test", description="desc", gender="female")
# frozen + slots dataclass 不允许动态添加属性
with pytest.raises((AttributeError, TypeError)):
v.nonexistent_field = "value"
# ── PRESET_VOICES 列表 ───────────────────────────────────────────────────────
class TestPresetVoicesList:
"""PRESET_VOICES 预置音色列表"""
def test_not_empty(self):
assert len(PRESET_VOICES) > 0
def test_count(self):
assert len(PRESET_VOICES) == 8
def test_all_are_preset_voice(self):
for v in PRESET_VOICES:
assert isinstance(v, PresetVoice)
def test_unique_voice_ids(self):
ids = [v.voice_id for v in PRESET_VOICES]
assert len(ids) == len(set(ids))
def test_unique_names(self):
names = [v.name for v in PRESET_VOICES]
assert len(names) == len(set(names))
def test_all_have_required_fields(self):
for v in PRESET_VOICES:
assert v.voice_id != ""
assert v.name != ""
assert v.description != ""
assert v.gender in ("male", "female")
def test_all_chinese(self):
for v in PRESET_VOICES:
assert v.language == "zh-CN"
def test_longxiaochun_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochun_v3")
assert v.name == "龙小淳"
assert v.gender == "female"
assert "温柔" in v.description
def test_longxiaochen_voice(self):
v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochen_v3")
assert v.name == "龙小晨"
assert v.gender == "male"
def test_male_voices_count(self):
males = [v for v in PRESET_VOICES if v.gender == "male"]
assert len(males) == 3 # 龙小晨/龙书/龙博
def test_female_voices_count(self):
females = [v for v in PRESET_VOICES if v.gender == "female"]
assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜
def test_all_have_tags(self):
for v in PRESET_VOICES:
assert v.tags is not None
assert len(v.tags) > 0
def test_voice_id_pattern(self):
# 所有音色 ID 都以 _v3 结尾
for v in PRESET_VOICES:
assert v.voice_id.endswith("_v3")
# ── 工具函数 ─────────────────────────────────────────────────────────────────
class TestGetPresetVoices:
"""get_preset_voices 函数"""
def test_returns_full_list(self):
result = get_preset_voices()
assert len(result) == len(PRESET_VOICES)
assert result is PRESET_VOICES # 返回同一列表引用
def test_all_are_preset_voice(self):
result = get_preset_voices()
for v in result:
assert isinstance(v, PresetVoice)
class TestGetPresetVoiceById:
"""get_preset_voice_by_id 函数"""
def test_get_existing_female(self):
v = get_preset_voice_by_id("longxiaochun_v3")
assert v is not None
assert v.voice_id == "longxiaochun_v3"
assert v.name == "龙小淳"
def test_get_existing_male(self):
v = get_preset_voice_by_id("longxiaochen_v3")
assert v is not None
assert v.gender == "male"
def test_get_nonexistent_returns_none(self):
v = get_preset_voice_by_id("nonexistent_voice")
assert v is None
def test_get_empty_string_returns_none(self):
v = get_preset_voice_by_id("")
assert v is None
def test_returns_same_instance(self):
v1 = get_preset_voice_by_id("longyue_v3")
v2 = get_preset_voice_by_id("longyue_v3")
assert v1 is v2
def test_all_voices_reachable(self):
for v in PRESET_VOICES:
found = get_preset_voice_by_id(v.voice_id)
assert found is not None
assert found.voice_id == v.voice_id
class TestIsPresetVoice:
"""is_preset_voice 函数"""
def test_existing_voice_true(self):
assert is_preset_voice("longxiaochun_v3") is True
def test_all_existing_are_true(self):
for v in PRESET_VOICES:
assert is_preset_voice(v.voice_id) is True
def test_nonexistent_voice_false(self):
assert is_preset_voice("fake_voice") is False
def test_empty_string_false(self):
assert is_preset_voice("") is False
def test_consistent_with_get_by_id(self):
for v in PRESET_VOICES:
assert is_preset_voice(v.voice_id) == (get_preset_voice_by_id(v.voice_id) is not None)
+453
View File
@@ -0,0 +1,453 @@
"""speed_config 调速配置领域模型单测."""
import pytest
from domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
adjust_duration,
build_audio_filter,
build_clip_speed_filter,
build_video_filter,
resolve_clip_speed,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_speed_limits(self):
assert MIN_SPEED == 0.25
assert MAX_SPEED == 4.0
assert DEFAULT_SPEED == 1.0
# ── SpeedConfig 默认值与基础 ────────────────────────────────────────────────
class TestSpeedConfigDefaults:
"""SpeedConfig 默认值"""
def test_default_values(self):
c = SpeedConfig()
assert c.speed == 1.0
assert c.pitch_correct is True
def test_custom_values(self):
c = SpeedConfig(speed=2.0, pitch_correct=False)
assert c.speed == 2.0
assert c.pitch_correct is False
# ── SpeedConfig.parse ───────────────────────────────────────────────────────
class TestSpeedConfigParse:
"""SpeedConfig.parse 工厂方法"""
def test_none_returns_default(self):
c = SpeedConfig.parse(None)
assert c.speed == 1.0
assert c.pitch_correct is True
def test_empty_dict_returns_default(self):
c = SpeedConfig.parse({})
assert c.speed == 1.0
def test_not_dict_returns_default(self):
c = SpeedConfig.parse("not a dict")
assert c.speed == 1.0
def test_valid_speed(self):
c = SpeedConfig.parse({"speed": 2.0})
assert c.speed == 2.0
def test_valid_speed_int(self):
c = SpeedConfig.parse({"speed": 2})
assert c.speed == 2.0
assert isinstance(c.speed, float)
def test_pitch_correct_false(self):
c = SpeedConfig.parse({"pitch_correct": False})
assert c.pitch_correct is False
def test_pitch_correct_non_bool_falls_back(self):
c = SpeedConfig.parse({"pitch_correct": "true"})
assert c.pitch_correct is True
def test_invalid_speed_string_falls_back(self):
c = SpeedConfig.parse({"speed": "fast"})
assert c.speed == 1.0
def test_speed_below_min_clamped(self):
c = SpeedConfig.parse({"speed": 0.1})
assert c.speed == MIN_SPEED
def test_speed_above_max_clamped(self):
c = SpeedConfig.parse({"speed": 10.0})
assert c.speed == MAX_SPEED
def test_zero_speed_falls_back_to_default(self):
c = SpeedConfig.parse({"speed": 0})
assert c.speed == DEFAULT_SPEED
def test_negative_speed_falls_back(self):
c = SpeedConfig.parse({"speed": -1.0})
assert c.speed == DEFAULT_SPEED
def test_min_speed_boundary(self):
c = SpeedConfig.parse({"speed": 0.25})
assert c.speed == 0.25
def test_max_speed_boundary(self):
c = SpeedConfig.parse({"speed": 4.0})
assert c.speed == 4.0
# ── SpeedConfig.clamp ───────────────────────────────────────────────────────
class TestSpeedConfigClamp:
"""SpeedConfig.clamp 方法"""
def test_normal_speed_no_change(self):
c = SpeedConfig(speed=1.5)
c.clamp()
assert c.speed == 1.5
def test_zero_speed_reset_default(self):
c = SpeedConfig(speed=0.0)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_negative_speed_reset_default(self):
c = SpeedConfig(speed=-0.5)
c.clamp()
assert c.speed == DEFAULT_SPEED
def test_below_min_clamped(self):
c = SpeedConfig(speed=0.1)
c.clamp()
assert c.speed == MIN_SPEED
def test_above_max_clamped(self):
c = SpeedConfig(speed=5.0)
c.clamp()
assert c.speed == MAX_SPEED
def test_exact_min_unchanged(self):
c = SpeedConfig(speed=MIN_SPEED)
c.clamp()
assert c.speed == MIN_SPEED
def test_exact_max_unchanged(self):
c = SpeedConfig(speed=MAX_SPEED)
c.clamp()
assert c.speed == MAX_SPEED
# ── SpeedConfig 属性方法 ────────────────────────────────────────────────────
class TestSpeedConfigProperties:
"""SpeedConfig 属性方法"""
def test_is_original_true(self):
c = SpeedConfig(speed=1.0)
assert c.is_original is True
def test_is_original_very_close(self):
c = SpeedConfig(speed=1.0 + 1e-7)
assert c.is_original is True
def test_is_original_false_fast(self):
c = SpeedConfig(speed=1.5)
assert c.is_original is False
def test_is_original_false_slow(self):
c = SpeedConfig(speed=0.8)
assert c.is_original is False
def test_is_fast_true(self):
c = SpeedConfig(speed=2.0)
assert c.is_fast is True
def test_is_fast_false(self):
c = SpeedConfig(speed=0.5)
assert c.is_fast is False
def test_is_fast_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_fast is False
def test_is_slow_true(self):
c = SpeedConfig(speed=0.5)
assert c.is_slow is True
def test_is_slow_false(self):
c = SpeedConfig(speed=2.0)
assert c.is_slow is False
def test_is_slow_at_one(self):
c = SpeedConfig(speed=1.0)
assert c.is_slow is False
# ── build_video_filter ───────────────────────────────────────────────────────
class TestBuildVideoFilter:
"""build_video_filter 视频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_video_filter(c) == ""
def test_double_speed(self):
c = SpeedConfig(speed=2.0)
result = build_video_filter(c)
assert "setpts=PTS/2.0" in result
def test_half_speed(self):
c = SpeedConfig(speed=0.5)
result = build_video_filter(c)
assert "setpts=PTS/0.5" in result
def test_format_precision(self):
c = SpeedConfig(speed=1.5)
result = build_video_filter(c)
# 应该是 4 位小数
assert "1.5000" in result
def test_min_speed(self):
c = SpeedConfig(speed=0.25)
result = build_video_filter(c)
assert result.startswith("setpts=PTS/")
def test_max_speed(self):
c = SpeedConfig(speed=4.0)
result = build_video_filter(c)
assert "4.0000" in result
# ── build_audio_filter / atempo 拆分 ────────────────────────────────────────
class TestBuildAudioFilter:
"""build_audio_filter 音频滤镜构建"""
def test_original_speed_empty(self):
c = SpeedConfig(speed=1.0)
assert build_audio_filter(c) == ""
def test_within_range_single_stage(self):
c = SpeedConfig(speed=1.5)
result = build_audio_filter(c)
assert result == "atempo=1.5000"
def test_05_speed_single_stage(self):
c = SpeedConfig(speed=0.5)
result = build_audio_filter(c)
assert result == "atempo=0.5000"
def test_20_speed_single_stage(self):
c = SpeedConfig(speed=2.0)
result = build_audio_filter(c)
assert result == "atempo=2.0000"
def test_4x_speed_two_stages(self):
c = SpeedConfig(speed=4.0)
result = build_audio_filter(c)
# 2.0 * 2.0 = 4.0
assert result == "atempo=2.0000,atempo=2.0000"
def test_025_speed_two_stages(self):
c = SpeedConfig(speed=0.25)
result = build_audio_filter(c)
# 0.5 * 0.5 = 0.25
assert result == "atempo=0.5000,atempo=0.5000"
def test_3x_speed_two_stages(self):
c = SpeedConfig(speed=3.0)
result = build_audio_filter(c)
# 2.0 * 1.5 = 3.0
stages = result.split(",")
assert len(stages) == 2
assert "atempo=2.0000" in stages[0]
assert "atempo=1.5000" in stages[1]
def test_03_speed_two_stages(self):
c = SpeedConfig(speed=0.3)
result = build_audio_filter(c)
stages = result.split(",")
assert len(stages) == 2
# 0.5 * 0.6 = 0.3
assert "atempo=0.5000" in stages[0]
def test_format_each_stage(self):
c = SpeedConfig(speed=1.2345)
result = build_audio_filter(c)
assert "atempo=1.2345" in result
class TestAtempoStages:
"""atempo 多级拆分逻辑验证"""
def _extract_speeds(self, filter_str: str) -> list[float]:
"""从 atempo 滤镜字符串中提取速度值."""
import re
return [float(m) for m in re.findall(r"atempo=([\d.]+)", filter_str)]
def test_product_equals_speed_fast_3x(self):
c = SpeedConfig(speed=3.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 3.0) < 1e-4
def test_product_equals_speed_4x(self):
c = SpeedConfig(speed=4.0)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 4.0) < 1e-4
def test_product_equals_speed_slow_025(self):
c = SpeedConfig(speed=0.25)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.25) < 1e-4
def test_product_equals_speed_slow_03(self):
c = SpeedConfig(speed=0.3)
speeds = self._extract_speeds(build_audio_filter(c))
product = 1.0
for s in speeds:
product *= s
assert abs(product - 0.3) < 1e-4
def test_each_stage_in_range_fast(self):
c = SpeedConfig(speed=3.5)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
def test_each_stage_in_range_slow(self):
c = SpeedConfig(speed=0.35)
speeds = self._extract_speeds(build_audio_filter(c))
for s in speeds:
assert 0.5 <= s <= 2.0
# ── adjust_duration ──────────────────────────────────────────────────────────
class TestAdjustDuration:
"""adjust_duration 时长计算"""
def test_original_speed_no_change(self):
assert adjust_duration(10.0, SpeedConfig(speed=1.0)) == 10.0
def test_double_speed_half_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=2.0)) == 5.0
def test_half_speed_double_duration(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.5)) == 20.0
def test_zero_duration_unchanged(self):
assert adjust_duration(0.0, SpeedConfig(speed=2.0)) == 0.0
def test_negative_duration_unchanged(self):
assert adjust_duration(-1.0, SpeedConfig(speed=2.0)) == -1.0
def test_original_with_zero_duration(self):
assert adjust_duration(0.0, SpeedConfig(speed=1.0)) == 0.0
def test_triple_speed(self):
assert adjust_duration(30.0, SpeedConfig(speed=3.0)) == 10.0
def test_quarter_speed(self):
assert adjust_duration(10.0, SpeedConfig(speed=0.25)) == 40.0
# ── build_clip_speed_filter ──────────────────────────────────────────────────
class TestBuildClipSpeedFilter:
"""build_clip_speed_filter 便捷方法"""
def test_returns_tuple_of_three(self):
result = build_clip_speed_filter(1.5)
assert len(result) == 3
video_filter, audio_filter, config = result
assert isinstance(video_filter, str)
assert isinstance(audio_filter, str)
assert isinstance(config, SpeedConfig)
def test_normal_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(1.0)
assert video_filter == ""
assert audio_filter == ""
assert config.speed == 1.0
def test_double_speed(self):
video_filter, audio_filter, config = build_clip_speed_filter(2.0)
assert "setpts" in video_filter
assert "atempo" in audio_filter
assert config.speed == 2.0
def test_clamps_speed(self):
_, _, config = build_clip_speed_filter(10.0)
assert config.speed == MAX_SPEED
def test_pitch_correct_false(self):
# pitch_correct=False 时仍然生成滤镜(实际使用中可能换其他算法,但接口返回不变)
video_filter, audio_filter, config = build_clip_speed_filter(2.0, pitch_correct=False)
assert config.pitch_correct is False
assert "setpts" in video_filter
# ── resolve_clip_speed ───────────────────────────────────────────────────────
class TestResolveClipSpeed:
"""resolve_clip_speed 片段速度解析"""
def test_none_config_uses_global(self):
assert resolve_clip_speed(None, 1.5) == 1.5
def test_zero_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
def test_missing_key_uses_global(self):
assert resolve_clip_speed({}, 2.0) == 2.0
def test_valid_speed(self):
assert resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
def test_negative_speed_uses_global(self):
assert resolve_clip_speed({"playback_speed": -1.0}, 1.0) == 1.0
def test_invalid_type_uses_global(self):
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
def test_default_global_is_one(self):
assert resolve_clip_speed({"playback_speed": 0}) == 1.0
def test_int_speed(self):
result = resolve_clip_speed({"playback_speed": 2})
assert result == 2.0
assert isinstance(result, float)
def test_very_small_positive_uses_it(self):
# 只要 > 0 就用
result = resolve_clip_speed({"playback_speed": 0.1})
assert result == 0.1
+497
View File
@@ -0,0 +1,497 @@
"""subtitle 字幕时间轴领域模型单测."""
import pytest
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
# ── SubtitleWord ─────────────────────────────────────────────────────────────
class TestSubtitleWord:
"""SubtitleWord 词级字幕单元"""
def test_basic(self):
w = SubtitleWord(text="你好", start=1.0, end=1.5)
assert w.text == "你好"
assert w.start == 1.0
assert w.end == 1.5
def test_duration(self):
w = SubtitleWord(text="test", start=0.0, end=2.5)
assert w.duration == 2.5
def test_duration_zero(self):
w = SubtitleWord(text="x", start=5.0, end=5.0)
assert w.duration == 0.0
def test_duration_negative_becomes_zero(self):
w = SubtitleWord(text="x", start=3.0, end=2.0)
assert w.duration == 0.0
# ── SubtitleSegment ──────────────────────────────────────────────────────────
class TestSubtitleSegment:
"""SubtitleSegment 字幕片段"""
def test_basic(self):
s = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
assert s.text == "你好世界"
assert s.start == 0.0
assert s.end == 2.0
assert s.words == []
def test_with_words(self):
words = [
SubtitleWord("你好", 0.0, 0.5),
SubtitleWord("世界", 0.5, 1.0),
]
s = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
assert len(s.words) == 2
assert s.words[0].text == "你好"
def test_duration(self):
s = SubtitleSegment(text="test", start=1.5, end=3.5)
assert s.duration == 2.0
def test_duration_negative_becomes_zero(self):
s = SubtitleSegment(text="test", start=5.0, end=3.0)
assert s.duration == 0.0
def test_char_count(self):
s = SubtitleSegment(text="你好世界", start=0, end=1)
assert s.char_count == 4
def test_char_count_empty(self):
s = SubtitleSegment(text="", start=0, end=1)
assert s.char_count == 0
def test_char_count_mixed(self):
s = SubtitleSegment(text="Hello 世界", start=0, end=1)
assert s.char_count == 8 # H-e-l-l-o- -世-界
# ── SubtitleTimeline 基础 ────────────────────────────────────────────────────
class TestSubtitleTimelineBasics:
"""SubtitleTimeline 基础属性"""
def test_defaults(self):
tl = SubtitleTimeline()
assert tl.segments == []
assert tl.language == "zh"
assert tl.total_duration == 0.0
def test_custom_language(self):
tl = SubtitleTimeline(language="en")
assert tl.language == "en"
def test_segment_count(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
assert tl.segment_count == 2
def test_segment_count_empty(self):
tl = SubtitleTimeline()
assert tl.segment_count == 0
def test_total_chars(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1),
SubtitleSegment("世界", 1, 2),
]
)
assert tl.total_chars == 4
def test_total_chars_empty(self):
tl = SubtitleTimeline()
assert tl.total_chars == 0
# ── merge_short_segments ─────────────────────────────────────────────────────
class TestMergeShortSegments:
"""merge_short_segments 合并过短片段"""
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.merge_short_segments()
assert result.segment_count == 0
def test_single_segment(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
]
)
result = tl.merge_short_segments(min_chars=8)
assert result.segment_count == 1
assert result.segments[0].text == ""
def test_two_short_segments_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0, 1), # 2
SubtitleSegment("世界", 1, 2), # 2
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert result.segments[0].text == "你好世界"
assert result.segments[0].start == 0.0
assert result.segments[0].end == 2.0
def test_multiple_short_merged(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 0.5), # 1
SubtitleSegment("", 0.5, 1.0), # 1
SubtitleSegment("", 1.0, 1.5), # 1
SubtitleSegment("", 1.5, 2.0), # 1
SubtitleSegment("", 2.0, 2.5), # 1
SubtitleSegment("六七八", 2.5, 3.5), # 3
SubtitleSegment("八九十", 3.5, 4.5), # 3
]
)
result = tl.merge_short_segments(min_chars=5)
# 一二三四五 5个=5 → 合并为1段
# 六七八+八九十 3+3=6 → 合并为1段
assert result.segment_count == 2
assert result.segments[0].text == "一二三四五"
assert result.segments[1].text == "六七八八九十"
def test_long_segment_stays_alone(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("这是一段很长的字幕内容", 0, 2), # 11
SubtitleSegment("", 2, 2.5), # 1
SubtitleSegment("", 2.5, 3.0), # 1
]
)
result = tl.merge_short_segments(min_chars=8)
# 第一段11字>=8,单独输出;后两段加起来2字<8,合并到上一段
assert result.segment_count == 1
assert result.segments[0].text == "这是一段很长的字幕内容短语"
def test_tail_short_merged_with_previous(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五六七八", 0, 2), # 8
SubtitleSegment("", 2, 2.5), # 1,太短了
]
)
result = tl.merge_short_segments(min_chars=5)
assert result.segment_count == 1
assert result.segments[0].text == "一二三四五六七八尾"
def test_preserves_language_and_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a", 0, 1)],
language="en",
total_duration=10.0,
)
result = tl.merge_short_segments()
assert result.language == "en"
assert result.total_duration == 10.0
def test_default_min_chars_is_8(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("一二三四五", 0, 1), # 5 < 8
SubtitleSegment("六七八", 1, 2), # 3 → 5+3=8
]
)
result = tl.merge_short_segments()
assert result.segment_count == 1
def test_merges_words(self):
words1 = [SubtitleWord("", 0.0, 0.3), SubtitleWord("", 0.3, 0.6)]
words2 = [SubtitleWord("", 1.0, 1.3), SubtitleWord("", 1.3, 1.6)]
tl = SubtitleTimeline(
segments=[
SubtitleSegment("你好", 0.0, 0.6, words=words1),
SubtitleSegment("世界", 1.0, 1.6, words=words2),
]
)
result = tl.merge_short_segments(min_chars=3)
assert result.segment_count == 1
assert len(result.segments[0].words) == 4
assert result.segments[0].words[0].text == ""
assert result.segments[0].words[3].text == ""
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a", 0, 1),
SubtitleSegment("b", 1, 2),
]
)
original_count = tl.segment_count
tl.merge_short_segments(min_chars=5)
assert tl.segment_count == original_count
# ── split_long_segments ──────────────────────────────────────────────────────
class TestSplitLongSegments:
"""split_long_segments 拆分过长片段"""
def test_short_segment_no_split(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("短文本", 0, 1),
]
)
result = tl.split_long_segments(max_chars=20)
assert result.segment_count == 1
assert result.segments[0].text == "短文本"
def test_empty_timeline(self):
tl = SubtitleTimeline()
result = tl.split_long_segments()
assert result.segment_count == 0
def test_split_by_sentence_end(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment(
"这是第一句话。这是第二句话。这是第三句话。",
start=0.0,
end=9.0,
),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 第一句应该是完整的
assert result.segments[0].text.endswith("")
def test_split_preserves_total_text(self):
original = "这是第一句话。这是第二句话。这是第三句话,很长的一句话。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(original, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=8)
# 拆分后所有片段拼起来应该等于原文
combined = "".join(s.text for s in result.segments)
assert combined == original
def test_split_time_proportional(self):
text = "一二三四五六七八九十。一二三四五六七八九十。"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=12)
assert result.segment_count >= 2
# 第一段结束时间应该早于总时长
assert result.segments[0].end < 10.0
# 最后一段结束应该等于原结束时间
assert abs(result.segments[-1].end - 10.0) < 0.01
def test_no_punctuation_hard_split(self):
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 3
combined = "".join(s.text for s in result.segments)
assert combined == text
def test_multiple_mixed_segments(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("", 0, 1),
SubtitleSegment("这是一段非常非常长的字幕文本内容需要拆分", 1, 5),
SubtitleSegment("短的", 5, 6),
]
)
result = tl.split_long_segments(max_chars=10)
# 第一个和第三个保持不变,中间被拆分
assert result.segment_count > 3
assert result.segments[0].text == ""
assert result.segments[-1].text == "短的"
def test_preserves_language_and_total_duration(self):
tl = SubtitleTimeline(
segments=[SubtitleSegment("a" * 30, 0, 10)],
language="ja",
total_duration=20.0,
)
result = tl.split_long_segments(max_chars=10)
assert result.language == "ja"
assert result.total_duration == 20.0
def test_default_max_chars_is_20(self):
text = "" * 25
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0, end=5),
]
)
result = tl.split_long_segments()
assert result.segment_count >= 2
def test_split_with_words(self):
words = [SubtitleWord(f"w{i}", i * 0.5, i * 0.5 + 0.4) for i in range(20)]
text = "".join(w.text for w in words)
tl = SubtitleTimeline(
segments=[
SubtitleSegment(text=text, start=0.0, end=10.0, words=words),
]
)
result = tl.split_long_segments(max_chars=10)
assert result.segment_count >= 2
# 所有片段的词数之和应该等于原词数
total_words = sum(len(s.words) for s in result.segments)
assert total_words <= len(words) + 1 # 可能有边界误差
def test_does_not_mutate_original(self):
tl = SubtitleTimeline(
segments=[
SubtitleSegment("a" * 30, 0, 10),
]
)
original_count = tl.segment_count
tl.split_long_segments(max_chars=10)
assert tl.segment_count == original_count
# ── _split_text_by_punctuation 静态方法 ─────────────────────────────────────
class TestSplitTextByPunctuation:
"""_split_text_by_punctuation 静态方法"""
def test_short_text_no_split(self):
result = SubtitleTimeline._split_text_by_punctuation("短文本", max_chars=20)
assert len(result) == 1
assert result[0] == "短文本"
def test_sentence_end_punctuation_split(self):
result = SubtitleTimeline._split_text_by_punctuation(
"第一句。第二句。第三句。",
max_chars=5,
)
assert len(result) >= 2
assert result[0] == "第一句。"
def test_clause_pause_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"今天天气很好,阳光明媚,适合出去玩。",
max_chars=8,
)
assert len(result) >= 2
def test_exclamation_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"太精彩了!真的很棒!",
max_chars=5,
)
assert len(result) >= 2
def test_question_mark(self):
result = SubtitleTimeline._split_text_by_punctuation(
"你是谁?从哪里来?",
max_chars=5,
)
assert len(result) >= 2
def test_english_punctuation(self):
result = SubtitleTimeline._split_text_by_punctuation(
"Hello, world! How are you?",
max_chars=10,
)
assert len(result) >= 2
def test_no_punctuation_hard_split(self):
text = "" * 25
result = SubtitleTimeline._split_text_by_punctuation(text, max_chars=10)
assert len(result) >= 3
assert "".join(result) == text
def test_empty_string(self):
result = SubtitleTimeline._split_text_by_punctuation("", max_chars=10)
assert len(result) == 0 or (len(result) == 1 and result[0] == "")
def test_semicolon_colon(self):
result = SubtitleTimeline._split_text_by_punctuation(
"注意事项:第一,要认真;第二,要仔细。",
max_chars=8,
)
assert len(result) >= 2
# ── _merge_segments 静态方法 ────────────────────────────────────────────────
class TestMergeSegmentsStatic:
"""_merge_segments 静态方法"""
def test_empty_list(self):
result = SubtitleTimeline._merge_segments([])
assert result.text == ""
assert result.start == 0
assert result.end == 0
def test_single_segment(self):
seg = SubtitleSegment("hello", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([seg])
assert result.text == "hello"
assert result.start == 1.0
assert result.end == 2.0
def test_two_segments(self):
s1 = SubtitleSegment("你好", 0.0, 1.0)
s2 = SubtitleSegment("世界", 1.0, 2.0)
result = SubtitleTimeline._merge_segments([s1, s2])
assert result.text == "你好世界"
assert result.start == 0.0
assert result.end == 2.0
def test_merges_words(self):
w1 = [SubtitleWord("", 0, 0.5)]
w2 = [SubtitleWord("", 0.5, 1.0)]
s1 = SubtitleSegment("", 0, 0.5, words=w1)
s2 = SubtitleSegment("", 0.5, 1.0, words=w2)
result = SubtitleTimeline._merge_segments([s1, s2])
assert len(result.words) == 2
assert result.words[0].text == ""
assert result.words[1].text == ""
# ── 端到端:先合并再拆分 ────────────────────────────────────────────────────
class TestMergeAndSplit:
"""合并和拆分组合使用"""
def test_merge_then_split_roundtrip(self):
# 很多短句先合并,再按合理长度拆分
segments = [
SubtitleSegment("你好", 0, 0.5),
SubtitleSegment("我是小明", 0.5, 1.5),
SubtitleSegment("今天天气真好。", 1.5, 3.0),
SubtitleSegment("我们出去玩吧。", 3.0, 5.0),
]
tl = SubtitleTimeline(segments=segments)
merged = tl.merge_short_segments(min_chars=5)
split = merged.split_long_segments(max_chars=15)
# 结果应该合理(不保证完全一样,但文本应该完整)
original_text = "".join(s.text for s in segments)
result_text = "".join(s.text for s in split.segments)
assert original_text == result_text
+134
View File
@@ -0,0 +1,134 @@
"""EditTemplateVersion 单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from domain.template_version import EditTemplateVersion
class TestEditTemplateVersionCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
v = EditTemplateVersion.create("tmpl_001", 1)
assert v.id is not None
assert len(v.id) == 32
assert v.template_id == "tmpl_001"
assert v.version == 1
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
assert v.created_at is not None
def test_create_with_all_fields(self):
v = EditTemplateVersion.create(
"tmpl_001",
3,
name="第三版",
editing_mode="pip",
config={"bgm": True},
clip_configs=[{"id": "c1", "type": "video"}],
change_note="优化剪辑逻辑",
published_by="user_123",
)
assert v.template_id == "tmpl_001"
assert v.version == 3
assert v.name == "第三版"
assert v.editing_mode == "pip"
assert v.config == {"bgm": True}
assert v.clip_configs == [{"id": "c1", "type": "video"}]
assert v.change_note == "优化剪辑逻辑"
assert v.published_by == "user_123"
def test_create_config_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, config=None)
assert v.config == {}
def test_create_clip_configs_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, clip_configs=None)
assert v.clip_configs == []
def test_create_unique_ids(self):
v1 = EditTemplateVersion.create("t1", 1)
v2 = EditTemplateVersion.create("t1", 1)
assert v1.id != v2.id
def test_create_sets_created_at(self):
before = datetime.now(timezone.utc)
v = EditTemplateVersion.create("t1", 1)
after = datetime.now(timezone.utc)
assert before <= v.created_at <= after
class TestEditTemplateVersionConstruction:
"""直接构造测试."""
def test_direct_construction(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v = EditTemplateVersion(
id="v1",
template_id="t1",
version=5,
name="v5",
editing_mode="voice_over",
config={"key": "value"},
clip_configs=[{"a": 1}, {"b": 2}],
change_note="test",
published_by="admin",
created_at=now,
)
assert v.id == "v1"
assert v.template_id == "t1"
assert v.version == 5
assert v.name == "v5"
assert v.editing_mode == "voice_over"
assert v.config == {"key": "value"}
assert v.clip_configs == [{"a": 1}, {"b": 2}]
assert v.change_note == "test"
assert v.published_by == "admin"
assert v.created_at == now
def test_default_values(self):
v = EditTemplateVersion(id="v1", template_id="t1", version=1)
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
class TestEditTemplateVersionSlots:
"""slots 测试."""
def test_slots_no_extra_attrs(self):
v = EditTemplateVersion.create("t1", 1)
with pytest.raises((AttributeError, TypeError)):
v.new_field = "value" # type: ignore[attr-defined]
class TestEditTemplateVersionEquality:
"""相等性测试."""
def test_equal_same_id_and_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
assert v1 == v2
def test_not_equal_different_id(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="v1", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="v2", template_id="t1", version=1, created_at=now)
assert v1 != v2
def test_not_equal_different_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=2, created_at=now)
assert v1 != v2
+314
View File
@@ -0,0 +1,314 @@
"""TtsConfig 单元测试."""
from __future__ import annotations
import pytest
from domain.tts_config import TtsConfig
class TestTtsConfigDefaults:
"""默认值测试."""
def test_default_values(self):
config = TtsConfig()
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
assert config.pitch == 0.0
assert config.volume == 0.8
assert config.text == ""
assert config.align_mode == "full"
assert config.overlap_mode == "replace"
def test_custom_construction(self):
config = TtsConfig(
enabled=True,
voice_id="voice_001",
speed=1.5,
pitch=3.0,
volume=0.9,
text="hello",
align_mode="subtitle",
overlap_mode="mix",
)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 3.0
assert config.volume == 0.9
assert config.text == "hello"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_slots_no_extra_attrs(self):
config = TtsConfig()
with pytest.raises((AttributeError, TypeError)):
config.new_attr = "value" # type: ignore[attr-defined]
def test_equality_same_values(self):
a = TtsConfig(enabled=True, voice_id="v1")
b = TtsConfig(enabled=True, voice_id="v1")
assert a == b
def test_equality_different_values(self):
a = TtsConfig(enabled=True)
b = TtsConfig(enabled=False)
assert a != b
class TestTtsConfigParseNoneAndEmpty:
"""parse 空输入测试."""
def test_parse_none(self):
config = TtsConfig.parse(None)
assert config == TtsConfig()
def test_parse_empty_dict(self):
config = TtsConfig.parse({})
assert config == TtsConfig()
def test_parse_non_dict_string(self):
config = TtsConfig.parse("not a dict") # type: ignore[arg-type]
assert config == TtsConfig()
def test_parse_non_dict_list(self):
config = TtsConfig.parse([]) # type: ignore[arg-type]
assert config == TtsConfig()
def test_parse_non_dict_number(self):
config = TtsConfig.parse(123) # type: ignore[arg-type]
assert config == TtsConfig()
class TestTtsConfigParseDisabled:
"""parse disabled 场景."""
def test_parse_enabled_false_returns_default(self):
config = TtsConfig.parse({"enabled": False})
assert config.enabled is False
assert config.speed == 1.0
assert config.voice_id == ""
def test_parse_enabled_false_ignores_other_fields(self):
config = TtsConfig.parse(
{
"enabled": False,
"voice_id": "v1",
"speed": 1.5,
}
)
assert config.enabled is False
assert config.voice_id == ""
assert config.speed == 1.0
def test_parse_enabled_non_bool_falls_to_false(self):
config = TtsConfig.parse({"enabled": "true"})
assert config.enabled is False
def test_parse_enabled_int_falls_to_false(self):
config = TtsConfig.parse({"enabled": 1})
assert config.enabled is False
class TestTtsConfigParseNormal:
"""parse 正常数据测试."""
def test_parse_full_data(self):
data = {
"enabled": True,
"voice_id": "voice_001",
"speed": 1.5,
"pitch": 2.5,
"volume": 0.7,
"text": "你好世界",
"align_mode": "subtitle",
"overlap_mode": "mix",
}
config = TtsConfig.parse(data)
assert config.enabled is True
assert config.voice_id == "voice_001"
assert config.speed == 1.5
assert config.pitch == 2.5
assert config.volume == 0.7
assert config.text == "你好世界"
assert config.align_mode == "subtitle"
assert config.overlap_mode == "mix"
def test_parse_int_speed_becomes_float(self):
config = TtsConfig.parse({"enabled": True, "speed": 2})
assert isinstance(config.speed, float)
assert config.speed == 2.0
def test_parse_int_pitch_becomes_float(self):
config = TtsConfig.parse({"enabled": True, "pitch": -3})
assert isinstance(config.pitch, float)
assert config.pitch == -3.0
class TestTtsConfigParseTypeFallback:
"""parse 类型错误回退测试."""
def test_parse_voice_id_non_string_fallback(self):
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
assert config.voice_id == ""
def test_parse_speed_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
assert config.speed == 1.0
def test_parse_pitch_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
assert config.pitch == 0.0
def test_parse_volume_non_numeric_fallback(self):
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
assert config.volume == 0.8
def test_parse_text_non_string_fallback(self):
config = TtsConfig.parse({"enabled": True, "text": 456})
assert config.text == ""
def test_parse_voice_id_list_fallback(self):
config = TtsConfig.parse({"enabled": True, "voice_id": ["v1"]})
assert config.voice_id == ""
class TestTtsConfigParseClamp:
"""parse 边界钳制测试."""
def test_parse_speed_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert config.speed == 0.5
def test_parse_speed_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert config.speed == 2.0
def test_parse_speed_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
assert config.speed == 0.5
def test_parse_speed_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
assert config.speed == 2.0
def test_parse_pitch_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": -20})
assert config.pitch == -12
def test_parse_pitch_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "pitch": 20})
assert config.pitch == 12
def test_parse_pitch_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": -12})
assert config.pitch == -12
def test_parse_pitch_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "pitch": 12})
assert config.pitch == 12
def test_parse_volume_below_min_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
assert config.volume == 0.0
def test_parse_volume_above_max_clamped(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
assert config.volume == 1.0
def test_parse_volume_at_min_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
assert config.volume == 0.0
def test_parse_volume_at_max_ok(self):
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
assert config.volume == 1.0
class TestTtsConfigParseAlignMode:
"""align_mode 解析测试."""
def test_parse_align_mode_subtitle(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
assert config.align_mode == "subtitle"
def test_parse_align_mode_full(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
assert config.align_mode == "full"
def test_parse_align_mode_invalid_fallback(self):
config = TtsConfig.parse({"enabled": True, "align_mode": "auto"})
assert config.align_mode == "full"
def test_parse_align_mode_empty_fallback(self):
config = TtsConfig.parse({"enabled": True, "align_mode": ""})
assert config.align_mode == "full"
class TestTtsConfigParseOverlapMode:
"""overlap_mode 解析测试."""
def test_parse_overlap_mode_replace(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_mix(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
assert config.overlap_mode == "mix"
def test_parse_overlap_mode_invalid_fallback(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": "add"})
assert config.overlap_mode == "replace"
def test_parse_overlap_mode_empty_fallback(self):
config = TtsConfig.parse({"enabled": True, "overlap_mode": ""})
assert config.overlap_mode == "replace"
class TestTtsConfigClamp:
"""_clamp 直接调用测试."""
def test_clamp_speed_low(self):
config = TtsConfig(enabled=True, speed=0.1)
config._clamp()
assert config.speed == 0.5
def test_clamp_speed_high(self):
config = TtsConfig(enabled=True, speed=5.0)
config._clamp()
assert config.speed == 2.0
def test_clamp_speed_normal_unchanged(self):
config = TtsConfig(enabled=True, speed=1.2)
config._clamp()
assert config.speed == 1.2
def test_clamp_pitch_low(self):
config = TtsConfig(enabled=True, pitch=-20)
config._clamp()
assert config.pitch == -12
def test_clamp_pitch_high(self):
config = TtsConfig(enabled=True, pitch=20)
config._clamp()
assert config.pitch == 12
def test_clamp_pitch_normal_unchanged(self):
config = TtsConfig(enabled=True, pitch=5.0)
config._clamp()
assert config.pitch == 5.0
def test_clamp_volume_low(self):
config = TtsConfig(enabled=True, volume=-1.0)
config._clamp()
assert config.volume == 0.0
def test_clamp_volume_high(self):
config = TtsConfig(enabled=True, volume=2.0)
config._clamp()
assert config.volume == 1.0
def test_clamp_volume_normal_unchanged(self):
config = TtsConfig(enabled=True, volume=0.5)
config._clamp()
assert config.volume == 0.5
+280
View File
@@ -0,0 +1,280 @@
"""VerificationCode 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from domain.verification_code import VerificationCode
class TestVerificationCodeCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.id is not None
assert len(vc.id) == 32
assert vc.recipient == "test@example.com"
assert vc.code_type == "email_login"
assert len(vc.code) == 6
assert vc.code.isdigit()
assert vc.used_at is None
assert vc.attempts == 0
assert vc.created_at is not None
assert vc.expires_at > vc.created_at
def test_create_recipient_stripped(self):
vc = VerificationCode.create(" test@example.com ", "email_login")
assert vc.recipient == "test@example.com"
def test_create_custom_code(self):
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
assert vc.code == "123456"
def test_create_custom_ttl(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
assert vc.expires_at == fixed_now + timedelta(seconds=60)
def test_create_default_ttl_300(self):
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode.create("test@example.com", "email_login")
assert vc.expires_at == fixed_now + timedelta(seconds=300)
def test_create_unique_ids(self):
vc1 = VerificationCode.create("a@b.com", "email_login")
vc2 = VerificationCode.create("a@b.com", "email_login")
assert vc1.id != vc2.id
def test_create_unique_codes(self):
codes = set()
for _ in range(20):
vc = VerificationCode.create("a@b.com", "email_login")
codes.add(vc.code)
# 20个随机6位码几乎肯定不都一样
assert len(codes) > 1
def test_create_phone_recipient(self):
vc = VerificationCode.create("13800138000", "phone_login")
assert vc.recipient == "13800138000"
assert vc.code_type == "phone_login"
def test_create_all_code_types(self):
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
vc = VerificationCode.create("test@example.com", ct)
assert vc.code_type == ct
class TestVerificationCodeIsExpired:
"""is_expired 属性测试."""
def test_not_expired_future(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_expired is False
def test_expired_past(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_expired is True
def test_expired_boundary_exact(self):
# 用mock固定时间,expires_at等于当前时间不算过期
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("domain.verification_code.datetime") as mock_dt:
mock_dt.now.return_value = fixed_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=fixed_now,
)
assert vc.is_expired is False
class TestVerificationCodeIsUsed:
"""is_used 属性测试."""
def test_not_used_default(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.is_used is False
def test_is_used_after_mark(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
assert vc.is_used is True
class TestVerificationCodeIsValid:
"""is_valid 属性测试."""
def test_valid_fresh(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
assert vc.is_valid is True
def test_invalid_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
assert vc.is_valid is False
def test_invalid_used(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=future,
)
vc.mark_used()
assert vc.is_valid is False
def test_invalid_expired_and_used(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
vc = VerificationCode(
id="1",
recipient="a@b.com",
code="123456",
code_type="email_login",
expires_at=past,
)
vc.mark_used()
assert vc.is_valid is False
class TestVerificationCodeMarkUsed:
"""mark_used 方法测试."""
def test_mark_used_sets_timestamp(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.used_at is None
before = datetime.now(timezone.utc)
vc.mark_used()
after = datetime.now(timezone.utc)
assert vc.used_at is not None
assert before <= vc.used_at <= after
def test_mark_used_twice_overwrites(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.mark_used()
first = vc.used_at
# 时间足够短,一般不会不同,但确保可以重复调用
vc.mark_used()
assert vc.used_at is not None
class TestVerificationCodeIncrementAttempts:
"""increment_attempts 方法测试."""
def test_default_zero(self):
vc = VerificationCode.create("a@b.com", "email_login")
assert vc.attempts == 0
def test_increment_once(self):
vc = VerificationCode.create("a@b.com", "email_login")
vc.increment_attempts()
assert vc.attempts == 1
def test_increment_multiple(self):
vc = VerificationCode.create("a@b.com", "email_login")
for _i in range(5):
vc.increment_attempts()
assert vc.attempts == 5
class TestVerificationCodeBasics:
"""基础构造和 slots 测试."""
def test_direct_construction(self):
now = datetime.now(timezone.utc)
vc = VerificationCode(
id="abc123",
recipient="test@test.com",
code="000000",
code_type="email_bind",
expires_at=now + timedelta(minutes=5),
used_at=None,
attempts=0,
created_at=now,
)
assert vc.id == "abc123"
assert vc.recipient == "test@test.com"
assert vc.code == "000000"
def test_slots_no_extra_attrs(self):
vc = VerificationCode.create("a@b.com", "email_login")
with pytest.raises((AttributeError, TypeError)):
vc.new_field = "value" # type: ignore[attr-defined]
def test_equality_same_id(self):
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
vc1 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
vc2 = VerificationCode(
id="same",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
created_at=now,
)
assert vc1 == vc2
def test_equality_different_id(self):
now = datetime.now(timezone.utc)
vc1 = VerificationCode(
id="id1",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
vc2 = VerificationCode(
id="id2",
recipient="a@b.com",
code="111",
code_type="email_login",
expires_at=now,
)
assert vc1 != vc2
+392
View File
@@ -0,0 +1,392 @@
"""video_concat 视频拼接配置单测."""
import pytest
from domain.video_concat import (
ALLOWED_VIDEO_EXTENSIONS,
CONCAT_DEMUXER_REQUIRED_PARAMS,
MAX_CONCAT_SEGMENTS,
ConcatConfig,
ConcatSegment,
)
# ── 常量测试 ─────────────────────────────────────────────────────────────────
class TestConstants:
"""模块常量"""
def test_max_concat_segments(self):
assert MAX_CONCAT_SEGMENTS == 50
def test_allowed_extensions(self):
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
assert ".flv" in ALLOWED_VIDEO_EXTENSIONS
assert ".wmv" in ALLOWED_VIDEO_EXTENSIONS
def test_concat_demuxer_params(self):
params = CONCAT_DEMUXER_REQUIRED_PARAMS
assert "codec_name" in params
assert "width" in params
assert "height" in params
assert "r_frame_rate" in params
assert "pix_fmt" in params
assert "sample_rate" in params
assert "channels" in params
assert "audio_codec" in params
assert len(params) == 8
# ── ConcatSegment ────────────────────────────────────────────────────────────
class TestConcatSegmentDefaults:
"""ConcatSegment 默认值"""
def test_required_path(self):
s = ConcatSegment(video_path="/video.mp4")
assert s.video_path == "/video.mp4"
assert s.start_time == 0.0
assert s.duration == 0.0
assert s.has_audio is True
def test_all_custom(self):
s = ConcatSegment(
video_path="/clip.mp4",
start_time=5.0,
duration=10.0,
has_audio=False,
)
assert s.video_path == "/clip.mp4"
assert s.start_time == 5.0
assert s.duration == 10.0
assert s.has_audio is False
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict"""
def test_none_returns_empty_path(self):
s = ConcatSegment.from_dict(None)
assert s.video_path == ""
assert s.is_valid is False
def test_empty_dict(self):
s = ConcatSegment.from_dict({})
assert s.video_path == ""
def test_not_dict(self):
s = ConcatSegment.from_dict("not a dict")
assert s.video_path == ""
def test_full_dict(self):
s = ConcatSegment.from_dict(
{
"video_path": "/clip.mp4",
"start_time": 2.5,
"duration": 15.0,
"has_audio": False,
}
)
assert s.video_path == "/clip.mp4"
assert s.start_time == 2.5
assert s.duration == 15.0
assert s.has_audio is False
def test_invalid_start_time_falls_back(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": "bad"})
assert s.start_time == 0.0
def test_negative_start_time_clamped(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "start_time": -5.0})
assert s.start_time == 0.0
def test_invalid_duration_falls_back(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": None})
assert s.duration == 0.0
def test_negative_duration_clamped(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "duration": -10.0})
assert s.duration == 0.0
def test_has_audio_default_true(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4"})
assert s.has_audio is True
def test_has_audio_false(self):
s = ConcatSegment.from_dict({"video_path": "/a.mp4", "has_audio": False})
assert s.has_audio is False
def test_path_is_string(self):
s = ConcatSegment.from_dict({"video_path": 123})
assert s.video_path == "123"
class TestConcatSegmentProperties:
"""ConcatSegment 属性方法"""
def test_is_valid_true(self):
s = ConcatSegment(video_path="/a.mp4")
assert s.is_valid is True
def test_is_valid_false_empty(self):
s = ConcatSegment(video_path="")
assert s.is_valid is False
def test_effective_duration_positive(self):
s = ConcatSegment(video_path="/a.mp4", duration=10.0)
assert s.effective_duration == 10.0
def test_effective_duration_zero(self):
s = ConcatSegment(video_path="/a.mp4", duration=0.0)
assert s.effective_duration == 0.0
def test_effective_duration_negative(self):
s = ConcatSegment(video_path="/a.mp4", duration=-5.0)
assert s.effective_duration == 0.0
# ── ConcatConfig ─────────────────────────────────────────────────────────────
class TestConcatConfigDefaults:
"""ConcatConfig 默认值"""
def test_default_values(self):
c = ConcatConfig()
assert c.segments == []
assert c.output_width == 0
assert c.output_height == 0
assert c.output_fps == 0.0
assert c.force_reencode is False
assert c.transition == "none"
assert c.transition_duration == 0.3
class TestConcatConfigFromDict:
"""ConcatConfig.from_config_dict"""
def test_none_returns_default(self):
c = ConcatConfig.from_config_dict(None)
assert c.segments == []
def test_empty_dict_returns_default(self):
c = ConcatConfig.from_config_dict({})
assert c.segments == []
def test_not_dict_returns_default(self):
c = ConcatConfig.from_config_dict("config")
assert c.segments == []
def test_single_segment(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4", "duration": 10.0},
],
}
)
assert len(c.segments) == 1
assert c.segments[0].video_path == "/a.mp4"
def test_multiple_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4", "duration": 10.0},
{"video_path": "/b.mp4", "duration": 20.0},
{"video_path": "/c.mp4", "duration": 15.0},
],
}
)
assert len(c.segments) == 3
def test_skip_no_path_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"duration": 5.0}, # 没有 video_path
{"video_path": "/b.mp4"},
],
}
)
assert len(c.segments) == 2
def test_skip_non_dict_segments(self):
c = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4"},
"not a dict",
123,
None,
{"video_path": "/b.mp4"},
],
}
)
assert len(c.segments) == 2
def test_output_resolution(self):
c = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_width": 1920,
"output_height": 1080,
}
)
assert c.output_width == 1920
assert c.output_height == 1080
def test_output_width_clamped(self):
c = ConcatConfig.from_config_dict({"output_width": -100})
assert c.output_width == 0
def test_invalid_output_width_falls_back(self):
c = ConcatConfig.from_config_dict({"output_width": "wide"})
assert c.output_width == 0
def test_output_fps(self):
c = ConcatConfig.from_config_dict({"output_fps": 30.0})
assert c.output_fps == 30.0
def test_output_fps_clamped(self):
c = ConcatConfig.from_config_dict({"output_fps": -1.0})
assert c.output_fps == 0.0
def test_invalid_output_fps_falls_back(self):
c = ConcatConfig.from_config_dict({"output_fps": "fast"})
assert c.output_fps == 0.0
def test_force_reencode_true(self):
c = ConcatConfig.from_config_dict({"force_reencode": True})
assert c.force_reencode is True
def test_transition_crossfade(self):
c = ConcatConfig.from_config_dict({"transition": "crossfade"})
assert c.transition == "crossfade"
def test_transition_duration(self):
c = ConcatConfig.from_config_dict({"transition_duration": 1.0})
assert c.transition_duration == 1.0
def test_transition_duration_min_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.01})
# max(0.1, 0.01) = 0.1
assert c.transition_duration == 0.1
# 代码里 transition_duration = max(0.1, ...),默认 0.3
# 0.01 < 0.1 ,所以被钳制到 0.1
def test_invalid_transition_duration_falls_back(self):
c = ConcatConfig.from_config_dict({"transition_duration": "long"})
assert c.transition_duration == 0.3
def test_segments_not_list_ignored(self):
c = ConcatConfig.from_config_dict({"segments": "not a list"})
assert c.segments == []
class TestConcatConfigProperties:
"""ConcatConfig 属性方法"""
def _make_config(self, n=3):
return ConcatConfig.from_config_dict(
{
"segments": [{"video_path": f"/s{i}.mp4", "duration": 10.0 + i} for i in range(n)],
}
)
def test_has_effect_true(self):
c = self._make_config(3)
assert c.has_effect is True
def test_has_effect_false_one_segment(self):
c = self._make_config(1)
assert c.has_effect is False
def test_has_effect_false_empty(self):
c = ConcatConfig()
assert c.has_effect is False
def test_valid_segment_count(self):
c = self._make_config(5)
assert c.valid_segment_count == 5
def test_total_segments_alias(self):
c = self._make_config(4)
assert c.total_segments == 4
assert c.total_segments == c.valid_segment_count
def test_first_valid_segment(self):
c = self._make_config(3)
first = c.first_valid_segment
assert first is not None
assert first.video_path == "/s0.mp4"
def test_first_valid_segment_empty(self):
c = ConcatConfig()
assert c.first_valid_segment is None
def test_estimated_total_duration(self):
c = ConcatConfig(
segments=[
ConcatSegment("/a.mp4", duration=10.0),
ConcatSegment("/b.mp4", duration=20.0),
ConcatSegment("/c.mp4", duration=0.0), # 不计入
]
)
assert c.estimated_total_duration == 30.0
def test_estimated_total_duration_empty(self):
c = ConcatConfig()
assert c.estimated_total_duration == 0.0
def test_clamp_segments_within_limit(self):
c = self._make_config(10)
original = len(c.segments)
c.clamp_segments(max_segments=50)
assert len(c.segments) == original
def test_clamp_segments_over_limit(self):
c = self._make_config(10)
c.clamp_segments(max_segments=3)
assert len(c.segments) == 3
assert c.segments[0].video_path == "/s0.mp4"
assert c.segments[2].video_path == "/s2.mp4"
def test_clamp_segments_default_max(self):
# 默认应该是 MAX_CONCAT_SEGMENTS
c = ConcatConfig(segments=[ConcatSegment(f"/s{i}.mp4") for i in range(100)])
c.clamp_segments()
assert len(c.segments) == MAX_CONCAT_SEGMENTS
class TestTransitionDurationClamp:
"""transition_duration 钳制边界"""
def test_min_boundary_01(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.1})
assert c.transition_duration == 0.1
def test_below_min_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.05})
# max(0.1, 0.05) = 0.1
assert c.transition_duration == 0.1
def test_large_duration_ok(self):
c = ConcatConfig.from_config_dict({"transition_duration": 5.0})
assert c.transition_duration == 5.0
def test_zero_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": 0.0})
# max(0.1, 0.0) = 0.1
assert c.transition_duration == 0.1
def test_negative_clamped(self):
c = ConcatConfig.from_config_dict({"transition_duration": -1.0})
# max(0.1, -1.0) = 0.1
assert c.transition_duration == 0.1
+290
View File
@@ -0,0 +1,290 @@
"""video_share 视频分享领域实体单测."""
from datetime import datetime, timedelta, timezone
import pytest
from domain.video_share import (
VideoShare,
_hash_password,
generate_share_token,
)
# ── _hash_password ───────────────────────────────────────────────────────────
class TestHashPassword:
"""_hash_password 函数"""
def test_empty_password_returns_empty(self):
assert _hash_password("") == ""
def test_none_password_returns_empty(self):
assert _hash_password(None) == ""
def test_same_password_same_hash(self):
h1 = _hash_password("mypassword")
h2 = _hash_password("mypassword")
assert h1 == h2
def test_different_passwords_different_hashes(self):
h1 = _hash_password("password1")
h2 = _hash_password("password2")
assert h1 != h2
def test_hash_is_hex_string(self):
h = _hash_password("test")
assert isinstance(h, str)
assert len(h) == 64 # SHA-256 hex
int(h, 16) # 应该能被解析为16进制
def test_hash_contains_salt(self):
# 直接的 SHA-256(password) 应该不等于加盐后的
from hashlib import sha256
raw = sha256("mypass".encode()).hexdigest()
salted = _hash_password("mypass")
assert raw != salted
# ── generate_share_token ─────────────────────────────────────────────────────
class TestGenerateShareToken:
"""generate_share_token 函数"""
def test_default_length(self):
token = generate_share_token()
assert len(token) == 12
def test_custom_length(self):
token = generate_share_token(20)
assert len(token) == 20
def test_short_token(self):
token = generate_share_token(6)
assert len(token) == 6
def test_url_friendly_chars(self):
token = generate_share_token(100)
# 不应该有容易混淆的字符 i,l,o,0,1
assert "i" not in token
assert "l" not in token
assert "o" not in token
assert "0" not in token
assert "1" not in token
def test_unique_tokens(self):
tokens = {generate_share_token() for _ in range(100)}
assert len(tokens) == 100 # 应该都是唯一的
def test_alphanumeric(self):
token = generate_share_token(50)
assert token.isalnum()
# ── VideoShare.create ───────────────────────────────────────────────────────
class TestVideoShareCreate:
"""VideoShare.create 工厂方法"""
def test_minimal_create(self):
s = VideoShare.create(video_id="vid_001", user_id="user_001")
assert s.id is not None
assert len(s.id) == 32 # uuid4 hex
assert s.video_id == "vid_001"
assert s.user_id == "user_001"
assert s.share_token is not None
assert len(s.share_token) == 12
assert s.password_hash is None
assert s.expires_at is None
assert s.view_count == 0
assert s.download_count == 0
assert s.is_active is True
def test_with_password(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="secret123")
assert s.password_hash is not None
assert s.password_hash != "secret123" # 不是明文
assert len(s.password_hash) == 64 # SHA-256
def test_with_expiry(self):
future = datetime.now(timezone.utc) + timedelta(days=7)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.expires_at == future
def test_empty_video_id_raises(self):
with pytest.raises(ValueError, match="video_id"):
VideoShare.create(video_id="", user_id="u1")
def test_whitespace_video_id_raises(self):
with pytest.raises(ValueError):
VideoShare.create(video_id=" ", user_id="u1")
def test_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id"):
VideoShare.create(video_id="v1", user_id="")
def test_past_expiry_raises(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
with pytest.raises(ValueError, match="past"):
VideoShare.create(video_id="v1", user_id="u1", expires_at=past)
def test_video_id_stripped(self):
s = VideoShare.create(video_id=" vid_123 ", user_id="u1")
assert s.video_id == "vid_123"
def test_user_id_stripped(self):
s = VideoShare.create(video_id="v1", user_id=" user_456 ")
assert s.user_id == "user_456"
def test_unique_ids(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.id != s2.id
def test_unique_tokens(self):
s1 = VideoShare.create(video_id="v1", user_id="u1")
s2 = VideoShare.create(video_id="v1", user_id="u1")
assert s1.share_token != s2.share_token
def test_timestamps_set(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.created_at.tzinfo is not None
assert s.updated_at.tzinfo is not None
# ── VideoShare 属性方法 ─────────────────────────────────────────────────────
class TestVideoShareProperties:
"""VideoShare 属性方法"""
def test_has_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="pass")
assert s.has_password is True
def test_has_password_false(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.has_password is False
def test_is_expired_false_no_expiry(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_expired is False
def test_is_expired_false_future_expiry(self):
future = datetime.now(timezone.utc) + timedelta(hours=1)
s = VideoShare.create(video_id="v1", user_id="u1", expires_at=future)
assert s.is_expired is False
def test_is_expired_true_past_expiry(self):
# 直接构造一个已过期的
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_expired is True
def test_is_accessible_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
def test_is_accessible_false_inactive(self):
s = VideoShare.create(video_id="v1", user_id="u1")
s.is_active = False
assert s.is_accessible is False
def test_is_accessible_false_expired(self):
past = datetime.now(timezone.utc) - timedelta(hours=1)
s = VideoShare(
id="test",
video_id="v1",
user_id="u1",
share_token="abc",
expires_at=past,
)
assert s.is_accessible is False
# ── VideoShare 方法 ─────────────────────────────────────────────────────────
class TestVideoShareMethods:
"""VideoShare 方法"""
def test_verify_password_no_password_true(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.verify_password("anything") is True
assert s.verify_password("") is True
def test_verify_password_correct(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("mypass") is True
def test_verify_password_wrong(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("wrongpass") is False
def test_verify_password_empty_false(self):
s = VideoShare.create(video_id="v1", user_id="u1", password="mypass")
assert s.verify_password("") is False
def test_increment_view_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.view_count == 0
s.increment_view_count()
assert s.view_count == 1
s.increment_view_count()
assert s.view_count == 2
def test_increment_download_count(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.download_count == 0
s.increment_download_count()
assert s.download_count == 1
s.increment_download_count()
assert s.download_count == 2
def test_revoke(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_active is True
s.revoke()
assert s.is_active is False
def test_revoke_makes_inaccessible(self):
s = VideoShare.create(video_id="v1", user_id="u1")
assert s.is_accessible is True
s.revoke()
assert s.is_accessible is False
# ── dataclass 基础特性 ───────────────────────────────────────────────────────
class TestVideoShareBasics:
"""VideoShare 基础特性"""
def test_slots_no_extra_attrs(self):
s = VideoShare.create(video_id="v1", user_id="u1")
with pytest.raises(AttributeError):
s.nonexistent = "value"
def test_direct_construction(self):
s = VideoShare(
id="custom_id",
video_id="v1",
user_id="u1",
share_token="abc123",
)
assert s.id == "custom_id"
assert s.share_token == "abc123"
def test_equality_same_id(self):
now = datetime.now(timezone.utc)
s1 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
s2 = VideoShare(id="same", video_id="v1", user_id="u1", share_token="t", created_at=now, updated_at=now)
assert s1 == s2
+368
View File
@@ -0,0 +1,368 @@
"""voice_presets 配音音色预设模块单测."""
import pytest
from domain.voice_presets import (
MOCK_VOICES,
VoiceGender,
VoicePreset,
VoiceStyle,
get_default_voice,
get_voice,
list_voices,
)
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
class TestVoiceGender:
"""VoiceGender 音色性别枚举"""
def test_enum_values(self):
assert VoiceGender.MALE.value == "male"
assert VoiceGender.FEMALE.value == "female"
assert VoiceGender.CHILD.value == "child"
def test_is_str(self):
assert isinstance(VoiceGender.MALE, str)
assert VoiceGender.FEMALE == "female"
def test_from_string(self):
assert VoiceGender("male") == VoiceGender.MALE
assert VoiceGender("child") == VoiceGender.CHILD
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceGender("unknown")
class TestVoiceStyle:
"""VoiceStyle 音色风格枚举"""
def test_enum_values(self):
assert VoiceStyle.STABLE.value == "stable"
assert VoiceStyle.LIVELY.value == "lively"
assert VoiceStyle.CUSTOMER_SERVICE.value == "customer_service"
assert VoiceStyle.NARRATION.value == "narration"
assert VoiceStyle.NEWS.value == "news"
assert VoiceStyle.STORY.value == "story"
def test_is_str(self):
assert isinstance(VoiceStyle.NARRATION, str)
assert VoiceStyle.STORY == "story"
def test_from_string(self):
assert VoiceStyle("news") == VoiceStyle.NEWS
def test_invalid_raises(self):
with pytest.raises(ValueError):
VoiceStyle("rock")
# ── VoicePreset dataclass ─────────────────────────────────────────────────────
class TestVoicePreset:
"""VoicePreset 音色预设 dataclass"""
def test_minimal_creation(self):
v = VoicePreset(voice_id="test_voice", name="测试音色")
assert v.voice_id == "test_voice"
assert v.name == "测试音色"
# 默认值
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.description == ""
assert v.provider == "mock"
assert v.provider_voice_id == ""
assert v.default_speed == 1.0
assert v.default_pitch == 0.0
assert v.sample_rate == 22050
assert v.language == "zh-CN"
def test_full_creation(self):
v = VoicePreset(
voice_id="male_deep",
name="深沉男声",
gender=VoiceGender.MALE,
style=VoiceStyle.STABLE,
description="非常深沉的男声",
provider="aliyun",
provider_voice_id="zhiyuan",
default_speed=0.8,
default_pitch=-1.0,
sample_rate=16000,
language="zh-CN",
)
assert v.voice_id == "male_deep"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.provider == "aliyun"
assert v.default_speed == 0.8
assert v.sample_rate == 16000
def test_str_gender_creation(self):
# 用字符串值创建也可以(因为是 StrEnum)
v = VoicePreset(voice_id="v1", name="V1", gender="male")
assert v.gender == VoiceGender.MALE
def test_str_style_creation(self):
v = VoicePreset(voice_id="v1", name="V1", style="news")
assert v.style == VoiceStyle.NEWS
def test_equality(self):
v1 = VoicePreset(voice_id="same", name="同名")
v2 = VoicePreset(voice_id="same", name="同名")
assert v1 == v2
def test_inequality(self):
v1 = VoicePreset(voice_id="a", name="A")
v2 = VoicePreset(voice_id="b", name="B")
assert v1 != v2
def test_slots_no_extra_attrs(self):
v = VoicePreset(voice_id="test", name="Test")
with pytest.raises(AttributeError):
v.nonexistent_field = "value"
# ── MOCK_VOICES 列表 ─────────────────────────────────────────────────────────
class TestMockVoices:
"""Mock 音色预设列表"""
def test_not_empty(self):
assert len(MOCK_VOICES) > 0
def test_count(self):
assert len(MOCK_VOICES) == 8
def test_all_are_voice_preset(self):
for v in MOCK_VOICES:
assert isinstance(v, VoicePreset)
def test_unique_voice_ids(self):
ids = [v.voice_id for v in MOCK_VOICES]
assert len(ids) == len(set(ids))
def test_female_warm_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_warm")
assert v.name == "温暖女声"
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.NARRATION
assert v.default_speed == 1.0
assert "温柔" in v.description
def test_male_stable_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "male_stable")
assert v.name == "沉稳男声"
assert v.gender == VoiceGender.MALE
assert v.style == VoiceStyle.STABLE
assert v.default_speed == 0.9
def test_female_lively_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "female_lively")
assert v.gender == VoiceGender.FEMALE
assert v.style == VoiceStyle.LIVELY
assert v.default_speed == 1.2
assert v.default_pitch == 2.0
def test_child_cute_preset(self):
v = next(v for v in MOCK_VOICES if v.voice_id == "child_cute")
assert v.gender == VoiceGender.CHILD
assert v.style == VoiceStyle.STORY
assert v.default_pitch == 4.0
def test_all_mock_provider(self):
for v in MOCK_VOICES:
assert v.provider == "mock"
def test_all_have_provider_voice_id(self):
for v in MOCK_VOICES:
assert v.provider_voice_id != ""
def test_all_chinese(self):
for v in MOCK_VOICES:
assert v.language == "zh-CN"
# ── get_voice ─────────────────────────────────────────────────────────────────
class TestGetVoice:
"""get_voice 函数"""
def test_get_existing_voice(self):
v = get_voice("female_warm")
assert v is not None
assert v.voice_id == "female_warm"
assert v.name == "温暖女声"
def test_get_male_stable(self):
v = get_voice("male_stable")
assert v is not None
assert v.gender == VoiceGender.MALE
def test_get_child_cute(self):
v = get_voice("child_cute")
assert v is not None
assert v.gender == VoiceGender.CHILD
def test_get_nonexistent_returns_none(self):
v = get_voice("nonexistent_voice")
assert v is None
def test_get_empty_string_returns_none(self):
v = get_voice("")
assert v is None
def test_non_mock_provider_returns_none(self):
v = get_voice("female_warm", provider="aliyun")
assert v is None
def test_non_mock_provider_nonexistent(self):
v = get_voice("whatever", provider="xunfei")
assert v is None
def test_mock_provider_explicit(self):
v = get_voice("female_warm", provider="mock")
assert v is not None
assert v.voice_id == "female_warm"
def test_returns_same_instance(self):
# 应该返回同一个对象(缓存的)
v1 = get_voice("female_warm")
v2 = get_voice("female_warm")
assert v1 is v2
# ── list_voices ───────────────────────────────────────────────────────────────
class TestListVoices:
"""list_voices 函数"""
def test_no_filters_returns_all(self):
result = list_voices()
assert len(result) == len(MOCK_VOICES)
assert len(result) == 8
def test_filter_by_gender_male(self):
result = list_voices(gender="male")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.MALE
def test_filter_by_gender_female(self):
result = list_voices(gender="female")
assert len(result) > 0
for v in result:
assert v.gender == VoiceGender.FEMALE
def test_filter_by_gender_child(self):
result = list_voices(gender="child")
assert len(result) == 1
assert result[0].voice_id == "child_cute"
def test_filter_by_gender_invalid_returns_empty(self):
result = list_voices(gender="alien")
assert len(result) == 0
def test_filter_by_style_stable(self):
result = list_voices(style="stable")
assert len(result) > 0
for v in result:
assert v.style == VoiceStyle.STABLE
def test_filter_by_style_lively(self):
result = list_voices(style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_filter_by_style_story(self):
result = list_voices(style="story")
assert len(result) >= 2
for v in result:
assert v.style == VoiceStyle.STORY
def test_filter_by_style_invalid_returns_empty(self):
result = list_voices(style="punk")
assert len(result) == 0
def test_filter_by_provider_mock(self):
result = list_voices(provider="mock")
assert len(result) == len(MOCK_VOICES)
def test_filter_by_provider_other_returns_empty(self):
result = list_voices(provider="aliyun")
assert len(result) == 0
def test_filter_by_keyword_name(self):
result = list_voices(keyword="女声")
assert len(result) > 0
for v in result:
assert "女声" in v.name or "女声" in v.description or "女声" in v.voice_id
def test_filter_by_keyword_description(self):
result = list_voices(keyword="商务")
assert len(result) > 0
# 沉稳男声描述里有"商务"
def test_filter_by_keyword_voice_id(self):
result = list_voices(keyword="male_stable")
assert len(result) == 1
assert result[0].voice_id == "male_stable"
def test_filter_by_keyword_case_insensitive(self):
result1 = list_voices(keyword="Female")
result2 = list_voices(keyword="female")
assert len(result1) == len(result2)
def test_filter_by_keyword_nonexistent(self):
result = list_voices(keyword="不存在的关键词999")
assert len(result) == 0
def test_combined_gender_and_style(self):
result = list_voices(gender="female", style="lively")
assert len(result) == 1
assert result[0].voice_id == "female_lively"
def test_combined_gender_style_keyword(self):
result = list_voices(gender="male", style="story", keyword="磁性")
assert len(result) == 1
assert result[0].voice_id == "male_magnetic"
def test_combined_no_match(self):
result = list_voices(gender="child", style="news")
assert len(result) == 0
def test_returns_new_list(self):
# 修改返回值不应影响原始列表
result = list_voices()
result.clear()
assert len(MOCK_VOICES) == 8
# ── get_default_voice ─────────────────────────────────────────────────────────
class TestGetDefaultVoice:
"""get_default_voice 函数"""
def test_returns_voice_preset(self):
v = get_default_voice()
assert isinstance(v, VoicePreset)
def test_returns_first_mock_voice(self):
v = get_default_voice()
assert v == MOCK_VOICES[0]
def test_default_is_female_warm(self):
v = get_default_voice()
assert v.voice_id == "female_warm"
assert v.gender == VoiceGender.FEMALE
def test_multiple_calls_same(self):
v1 = get_default_voice()
v2 = get_default_voice()
assert v1 is v2
+292 -466
View File
@@ -1,9 +1,6 @@
"""BGM 混音纯逻辑单元测试."""
"""bgm_mixer_pure 单元测试."""
from __future__ import annotations
import pytest
from video_processing.bgm_mixer_pure import (
from apps.worker.video_processing.bgm_mixer_pure import (
BGMPureConfig,
build_bgm_filter_chain,
build_sidechain_mix_filter,
@@ -17,408 +14,286 @@ from video_processing.bgm_mixer_pure import (
validate_bgm_config,
)
# ─────────────────────────────────────────────────────────────────────────────
# should_loop_bgm 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── BGMPureConfig ────────────────────────────────────────────────────────────
class TestShouldLoopBGM:
"""BGM 循环判断测试."""
class TestBGMPureConfig:
def test_default_values(self):
cfg = BGMPureConfig()
assert cfg.volume == 0.3
assert cfg.fade_in == 0.0
assert cfg.fade_out == 0.0
assert cfg.loop_enabled is True
assert cfg.sidechain_enabled is False
assert cfg.sidechain_ratio == 0.3
assert cfg.sidechain_attack == 0.02
assert cfg.sidechain_release == 0.5
assert cfg.sidechain_threshold == -25.0
def test_need_loop_when_much_shorter(self):
"""BGM 远短于目标时长,需要循环."""
assert should_loop_bgm(10, 100, True) is True
def test_custom_values(self):
cfg = BGMPureConfig(
volume=0.5,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
)
assert cfg.volume == 0.5
assert cfg.loop_enabled is False
assert cfg.sidechain_enabled is True
assert cfg.sidechain_ratio == 0.5
def test_no_loop_when_long_enough(self):
"""BGM 够长,不需要循环."""
assert should_loop_bgm(100, 100, True) is False
def test_no_loop_when_just_slightly_shorter(self):
"""BGM 只差一点点(>90%),不循环."""
assert should_loop_bgm(95, 100, True) is False
# ── should_loop_bgm ─────────────────────────────────────────────────────────
def test_threshold_90_percent(self):
"""刚好 90% 阈值,不循环(<90% 才循环)."""
assert should_loop_bgm(90, 100, True) is False
def test_just_below_threshold(self):
"""略低于 90%,需要循环."""
assert should_loop_bgm(89, 100, True) is True
class TestShouldLoopBgm:
def test_loop_enabled_much_shorter(self):
# BGM 10秒,目标60秒 → 需要循环
assert should_loop_bgm(10, 60) is True
def test_loop_disabled(self):
"""禁用循环,即使 BGM 很短也不循环."""
assert should_loop_bgm(10, 100, False) is False
assert should_loop_bgm(10, 60, loop_enabled=False) is False
def test_bgm_longer_than_target(self):
# BGM 100秒,目标60秒 → 不需要循环
assert should_loop_bgm(100, 60) is False
def test_bgm_slightly_shorter_no_loop(self):
# BGM 58秒,目标60秒 → 58 > 60*0.9=54,不需要循环
assert should_loop_bgm(58, 60) is False
def test_bgm_significantly_shorter_loops(self):
# BGM 50秒,目标60秒 → 50 < 54,需要循环
assert should_loop_bgm(50, 60) is True
def test_zero_bgm_duration(self):
"""BGM 时长为 0,不循环."""
assert should_loop_bgm(0, 100, True) is False
assert should_loop_bgm(0, 60) is False
def test_negative_bgm_duration(self):
"""BGM 时长为负,不循环."""
assert should_loop_bgm(-5, 100, True) is False
assert should_loop_bgm(-1, 60) is False
def test_zero_target_duration(self):
"""目标时长为 0,不循环."""
assert should_loop_bgm(10, 0, True) is False
assert should_loop_bgm(10, 0) is False
def test_negative_target_duration(self):
"""目标时长为负,不循环."""
assert should_loop_bgm(10, -10, True) is False
assert should_loop_bgm(10, -1) is False
def test_exact_90_percent_no_loop(self):
# 边界:bgm == target * 0.9 → 不小于,不循环
assert should_loop_bgm(54, 60) is False
# ─────────────────────────────────────────────────────────────────────────────
# calculate_loop_count 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── calculate_loop_count ────────────────────────────────────────────────────
class TestCalculateLoopCount:
"""循环次数计算测试."""
def test_exact_fit_returns_1(self):
assert calculate_loop_count(60, 60) == 1
def test_exact_multiple(self):
"""刚好整数倍."""
# 100/10 = 10, +2 = 12
assert calculate_loop_count(10, 100) == 12
def test_bgm_longer_returns_1(self):
assert calculate_loop_count(100, 60) == 1
def test_not_exact_multiple(self):
"""不是整数倍."""
# 100/30 = 3, +2 = 5
assert calculate_loop_count(30, 100) == 5
def test_needs_3_loops_plus_2_margin(self):
# 60/20 = 3 + 2 = 5
assert calculate_loop_count(20, 60) == 5
def test_bgm_longer_than_target(self):
"""BGM 比目标长,至少 1 次."""
assert calculate_loop_count(200, 100) == 1
def test_needs_2_loops_plus_2_margin(self):
# 60/30 = 2 + 2 = 4
assert calculate_loop_count(30, 60) == 4
def test_zero_bgm_duration(self):
"""BGM 时长为 0,返回 1."""
assert calculate_loop_count(0, 100) == 1
assert calculate_loop_count(0, 60) == 1
def test_negative_bgm_duration(self):
"""BGM 时长为负,返回 1."""
assert calculate_loop_count(-5, 100) == 1
assert calculate_loop_count(-1, 60) == 1
def test_zero_target_duration(self):
"""目标时长为 0,返回 1."""
assert calculate_loop_count(10, 0) == 1
def test_negative_target_duration(self):
"""目标时长为负,返回 1."""
assert calculate_loop_count(10, -10) == 1
assert calculate_loop_count(10, -1) == 1
def test_very_short_bgm(self):
"""非常短的 BGM,循环次数多."""
# 100/1 = 100, +2 = 102
assert calculate_loop_count(1, 100) == 102
def test_minimum_is_1(self):
assert calculate_loop_count(10, 5) == 1
# ─────────────────────────────────────────────────────────────────────────────
# build_bgm_filter_chain 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── build_bgm_filter_chain ──────────────────────────────────────────────────
class TestBuildBGMFilterChain:
"""BGM 预处理滤镜链构建测试."""
class TestBuildBgmFilterChain:
def test_basic_structure(self):
result = build_bgm_filter_chain(100, 60)
parts = result.split(",")
# 至少有 atrim + asetpts
assert any("atrim=" in p for p in parts)
assert "asetpts=N/SR/TB" in parts
def test_basic_volume_only(self):
"""只有音量调节."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.5,
)
def test_volume_filter_applied(self):
result = build_bgm_filter_chain(100, 60, volume=0.5)
assert "volume=0.500" in result
assert "aloop" not in result
assert "afade=t=in" not in result
assert "afade=t=out" not in result
assert "atrim=0:100.000" in result
assert "asetpts=N/SR/TB" in result
def test_with_loop(self):
"""需要循环的情况."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=True,
)
assert "aloop=loop=" in result
assert "volume=0.300" in result
def test_volume_one_omitted(self):
result = build_bgm_filter_chain(100, 60, volume=1.0)
assert "volume=" not in result
def test_no_loop_when_disabled(self):
"""禁用循环,即使 BGM 短也不循环."""
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.3,
loop_enabled=False,
)
assert "aloop" not in result
def test_volume_clamped(self):
# volume=2.0钳制到1.01.0等于默认值所以被跳过
result = build_bgm_filter_chain(100, 60, volume=2.0)
assert "volume=" not in result # 钳制到1.0后与默认相同,跳过
# 用0.5验证音量过滤器本身存在
result2 = build_bgm_filter_chain(100, 60, volume=0.5)
assert "volume=0.500" in result2
def test_fade_in_only(self):
"""只有淡入."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=2.5,
)
assert "afade=t=in:st=0:d=2.500" in result
assert "afade=t=out" not in result
assert "volume=" not in result # volume=1.0 不加
def test_volume_zero(self):
result = build_bgm_filter_chain(100, 60, volume=0.0)
assert "volume=0.000" in result
def test_fade_out_only(self):
"""只有淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_out=3.0,
)
assert "afade=t=out:st=97.000:d=3.000" in result
assert "afade=t=in" not in result
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
fade_in=1.5,
fade_out=2.0,
)
def test_fade_in_applied(self):
result = build_bgm_filter_chain(100, 60, fade_in=1.5)
assert "afade=t=in:st=0:d=1.500" in result
assert "afade=t=out:st=98.000:d=2.000" in result
def test_volume_1_0_skipped(self):
"""音量为 1.0 时不添加 volume 滤镜."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.0,
)
assert "volume=" not in result
def test_fade_in_zero_skipped(self):
result = build_bgm_filter_chain(100, 60, fade_in=0)
assert "afade=t=in" not in result
def test_volume_0(self):
"""音量为 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=0.0,
)
assert "volume=0.000" in result
def test_fade_out_applied(self):
result = build_bgm_filter_chain(100, 60, fade_out=2.0)
assert "afade=t=out:st=58.000:d=2.000" in result
def test_volume_clamped_high(self):
"""音量超过 1.0 被钳制."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=1.5,
)
assert "volume=1.000" not in result # 1.0不加
# 钳制到1.0后和1.0一样,不加volume滤镜
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
assert "volume=" not in result
def test_volume_clamped_low(self):
"""音量为负被钳制到 0."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=100,
volume=-0.5,
)
assert "volume=0.000" in result
def test_fade_out_longer_than_duration(self):
"""淡出时长超过总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=20.0,
)
def test_fade_out_longer_than_target_skipped(self):
result = build_bgm_filter_chain(100, 10, fade_out=20)
# fade_out >= safe_target,不做淡出
assert "afade=t=out" not in result
def test_fade_out_equal_to_duration(self):
"""淡出时长等于总时长,不加淡出."""
result = build_bgm_filter_chain(
bgm_duration=200,
target_duration=10,
volume=1.0,
fade_out=10.0,
)
assert "afade=t=out" not in result
def test_loop_applied_when_needed(self):
result = build_bgm_filter_chain(10, 60)
assert "aloop=loop=" in result
def test_zero_target_duration_fallback(self):
"""目标时长为 0,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=0,
volume=0.5,
)
def test_no_loop_when_bgm_long(self):
result = build_bgm_filter_chain(100, 60)
assert "aloop=" not in result
def test_loop_disabled(self):
result = build_bgm_filter_chain(10, 60, loop_enabled=False)
assert "aloop=" not in result
def test_trim_to_target_duration(self):
result = build_bgm_filter_chain(100, 60)
assert "atrim=0:60.000" in result
def test_zero_target_uses_fallback(self):
result = build_bgm_filter_chain(100, 0)
# 兜底5秒
assert "atrim=0:5.000" in result
def test_negative_target_duration_fallback(self):
"""目标时长为负,兜底 5 秒."""
result = build_bgm_filter_chain(
bgm_duration=3,
target_duration=-5,
volume=0.5,
)
def test_negative_target_uses_fallback(self):
result = build_bgm_filter_chain(100, -5)
assert "atrim=0:5.000" in result
def test_full_chain_with_all_effects(self):
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
def test_all_features_combined(self):
result = build_bgm_filter_chain(
bgm_duration=10,
target_duration=100,
volume=0.4,
bgm_duration=15,
target_duration=60,
volume=0.3,
fade_in=1.0,
fade_out=2.0,
loop_enabled=True,
)
parts = result.split(",")
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
assert len(parts) >= 6
assert "aloop" in parts[0]
assert "volume" in parts[1]
assert "afade=t=in" in parts[2]
assert "afade=t=out" in parts[3]
assert "atrim" in parts[4]
assert "asetpts" in parts[5]
assert "aloop=loop=" in result
assert "volume=0.300" in result
assert "afade=t=in" in result
assert "afade=t=out" in result
assert "atrim=0:60.000" in result
assert "asetpts=N/SR/TB" in result
# ─────────────────────────────────────────────────────────────────────────────
# calculate_sidechain_ratio 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── calculate_sidechain_ratio ───────────────────────────────────────────────
class TestCalculateSidechainRatio:
"""Sidechain 压缩比计算测试."""
def test_zero_ratio_minimum(self):
assert calculate_sidechain_ratio(0) == 2.0
def test_default_ratio_0_3(self):
"""默认 0.3."""
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_5(self):
"""比例 0.5."""
# 1 / (1 - 0.5) = 2.0
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
def test_ratio_0_8(self):
"""比例 0.8."""
# 1 / (1 - 0.8) = 5.0
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
def test_ratio_0_9(self):
"""比例 0.9."""
# 1 / (1 - 0.9) = 10.0
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
def test_ratio_0(self):
"""比例 0,返回下限 2.0."""
assert calculate_sidechain_ratio(0.0) == 2.0
def test_ratio_negative(self):
"""比例为负,返回下限 2.0."""
def test_negative_clamped(self):
assert calculate_sidechain_ratio(-0.5) == 2.0
def test_ratio_1_0(self):
"""比例 1.0,返回上限 10.0."""
def test_one_ratio_maximum(self):
assert calculate_sidechain_ratio(1.0) == 10.0
def test_ratio_greater_than_1(self):
"""比例超过 1.0,返回上限 10.0."""
assert calculate_sidechain_ratio(2.0) == 10.0
def test_above_one_clamped(self):
assert calculate_sidechain_ratio(1.5) == 10.0
def test_mid_value(self):
# ratio = 1/(1-0.5) = 2.0
result = calculate_sidechain_ratio(0.5)
assert abs(result - 2.0) < 0.01
def test_high_value(self):
# 1/(1-0.9) = 10 → 钳制到10
assert calculate_sidechain_ratio(0.9) == 10.0
def test_03_default(self):
# 1/(1-0.3) = 1.428... → 钳制到2.0
result = calculate_sidechain_ratio(0.3)
assert result >= 2.0
# ─────────────────────────────────────────────────────────────────────────────
# build_simple_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── build_simple_mix_filter ─────────────────────────────────────────────────
class TestBuildSimpleMixFilter:
"""普通混音滤镜构建测试."""
def test_contains_amix(self):
"""包含 amix."""
def test_contains_inputs_and_output(self):
result = build_simple_mix_filter()
assert "[0:a][1:a]" in result
assert "amix=inputs=2" in result
def test_contains_volume_compensation(self):
"""包含 volume=2 补偿."""
result = build_simple_mix_filter()
assert "duration=first" in result
assert "[final]" in result
assert "volume=2" in result
def test_output_label(self):
"""输出标签为 [final]."""
result = build_simple_mix_filter()
assert "[final]" in result
def test_duration_first(self):
"""duration=first,以主音频时长为准."""
result = build_simple_mix_filter()
assert "duration=first" in result
# ─────────────────────────────────────────────────────────────────────────────
# build_sidechain_mix_filter 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── build_sidechain_mix_filter ──────────────────────────────────────────────
class TestBuildSidechainMixFilter:
"""Sidechain 混音滤镜构建测试."""
def test_contains_sidechaincompress(self):
"""包含 sidechaincompress."""
result = build_sidechain_mix_filter()
assert "sidechaincompress=" in result
assert "[1:a][0:a]sidechaincompress" in result
def test_threshold_param(self):
"""threshold 参数正确."""
def test_threshold_in_db(self):
result = build_sidechain_mix_filter(threshold=-30.0)
assert "threshold=-30.0dB" in result
def test_attack_param(self):
"""attack 参数正确."""
result = build_sidechain_mix_filter(attack=0.05)
assert "attack=0.050" in result
def test_release_param(self):
"""release 参数正确."""
result = build_sidechain_mix_filter(release=0.8)
assert "release=0.800" in result
def test_knee_param(self):
"""knee=6 参数."""
result = build_sidechain_mix_filter()
assert "knee=6" in result
def test_attack_and_release(self):
result = build_sidechain_mix_filter(attack=0.01, release=0.3)
assert "attack=0.010" in result
assert "release=0.300" in result
def test_contains_amix(self):
"""包含 amix 混音."""
result = build_sidechain_mix_filter()
assert "amix=inputs=2" in result
assert "duration=first" in result
def test_volume_compensation(self):
"""volume=1.5 轻微补偿."""
def test_contains_volume_compensation(self):
result = build_sidechain_mix_filter()
assert "volume=1.5" in result
def test_bgmc_comp_label(self):
"""包含 [bgm_comp] 中间标签."""
def test_output_label(self):
result = build_sidechain_mix_filter()
assert "[final]" in result
def test_bgm_comp_label(self):
result = build_sidechain_mix_filter()
assert "[bgm_comp]" in result
# ─────────────────────────────────────────────────────────────────────────────
# normalize_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── normalize_bgm_config ────────────────────────────────────────────────────
class TestNormalizeBGMConfig:
"""配置规范化测试."""
def test_empty_dict_defaults(self):
"""空字典返回默认值."""
class TestNormalizeBgmConfig:
def test_default_values(self):
result = normalize_bgm_config({})
assert result["volume"] == 0.3
assert result["fade_in"] == 0.0
@@ -426,233 +301,184 @@ class TestNormalizeBGMConfig:
assert result["loop_enabled"] is True
assert result["sidechain_enabled"] is False
assert result["sidechain_ratio"] == 0.3
assert result["sidechain_attack"] == 0.02
assert result["sidechain_release"] == 0.5
assert result["sidechain_threshold"] == -25.0
def test_volume_clamped(self):
"""音量钳制."""
result = normalize_bgm_config({"volume": 1.5})
result = normalize_bgm_config({"volume": 2.0})
assert result["volume"] == 1.0
result2 = normalize_bgm_config({"volume": -0.5})
assert result2["volume"] == 0.0
result = normalize_bgm_config({"volume": -1.0})
assert result["volume"] == 0.0
def test_fade_in_negative(self):
"""淡入为负钳制到 0."""
result = normalize_bgm_config({"fade_in": -1})
def test_fade_in_clamped_to_zero(self):
result = normalize_bgm_config({"fade_in": -5})
assert result["fade_in"] == 0.0
def test_fade_out_negative(self):
"""淡出为负钳制到 0."""
result = normalize_bgm_config({"fade_out": -1})
def test_fade_out_clamped_to_zero(self):
result = normalize_bgm_config({"fade_out": -5})
assert result["fade_out"] == 0.0
def test_sidechain_ratio_clamped(self):
"""sidechain_ratio 钳制."""
result = normalize_bgm_config({"sidechain_ratio": 1.5})
assert result["sidechain_ratio"] == 1.0
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
assert result2["sidechain_ratio"] == 0.0
def test_loop_enabled_bool_conversion(self):
assert normalize_bgm_config({"loop_enabled": True})["loop_enabled"] is True
assert normalize_bgm_config({"loop_enabled": False})["loop_enabled"] is False
assert normalize_bgm_config({"loop_enabled": 1})["loop_enabled"] is True
assert normalize_bgm_config({"loop_enabled": 0})["loop_enabled"] is False
def test_sidechain_attack_min(self):
"""attack 最小值 0.001."""
def test_sidechain_ratio_clamped(self):
result = normalize_bgm_config({"sidechain_ratio": 2.0})
assert result["sidechain_ratio"] == 1.0
result = normalize_bgm_config({"sidechain_ratio": -1.0})
assert result["sidechain_ratio"] == 0.0
def test_sidechain_attack_minimum(self):
result = normalize_bgm_config({"sidechain_attack": 0})
assert result["sidechain_attack"] == 0.001
def test_sidechain_release_min(self):
"""release 最小值 0.01."""
def test_sidechain_release_minimum(self):
result = normalize_bgm_config({"sidechain_release": 0})
assert result["sidechain_release"] == 0.01
def test_sidechain_threshold_pass_through(self):
result = normalize_bgm_config({"sidechain_threshold": -40.0})
assert result["sidechain_threshold"] == -40.0
def test_string_values_converted(self):
"""字符串数值被转换."""
result = normalize_bgm_config(
{
"volume": "0.5",
"fade_in": "2.0",
"fade_in": "1.0",
"sidechain_ratio": "0.7",
}
)
assert result["volume"] == 0.5
assert result["fade_in"] == 2.0
def test_loop_enabled_truthy(self):
"""loop_enabled 真值转换."""
result = normalize_bgm_config({"loop_enabled": 1})
assert result["loop_enabled"] is True
result2 = normalize_bgm_config({"loop_enabled": 0})
assert result2["loop_enabled"] is False
def test_preserves_unknown_keys(self):
"""未知 key 不保留."""
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
assert "unknown_key" not in result
assert result["volume"] == 0.5
assert result["fade_in"] == 1.0
assert result["sidechain_ratio"] == 0.7
# ─────────────────────────────────────────────────────────────────────────────
# validate_bgm_config 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── validate_bgm_config ─────────────────────────────────────────────────────
class TestValidateBGMConfig:
"""配置验证测试."""
class TestValidateBgmConfig:
def test_valid_config(self):
"""合法配置."""
ok, errors = validate_bgm_config(
{
"volume": 0.5,
"fade_in": 1.0,
"fade_out": 2.0,
"sidechain_ratio": 0.3,
}
)
assert ok is True
assert len(errors) == 0
valid, errors = validate_bgm_config({"volume": 0.3})
assert valid is True
assert errors == []
def test_volume_not_number(self):
"""volume 不是数字."""
ok, errors = validate_bgm_config({"volume": "high"})
assert ok is False
def test_invalid_volume_type(self):
valid, errors = validate_bgm_config({"volume": "abc"})
assert valid is False
assert any("volume" in e for e in errors)
def test_volume_out_of_range(self):
"""volume 超出范围."""
ok, errors = validate_bgm_config({"volume": 1.5})
assert ok is False
valid, errors = validate_bgm_config({"volume": -0.1})
assert valid is False
assert any("volume" in e for e in errors)
valid, errors = validate_bgm_config({"volume": 1.1})
assert valid is False
assert any("volume" in e for e in errors)
def test_fade_in_negative(self):
"""fade_in 为负."""
ok, errors = validate_bgm_config({"fade_in": -1})
assert ok is False
def test_volume_at_boundaries(self):
assert validate_bgm_config({"volume": 0})[0] is True
assert validate_bgm_config({"volume": 1})[0] is True
def test_invalid_fade_in_type(self):
valid, errors = validate_bgm_config({"fade_in": "abc"})
assert valid is False
assert any("fade_in" in e for e in errors)
def test_fade_out_negative(self):
"""fade_out 为负."""
ok, errors = validate_bgm_config({"fade_out": -1})
assert ok is False
def test_negative_fade_in(self):
valid, errors = validate_bgm_config({"fade_in": -1})
assert valid is False
assert any("fade_in" in e for e in errors)
def test_invalid_fade_out_type(self):
valid, errors = validate_bgm_config({"fade_out": "abc"})
assert valid is False
assert any("fade_out" in e for e in errors)
def test_negative_fade_out(self):
valid, errors = validate_bgm_config({"fade_out": -1})
assert valid is False
assert any("fade_out" in e for e in errors)
def test_invalid_sidechain_ratio_type(self):
valid, errors = validate_bgm_config({"sidechain_ratio": "abc"})
assert valid is False
assert any("sidechain_ratio" in e for e in errors)
def test_sidechain_ratio_out_of_range(self):
"""sidechain_ratio 超出范围."""
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
assert ok is False
valid, errors = validate_bgm_config({"sidechain_ratio": -0.1})
assert valid is False
assert any("sidechain_ratio" in e for e in errors)
valid, errors = validate_bgm_config({"sidechain_ratio": 1.1})
assert valid is False
assert any("sidechain_ratio" in e for e in errors)
def test_multiple_errors(self):
"""多个错误同时报告."""
ok, errors = validate_bgm_config(
valid, errors = validate_bgm_config(
{
"volume": 2.0,
"volume": "bad",
"fade_in": -1,
"sidechain_ratio": -0.5,
"sidechain_ratio": 2.0,
}
)
assert ok is False
assert valid is False
assert len(errors) >= 3
def test_empty_config_valid(self):
"""空配置(全用默认值)视为合法."""
ok, errors = validate_bgm_config({})
assert ok is True
assert len(errors) == 0
# ─────────────────────────────────────────────────────────────────────────────
# calculate_fade_out_start 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── calculate_fade_out_start ────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
assert calculate_fade_out_start(60, 2) == 58.0
def test_zero_fade_out(self):
"""淡出时长为 0,返回 None."""
assert calculate_fade_out_start(100, 0) is None
assert calculate_fade_out_start(60, 0) is None
def test_negative_fade_out(self):
"""淡出时长为负,返回 None."""
assert calculate_fade_out_start(100, -1) is None
def test_zero_duration(self):
"""总时长为 0,返回 None."""
assert calculate_fade_out_start(0, 3) is None
def test_fade_out_longer_than_duration(self):
"""淡出超过总时长,返回 None."""
assert calculate_fade_out_start(10, 20) is None
def test_fade_out_equal_to_duration(self):
"""淡出等于总时长,返回 None."""
assert calculate_fade_out_start(10, 10) is None
# ─────────────────────────────────────────────────────────────────────────────
# estimate_bgm_processing_duration 测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateBGMProcessingDuration:
"""BGM 处理时长估算测试."""
def test_normal_case_with_loop(self):
"""正常循环情况,输出目标时长."""
assert estimate_bgm_processing_duration(10, 100, True) == 100
def test_bgm_longer_no_loop(self):
"""BGM 够长,不循环,截断到目标时长."""
assert estimate_bgm_processing_duration(200, 100, False) == 100
def test_bgm_shorter_no_loop(self):
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
assert estimate_bgm_processing_duration(10, 100, False) == 100
assert calculate_fade_out_start(60, -1) is None
def test_zero_target(self):
"""目标时长为 0,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
assert calculate_fade_out_start(0, 2) is None
def test_negative_target(self):
"""目标时长为负,兜底 5 秒."""
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
assert calculate_fade_out_start(-5, 2) is None
def test_fade_longer_than_target(self):
assert calculate_fade_out_start(10, 20) is None
def test_fade_equal_to_target(self):
assert calculate_fade_out_start(10, 10) is None
def test_float_values(self):
assert calculate_fade_out_start(60.5, 2.5) == 58.0
# ─────────────────────────────────────────────────────────────────────────────
# BGMPureConfig 测试
# ─────────────────────────────────────────────────────────────────────────────
# ── estimate_bgm_processing_duration ────────────────────────────────────────
class TestBGMPureConfig:
"""BGMPureConfig 数据类测试."""
class TestEstimateBgmProcessingDuration:
def test_bgm_longer_no_loop(self):
assert estimate_bgm_processing_duration(100, 60, loop_enabled=False) == 60.0
def test_default_values(self):
"""默认值正确."""
config = BGMPureConfig()
assert config.volume == 0.3
assert config.fade_in == 0.0
assert config.fade_out == 0.0
assert config.loop_enabled is True
assert config.sidechain_enabled is False
assert config.sidechain_ratio == 0.3
assert config.sidechain_attack == 0.02
assert config.sidechain_release == 0.5
assert config.sidechain_threshold == -25.0
def test_bgm_longer_with_loop(self):
# 够长但允许循环,仍然截断到target
assert estimate_bgm_processing_duration(100, 60, loop_enabled=True) == 60.0
def test_custom_values(self):
"""自定义值."""
config = BGMPureConfig(
volume=0.7,
fade_in=1.0,
fade_out=2.0,
loop_enabled=False,
sidechain_enabled=True,
sidechain_ratio=0.5,
sidechain_attack=0.05,
sidechain_release=0.8,
sidechain_threshold=-30.0,
)
assert config.volume == 0.7
assert config.loop_enabled is False
assert config.sidechain_enabled is True
assert config.sidechain_threshold == -30.0
def test_bgm_shorter_with_loop(self):
assert estimate_bgm_processing_duration(10, 60, loop_enabled=True) == 60.0
def test_bgm_shorter_no_loop(self):
# 需要循环但不允许 → 截断到target
assert estimate_bgm_processing_duration(10, 60, loop_enabled=False) == 60.0
def test_zero_target_fallback(self):
assert estimate_bgm_processing_duration(100, 0) == 5.0
def test_negative_target_fallback(self):
assert estimate_bgm_processing_duration(100, -5) == 5.0
def test_equal_duration(self):
assert estimate_bgm_processing_duration(60, 60) == 60.0
+347 -302
View File
@@ -1,12 +1,12 @@
"""视频拼接引擎纯逻辑单元测试."""
"""concat_engine_pure 单元测试."""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.concat_engine_pure import (
from apps.worker.video_processing.concat_engine_pure import (
build_concat_filter,
build_fps_filter,
build_scale_pad_filter,
build_setpts_filter,
build_single_segment_filter_chain,
calculate_scaled_size,
can_use_stream_copy,
@@ -20,200 +20,203 @@ from video_processing.concat_engine_pure import (
validate_video_path,
)
# ─────────────────────────────────────────────────────────────────────────────
# 帧率解析测试
# ─────────────────────────────────────────────────────────────────────────────
# ── parse_fps ────────────────────────────────────────────────────────────────
class TestParseFps:
"""parse_fps 测试."""
def test_integer_fps(self):
"""整数帧率."""
assert parse_fps(30) == 30.0
def test_float_fps(self):
"""浮点帧率."""
assert parse_fps(29.97) == pytest.approx(29.97)
def test_string_integer(self):
"""字符串整数."""
assert parse_fps("30") == 30.0
def test_string_fraction(self):
"""分数字符串(30/1."""
assert parse_fps("30/1") == 30.0
def test_fraction_24000_1001(self):
"""23.976 帧率."""
result = parse_fps("24000/1001")
assert result == pytest.approx(23.976, rel=0.01)
def test_none_input(self):
"""None 输入返回默认值."""
def test_none_returns_default(self):
assert parse_fps(None) == 30.0
def test_empty_string(self):
"""空字符串返回默认值."""
assert parse_fps("") == 30.0
def test_integer_value(self):
assert parse_fps(30) == 30.0
assert parse_fps(24) == 24.0
def test_invalid_string(self):
"""无效字符串."""
assert parse_fps("abc") == 30.0
def test_float_value(self):
assert parse_fps(29.97) == 29.97
def test_string_integer(self):
assert parse_fps("30") == 30.0
assert parse_fps(" 60 ") == 60.0 # 带空格
def test_string_fraction(self):
assert parse_fps("30/1") == 30.0
assert abs(parse_fps("24000/1001") - 23.976) < 0.01
def test_zero_denominator(self):
"""分母为 0."""
assert parse_fps("30/0") == 30.0
def test_empty_string(self):
assert parse_fps("") == 30.0
assert parse_fps(" ") == 30.0
def test_invalid_string(self):
assert parse_fps("abc") == 30.0
assert parse_fps("30fps") == 30.0
def test_negative_fps(self):
"""负帧率."""
assert parse_fps(-30) == -30.0
def test_zero_fps(self):
assert parse_fps(0) == 0.0
# ── format_fps_filter ───────────────────────────────────────────────────────
class TestFormatFpsFilter:
"""format_fps_filter 测试."""
def test_integer_fps(self):
"""整数帧率."""
assert format_fps_filter(30.0) == "fps=30"
def test_float_fps(self):
"""浮点帧率."""
def test_near_integer_fps(self):
# 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入)
assert format_fps_filter(30.0001) == "fps=30"
assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30
def test_non_integer_fps(self):
result = format_fps_filter(23.976)
assert result.startswith("fps=")
assert "23.976" in result
def test_float_precision(self):
result = format_fps_filter(29.97)
assert result.startswith("fps=")
assert "29.97" in result
# 三位小数
parts = result.split("=")[1]
assert len(parts.split(".")[1]) == 3
def test_near_integer(self):
"""接近整数."""
assert format_fps_filter(30.0001) == "fps=30"
def test_one_fps(self):
assert format_fps_filter(1.0) == "fps=1"
# ─────────────────────────────────────────────────────────────────────────────
# 输出参数计算测试
# ─────────────────────────────────────────────────────────────────────────────
# ── resolve_output_params ───────────────────────────────────────────────────
class TestResolveOutputParams:
"""resolve_output_params 测试."""
def test_all_specified(self):
"""全部显式指定."""
def test_config_specified(self):
w, h, fps = resolve_output_params(1920, 1080, 60.0)
assert w == 1920
assert h == 1080
assert fps == 60.0
def test_no_specified_use_defaults(self):
"""全部未指定,用默认值."""
w, h, fps = resolve_output_params(0, 0, 0)
assert w == 1080
assert h == 1920
assert fps == 30.0
def test_use_first_video_info(self):
"""用第一段视频信息."""
def test_fallback_to_first_video_info(self):
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(0, 0, 0, info)
assert w == 1280
assert h == 720
assert fps == 24.0
def test_partial_specified(self):
"""部分指定,未指定的用探测值."""
def test_fallback_to_defaults(self):
w, h, fps = resolve_output_params(0, 0, 0)
assert w == 1080 # default_width
assert h == 1920 # default_height
assert fps == 30.0
def test_partial_config(self):
# 宽度配置了,高度和帧率用探测的
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
w, h, fps = resolve_output_params(1920, 0, 0, info)
assert w == 1920 # 指定的
assert h == 720 # 探测的
assert w == 1920
assert h == 720
assert fps == 24.0
def test_zero_size_clamped(self):
"""零尺寸被钳制."""
w, h, fps = resolve_output_params(0, 0, 0, {})
assert w >= 1
assert h >= 1
assert fps >= 1.0
def test_custom_defaults(self):
"""自定义默认值."""
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
w, h, fps = resolve_output_params(
0,
0,
0,
default_width=640,
default_height=480,
default_fps=25.0,
)
assert w == 640
assert h == 480
assert fps == 25.0
def test_minimum_size(self):
w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"})
assert w >= 1
assert h >= 1
assert fps >= 1.0
def test_fps_fraction_in_info(self):
info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}
_, _, fps = resolve_output_params(0, 0, 0, info)
assert abs(fps - 23.976) < 0.01
# ── calculate_scaled_size ───────────────────────────────────────────────────
class TestCalculateScaledSize:
"""calculate_scaled_size 测试."""
def test_same_ratio(self):
"""比例相同."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_wider_source(self):
"""源更宽,上下填黑边."""
def test_wider_source_pad_top_bottom(self):
# 源是16:9,目标是9:16竖屏 → 上下填黑边
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
assert sw == 1080 # 以宽度为准
assert sh < 1920 # 高度按比例
assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607
assert ox == 0
assert oy > 0 # 垂直居中
def test_taller_source(self):
"""源更高,左右填黑边."""
def test_taller_source_pad_left_right(self):
# 源是9:16竖屏,目标是16:9横屏 → 左右填黑边
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
assert sh == 1080 # 以高度为准
assert sw < 1920 # 宽度按比例
assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607
assert ox > 0 # 水平居中
assert oy == 0
def test_zero_source(self):
"""零尺寸源."""
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
assert sw == 100
assert sh == 100
def test_scale_down(self):
"""缩小."""
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
assert sw == 640
assert sh == 360
def test_zero_source_size(self):
sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_scale_up(self):
"""放大."""
def test_negative_source_size(self):
sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
def test_target_same_ratio_different_size(self):
# 比例相同,尺寸不同 → 直接缩放到目标大小
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
assert sw == 1920
assert sh == 1080
assert ox == 0
assert oy == 0
# ─────────────────────────────────────────────────────────────────────────────
# stream copy 判断测试
# ─────────────────────────────────────────────────────────────────────────────
# ── can_use_stream_copy ─────────────────────────────────────────────────────
class TestCanUseStreamCopy:
"""can_use_stream_copy 测试."""
def test_force_reencode_false(self):
assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False
def test_identical_segments(self):
"""所有段参数相同,可以 stream copy."""
def test_empty_segments(self):
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
def test_single_segment_matching_params(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
def test_multiple_segments_same_params(self):
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
def test_force_reencode(self):
"""强制重编码."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
def test_different_codec(self):
"""编码不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
@@ -221,7 +224,6 @@ class TestCanUseStreamCopy:
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_resolution(self):
"""分辨率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
@@ -229,306 +231,349 @@ class TestCanUseStreamCopy:
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_different_fps(self):
"""帧率不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
def test_target_differs(self):
"""目标参数与源不同."""
segs = [
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
]
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
def test_empty_segments(self):
"""空列表."""
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
def test_single_segment(self):
"""单段."""
def test_target_differs_from_source(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
# 目标分辨率不同
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
# 目标帧率不同
assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False
def test_fps_fraction_match(self):
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}]
assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True
# ─────────────────────────────────────────────────────────────────────────────
# 文件列表生成测试
# ─────────────────────────────────────────────────────────────────────────────
# ── generate_concat_file_list ───────────────────────────────────────────────
class TestGenerateConcatFileList:
"""generate_concat_file_list 测试."""
def test_single_file(self):
"""单个文件."""
result = generate_concat_file_list(["/a.mp4"])
assert "file '/a.mp4'" in result
assert result.endswith("\n")
result = generate_concat_file_list(["/tmp/video.mp4"])
assert result == "file '/tmp/video.mp4'\n"
def test_multiple_files(self):
"""多个文件."""
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
lines = result.strip().split("\n")
assert len(lines) == 3
assert lines[0] == "file '/a.mp4'"
assert lines[1] == "file '/b.mp4'"
assert lines[2] == "file '/c.mp4'"
assert result.endswith("\n")
def test_escapes_single_quotes(self):
result = generate_concat_file_list(["/path/with'quote.mp4"])
# 单引号转义: '\''
assert "'\\''" in result
def test_empty_list(self):
"""空列表."""
result = generate_concat_file_list([])
assert result == "\n"
def test_path_with_single_quote(self):
"""路径包含单引号(转义)."""
result = generate_concat_file_list(["/path/to/file's.mp4"])
# 单引号应该被转义
assert "'\\''" in result or file
assert "file '" in result
def test_path_with_spaces(self):
"""路径包含空格."""
result = generate_concat_file_list(["/path/to/my video.mp4"])
assert "my video" in result
result = generate_concat_file_list(["/path/to/video file.mp4"])
assert "file '/path/to/video file.mp4'" in result
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜构建测试
# ─────────────────────────────────────────────────────────────────────────────
# ── build_scale_pad_filter ──────────────────────────────────────────────────
class TestBuildScalePadFilter:
"""scale+pad 滤镜测试."""
def test_contains_scale(self):
"""包含 scale."""
result = build_scale_pad_filter(1920, 1080)
assert "scale=" in result
def test_contains_pad(self):
"""包含 pad."""
result = build_scale_pad_filter(1920, 1080)
assert "pad=" in result
assert "1920:1080" in result
def test_force_original_aspect_ratio(self):
"""保持宽高比."""
def test_basic_filter(self):
result = build_scale_pad_filter(1920, 1080)
assert "scale=1920:1080" in result
assert "force_original_aspect_ratio=decrease" in result
assert "pad=1920:1080" in result
assert "black" in result
assert "(ow-iw)/2" in result
assert "(oh-ih)/2" in result
def test_black_padding(self):
"""黑边填充."""
result = build_scale_pad_filter(1920, 1080)
assert ":black" in result
def test_different_resolution(self):
result = build_scale_pad_filter(1080, 1920)
assert "scale=1080:1920" in result
assert "pad=1080:1920" in result
def test_ignores_source_size(self):
# src_w/src_h 目前不影响输出,都是用表达式
result1 = build_scale_pad_filter(1920, 1080)
result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720)
assert result1 == result2
# ── build_fps_filter ────────────────────────────────────────────────────────
class TestBuildFpsFilter:
"""fps 滤镜测试."""
def test_integer_fps(self):
"""整数帧率."""
assert build_fps_filter(30.0) == "fps=30"
def test_float_fps(self):
"""浮点帧率."""
result = build_fps_filter(29.97)
assert result.startswith("fps=")
class TestBuildConcatFilter:
"""concat 滤镜测试."""
# ── build_setpts_filter ─────────────────────────────────────────────────────
def test_two_inputs_with_audio(self):
"""两路输入,有音频."""
result = build_concat_filter(2, has_audio=True)
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
class TestBuildSetptsFilter:
def test_returns_correct_string(self):
assert build_setpts_filter() == "setpts=PTS-STARTPTS"
# ── build_concat_filter ─────────────────────────────────────────────────────
class TestBuildConcatFilter:
def test_zero_inputs(self):
assert build_concat_filter(0) == ""
def test_single_input_with_audio(self):
result = build_concat_filter(1)
assert "[0:v][0:a]" in result
assert "concat=n=1:v=1:a=1" in result
assert "[concat_v][concat_a]" in result
def test_three_inputs_video_only(self):
"""三路输入,无音频."""
result = build_concat_filter(3, has_audio=False)
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
def test_single_input_no_audio(self):
result = build_concat_filter(1, has_audio=False)
assert "[0:v]" in result
assert "concat=n=1:v=1:a=0" in result
assert "[concat_v]" in result
assert "[concat_a]" not in result
def test_single_input(self):
"""单路输入."""
result = build_concat_filter(1, has_audio=True)
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
def test_multiple_inputs_with_audio(self):
result = build_concat_filter(3)
assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result
assert "concat=n=3:v=1:a=1" in result
def test_zero_inputs(self):
"""零输入."""
assert build_concat_filter(0) == ""
def test_multiple_inputs_no_audio(self):
result = build_concat_filter(3, has_audio=False)
assert "[0:v][1:v][2:v]" in result
assert "concat=n=3:v=1:a=0" in result
def test_negative_inputs(self):
assert build_concat_filter(-1) == ""
# ── build_single_segment_filter_chain ───────────────────────────────────────
class TestBuildSingleSegmentFilterChain:
"""单段滤镜链测试."""
def test_with_audio(self):
"""有音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
assert "scale=" in result
assert "fps=" in result
assert "setpts=PTS-STARTPTS" in result
assert "asetpts=PTS-STARTPTS" in result
# 视频链
assert "[0:v]" in result
assert "[v0]" in result
assert "scale=1920:1080" in result
assert "fps=30" in result
assert "setpts=PTS-STARTPTS" in result
# 音频链
assert "[0:a]" in result
assert "[a0]" in result
assert "asetpts=PTS-STARTPTS" in result
# 用分号分隔
assert ";" in result
def test_video_only(self):
"""无音频."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
assert "scale=" in result
assert "setpts=" in result
assert "asetpts" not in result
assert "[v1]" in result
def test_without_audio(self):
result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False)
assert "[2:v]" in result
assert "[v2]" in result
assert "[2:a]" not in result
assert ";" not in result # 没有音频就没有分号
def test_segment_index_in_labels(self):
"""段索引在标签中."""
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
assert "[5:v]" in result
assert "[v5]" in result
def test_segment_index_propagated(self):
for idx in [0, 5, 10]:
result = build_single_segment_filter_chain(1920, 1080, 30.0, idx)
assert f"[{idx}:v]" in result
assert f"[v{idx}]" in result
# ─────────────────────────────────────────────────────────────────────────────
# 配置验证测试
# ─────────────────────────────────────────────────────────────────────────────
# ── validate_concat_config ──────────────────────────────────────────────────
class TestValidateConcatConfig:
"""配置验证测试."""
def test_valid_config(self):
"""合法配置."""
config = {
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
"segments": [
{"video_path": "/a.mp4"},
{"video_path": "/b.mp4"},
],
"output_width": 1920,
"output_height": 1080,
"output_fps": 30,
}
ok, errors = validate_concat_config(config)
assert ok is True
assert len(errors) == 0
valid, errors = validate_concat_config(config)
assert valid is True
assert errors == []
def test_no_segments(self):
valid, errors = validate_concat_config({})
assert valid is False
assert any("至少需要一个" in e for e in errors)
def test_empty_segments(self):
"""空段列表."""
ok, errors = validate_concat_config({"segments": []})
assert ok is False
assert any("至少需要" in e or "视频段" in e for e in errors)
valid, errors = validate_concat_config({"segments": []})
assert valid is False
assert len(errors) >= 1
def test_missing_video_path(self):
"""缺少 video_path."""
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
ok, errors = validate_concat_config(config)
assert ok is False
config = {"segments": [{"video_path": ""}]}
valid, errors = validate_concat_config(config)
assert valid is False
assert any("video_path" in e for e in errors)
def test_negative_width(self):
"""负宽度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
ok, errors = validate_concat_config(config)
assert ok is False
def test_multiple_missing_paths(self):
config = {
"segments": [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"video_path": ""},
]
}
valid, errors = validate_concat_config(config)
assert valid is False
path_errors = [e for e in errors if "video_path" in e]
assert len(path_errors) == 2
def test_negative_output_width(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1}
valid, errors = validate_concat_config(config)
assert valid is False
assert any("output_width" in e for e in errors)
def test_negative_height(self):
"""负高度."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
ok, errors = validate_concat_config(config)
assert ok is False
def test_negative_output_height(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1}
valid, errors = validate_concat_config(config)
assert valid is False
assert any("output_height" in e for e in errors)
def test_negative_fps(self):
"""负帧率."""
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
ok, errors = validate_concat_config(config)
assert ok is False
def test_negative_output_fps(self):
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1}
valid, errors = validate_concat_config(config)
assert valid is False
assert any("output_fps" in e for e in errors)
def test_zero_output_params_ok(self):
"""零输出参数合法(表示自动探测)."""
config = {"segments": [{"video_path": "/a.mp4"}]}
ok, errors = validate_concat_config(config)
assert ok is True
def test_zero_output_params_valid(self):
# 0值表示未指定,是合法的
config = {
"segments": [{"video_path": "/a.mp4"}],
"output_width": 0,
"output_height": 0,
"output_fps": 0,
}
valid, errors = validate_concat_config(config)
assert valid is True
# ─────────────────────────────────────────────────────────────────────────────
# 路径验证测试
# ─────────────────────────────────────────────────────────────────────────────
# ── validate_video_path ─────────────────────────────────────────────────────
class TestValidateVideoPath:
"""视频路径验证测试."""
def test_empty_path(self):
"""空路径."""
ok, msg = validate_video_path("", "/work")
assert ok is False
assert "不能为空" in msg
valid, err = validate_video_path("", "/work")
assert valid is False
assert "不能为空" in err
def test_path_traversal(self):
"""路径遍历."""
ok, msg = validate_video_path("../etc/passwd", "/work")
assert ok is False
assert "回溯" in msg or ".." in msg
def test_relative_path_valid(self):
valid, err = validate_video_path("video.mp4", "/work")
assert valid is True
assert err == ""
def test_valid_relative_path(self):
"""相对路径(不检查边界)."""
ok, msg = validate_video_path("video.mp4", "/work")
assert ok is True
def test_relative_path_with_subdir(self):
valid, err = validate_video_path("sub/video.mp4", "/work")
assert valid is True
def test_valid_absolute_path(self):
"""绝对路径在工作目录内."""
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
assert ok is True
def test_path_traversal_rejected(self):
valid, err = validate_video_path("../secret.mp4", "/work")
assert valid is False
assert ".." in err
def test_path_outside_work_dir(self):
"""路径在工作目录外."""
ok, msg = validate_video_path("/etc/passwd", "/work")
assert ok is False
assert "工作目录" in msg
def test_nested_path_traversal_rejected(self):
valid, err = validate_video_path("sub/../../secret.mp4", "/work")
assert valid is False
def test_absolute_path_inside_workdir(self):
valid, err = validate_video_path("/work/sub/video.mp4", "/work")
assert valid is True
def test_absolute_path_outside_workdir(self):
valid, err = validate_video_path("/etc/passwd", "/work")
assert valid is False
assert "工作目录内" in err
def test_path_object_input(self):
valid, err = validate_video_path(Path("video.mp4"), Path("/work"))
assert valid is True
# ─────────────────────────────────────────────────────────────────────────────
# 工具函数测试
# ─────────────────────────────────────────────────────────────────────────────
# ── estimate_total_duration ─────────────────────────────────────────────────
class TestEstimateTotalDuration:
"""总时长估算测试."""
def test_single_segment(self):
assert estimate_total_duration([{"duration": 10.5}]) == 10.5
def test_multiple_segments(self):
"""多段视频."""
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
assert estimate_total_duration(segs) == pytest.approx(35.5)
segs = [
{"duration": 10},
{"duration": 20.5},
{"duration": 5.5},
]
assert estimate_total_duration(segs) == 36.0
def test_empty_list(self):
"""空列表."""
assert estimate_total_duration([]) == 0.0
def test_invalid_duration_skipped(self):
"""无效时长跳过."""
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
assert estimate_total_duration(segs) == pytest.approx(30.0)
def test_missing_duration_field(self):
segs = [{"path": "a.mp4"}, {"duration": 10}]
assert estimate_total_duration(segs) == 10.0
def test_missing_duration(self):
"""缺 duration 字段."""
segs = [{}, {"duration": 10}]
assert estimate_total_duration(segs) == pytest.approx(10.0)
def test_invalid_duration_skipped(self):
segs = [
{"duration": 10},
{"duration": "abc"},
{"duration": 20},
]
assert estimate_total_duration(segs) == 30.0
def test_string_duration(self):
segs = [{"duration": "15.5"}]
assert estimate_total_duration(segs) == 15.5
def test_negative_duration(self):
segs = [{"duration": -5}]
assert estimate_total_duration(segs) == -5.0
# ── count_valid_segments ────────────────────────────────────────────────────
class TestCountValidSegments:
"""有效段统计测试."""
def test_all_valid(self):
"""全部有效."""
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
segs = [
{"video_path": "/a.mp4"},
{"video_path": "/b.mp4"},
]
assert count_valid_segments(segs) == 2
def test_some_invalid(self):
"""部分无效."""
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
assert count_valid_segments(segs) == 1
segs = [
{"video_path": "/a.mp4"},
{"video_path": ""},
{"video_path": "/c.mp4"},
]
assert count_valid_segments(segs) == 2
def test_none_valid(self):
segs = [
{"video_path": ""},
{"other_field": "x"},
]
assert count_valid_segments(segs) == 0
def test_empty_list(self):
"""空列表."""
assert count_valid_segments([]) == 0
+401
View File
@@ -0,0 +1,401 @@
"""InMemoryAssetRepository 单元测试."""
import pytest
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
from packages.domain.entities import Asset, AssetStatus, ClassificationStatus
@pytest.fixture
def repo() -> InMemoryAssetRepository:
return InMemoryAssetRepository()
@pytest.fixture
def sample_asset() -> Asset:
return Asset.create(
project_id="proj-1",
library_id="lib-1",
name="test.mp4",
storage_key="storage/key1",
mime_type="video/mp4",
file_size=1024,
file_hash="hash-abc",
)
@pytest.fixture
def asset2() -> Asset:
return Asset.create(
project_id="proj-1",
library_id="lib-1",
name="test2.jpg",
storage_key="storage/key2",
mime_type="image/jpeg",
file_size=512,
file_hash="hash-def",
)
@pytest.fixture
def asset_other_project() -> Asset:
return Asset.create(
project_id="proj-2",
library_id="lib-2",
name="other.mp3",
storage_key="storage/key3",
mime_type="audio/mpeg",
file_size=256,
file_hash="hash-ghi",
)
class TestCreateAndGet:
def test_create_returns_asset(self, repo, sample_asset):
result = repo.create(sample_asset)
assert result.id == sample_asset.id
assert result.name == "test.mp4"
def test_get_existing_asset(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.get(sample_asset.id)
assert result is not None
assert result.id == sample_asset.id
def test_get_nonexistent_returns_none(self, repo):
assert repo.get("nonexistent") is None
def test_find_by_id_same_as_get(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_id(sample_asset.id).id == repo.get(sample_asset.id).id
class TestListByProject:
def test_list_by_project_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
proj1 = repo.list_by_project("proj-1")
assert len(proj1) == 2
assert all(a.project_id == "proj-1" for a in proj1)
proj2 = repo.list_by_project("proj-2")
assert len(proj2) == 1
assert proj2[0].id == asset_other_project.id
def test_list_by_project_empty(self, repo):
assert repo.list_by_project("nonexistent") == []
class TestListByLibrary:
def test_list_by_library_filters_correctly(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
lib1 = repo.list_by_library("lib-1")
assert len(lib1) == 2
lib2 = repo.list_by_library("lib-2")
assert len(lib2) == 1
assert lib2[0].id == asset_other_project.id
def test_find_by_library_is_alias(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_library("lib-1") == repo.list_by_library("lib-1")
def test_list_by_library_empty(self, repo):
assert repo.list_by_library("nonexistent") == []
class TestFindByLibraryAndFileType:
def test_filter_by_video(self, repo, sample_asset, asset2, asset_other_project):
repo.create(sample_asset)
repo.create(asset2)
repo.create(asset_other_project)
videos = repo.find_by_library_and_file_type("lib-1", "video")
assert len(videos) == 1
assert videos[0].mime_type.startswith("video/")
def test_filter_by_image(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
images = repo.find_by_library_and_file_type("lib-1", "image")
assert len(images) == 1
assert images[0].mime_type.startswith("image/")
def test_filter_by_audio(self, repo, sample_asset, asset_other_project):
repo.create(sample_asset)
repo.create(asset_other_project)
audio = repo.find_by_library_and_file_type("lib-2", "audio")
assert len(audio) == 1
def test_empty_result(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.find_by_library_and_file_type("lib-1", "audio") == []
class TestUpdate:
def test_update_existing_asset(self, repo, sample_asset):
repo.create(sample_asset)
sample_asset.name = "updated.mp4"
sample_asset.file_size = 2048
result = repo.update(sample_asset)
assert result.name == "updated.mp4"
assert result.file_size == 2048
fetched = repo.get(sample_asset.id)
assert fetched.name == "updated.mp4"
def test_update_nonexistent_creates(self, repo, sample_asset):
"""update 直接覆盖,不存在则相当于 create."""
result = repo.update(sample_asset)
assert result.id == sample_asset.id
assert repo.get(sample_asset.id) is not None
class TestDelete:
def test_delete_existing(self, repo, sample_asset):
repo.create(sample_asset)
assert repo.delete(sample_asset.id) is True
assert repo.get(sample_asset.id) is None
def test_delete_nonexistent(self, repo):
assert repo.delete("nonexistent") is False
class TestBatchDelete:
def test_batch_delete_soft_delete(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_delete([sample_asset.id, asset2.id])
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert a1.status == AssetStatus.DELETED
assert a2.status == AssetStatus.DELETED
assert a1.updated_at is not None
assert a2.updated_at is not None
def test_batch_delete_skip_already_deleted(self, repo, sample_asset):
repo.create(sample_asset)
sample_asset.status = AssetStatus.DELETED
repo.update(sample_asset)
count = repo.batch_delete([sample_asset.id])
assert count == 0
def test_batch_delete_nonexistent(self, repo):
count = repo.batch_delete(["nonexistent-1", "nonexistent-2"])
assert count == 0
def test_batch_delete_partial(self, repo, sample_asset):
repo.create(sample_asset)
count = repo.batch_delete([sample_asset.id, "nonexistent"])
assert count == 1
class TestBatchUpdateMetadata:
def test_batch_update_metadata_merge(self, repo, sample_asset, asset2):
sample_asset.metadata = {"key1": "val1"}
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_update_metadata(
[sample_asset.id, asset2.id],
{"key2": "val2"},
)
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert a1.metadata == {"key1": "val1", "key2": "val2"}
assert a2.metadata == {"key2": "val2"}
def test_batch_update_metadata_overwrite_existing_key(self, repo, sample_asset):
sample_asset.metadata = {"key1": "old"}
repo.create(sample_asset)
count = repo.batch_update_metadata([sample_asset.id], {"key1": "new"})
assert count == 1
assert repo.get(sample_asset.id).metadata["key1"] == "new"
def test_batch_update_metadata_nonexistent(self, repo):
count = repo.batch_update_metadata(["nonexistent"], {"key": "val"})
assert count == 0
class TestBatchAddTags:
def test_batch_add_tags_new_tags(self, repo, sample_asset, asset2):
repo.create(sample_asset)
repo.create(asset2)
count = repo.batch_add_tags([sample_asset.id, asset2.id], ["tag1", "tag2"])
assert count == 2
a1 = repo.get(sample_asset.id)
a2 = repo.get(asset2.id)
assert set(a1.tag_ids) == {"tag1", "tag2"}
assert set(a2.tag_ids) == {"tag1", "tag2"}
def test_batch_add_tags_dedup(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
assert count == 1 # tag1已存在,但tag2新增,所以有变化
tags = repo.get(sample_asset.id).tag_ids
assert tags.count("tag1") == 1
assert "tag2" in tags
def test_batch_add_tags_no_change_when_all_exist(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1", "tag2"]
repo.create(sample_asset)
count = repo.batch_add_tags([sample_asset.id], ["tag1", "tag2"])
assert count == 0 # 没有变化
def test_batch_add_tags_nonexistent_assets(self, repo):
count = repo.batch_add_tags(["nonexistent"], ["tag1"])
assert count == 0
class TestBatchReplaceTags:
def test_batch_replace_tags_override(self, repo, sample_asset):
sample_asset.tag_ids = ["old1", "old2"]
repo.create(sample_asset)
count = repo.batch_replace_tags([sample_asset.id], ["new1", "new2", "new3"])
assert count == 1
tags = repo.get(sample_asset.id).tag_ids
assert tags == ["new1", "new2", "new3"]
def test_batch_replace_tags_empty(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
count = repo.batch_replace_tags([sample_asset.id], [])
assert count == 1
assert repo.get(sample_asset.id).tag_ids == []
def test_batch_replace_tags_nonexistent(self, repo):
count = repo.batch_replace_tags(["nonexistent"], ["tag1"])
assert count == 0
class TestFindByProjectPagination:
@pytest.fixture
def five_assets(self, repo):
assets = []
for i in range(5):
a = Asset.create(
project_id="proj-paged",
library_id="lib-paged",
name=f"asset-{i}.mp4",
storage_key=f"key-{i}",
mime_type="video/mp4",
)
repo.create(a)
assets.append(a)
return assets
def test_find_by_project_default_pagination(self, repo, five_assets):
result = repo.find_by_project("proj-paged")
assert len(result) == 5
def test_find_by_project_skip(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=2)
assert len(result) == 3
def test_find_by_project_limit(self, repo, five_assets):
result = repo.find_by_project("proj-paged", limit=2)
assert len(result) == 2
def test_find_by_project_skip_and_limit(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=1, limit=2)
assert len(result) == 2
def test_find_by_project_skip_past_end(self, repo, five_assets):
result = repo.find_by_project("proj-paged", skip=10)
assert result == []
def test_find_by_project_empty(self, repo):
assert repo.find_by_project("nonexistent") == []
class TestFindByTagIds:
def test_find_by_tag_ids_match_all(self, repo, sample_asset, asset2):
sample_asset.tag_ids = ["tag1", "tag2", "tag3"]
asset2.tag_ids = ["tag1", "tag2"]
repo.create(sample_asset)
repo.create(asset2)
result = repo.find_by_tag_ids(["tag1", "tag2"])
assert len(result) == 2
def test_find_by_tag_ids_subset_match(self, repo, sample_asset, asset2):
sample_asset.tag_ids = ["tag1", "tag2"]
asset2.tag_ids = ["tag1"]
repo.create(sample_asset)
repo.create(asset2)
result = repo.find_by_tag_ids(["tag1", "tag2"])
assert len(result) == 1
assert result[0].id == sample_asset.id
def test_find_by_tag_ids_empty_tag_list(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
assert repo.find_by_tag_ids([]) == []
def test_find_by_tag_ids_no_match(self, repo, sample_asset):
sample_asset.tag_ids = ["tag1"]
repo.create(sample_asset)
assert repo.find_by_tag_ids(["tag999"]) == []
def test_find_by_tag_ids_pagination(self, repo):
for i in range(5):
a = Asset.create(
project_id="p1",
library_id="l1",
name=f"a{i}.mp4",
storage_key=f"k{i}",
mime_type="video/mp4",
)
a.tag_ids = ["shared-tag"]
repo.create(a)
result = repo.find_by_tag_ids(["shared-tag"], skip=1, limit=2)
assert len(result) == 2
class TestFindByLibraryAndFileHash:
def test_find_by_hash_match(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "hash-abc")
assert result is not None
assert result.id == sample_asset.id
def test_find_by_hash_wrong_library(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-2", "hash-abc")
assert result is None
def test_find_by_hash_wrong_hash(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "hash-wrong")
assert result is None
def test_find_by_hash_empty_hash(self, repo, sample_asset):
repo.create(sample_asset)
result = repo.find_by_library_and_file_hash("lib-1", "")
assert result is None
+191
View File
@@ -0,0 +1,191 @@
"""InMemoryUserRepository 单元测试."""
from datetime import datetime, timezone
import pytest
from packages.adapters.in_memory.user_repository import InMemoryUserRepository
from packages.domain.entities import User
@pytest.fixture
def repo() -> InMemoryUserRepository:
return InMemoryUserRepository()
@pytest.fixture
def sample_user() -> User:
return User(
id="user-1",
email="Test@Example.com",
display_name="Test User",
username="testuser",
password_hash="hashed-pw",
email_verification_token="verify-token-123",
password_reset_token="reset-token-456",
wechat_openid="wx-openid-abc",
wechat_unionid="wx-unionid-def",
phone="13800138000",
created_at=datetime.now(timezone.utc),
)
class TestSaveAndFindById:
def test_save_and_find_by_id(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_id("user-1")
assert found is not None
assert found.id == "user-1"
assert found.email == "Test@Example.com"
def test_find_by_id_not_found(self, repo):
assert repo.find_by_id("nonexistent") is None
def test_save_overwrite_existing(self, repo, sample_user):
repo.save(sample_user)
sample_user.display_name = "Updated Name"
repo.save(sample_user)
found = repo.find_by_id("user-1")
assert found.display_name == "Updated Name"
class TestFindByEmail:
def test_find_by_email_case_insensitive(self, repo, sample_user):
repo.save(sample_user)
# 用不同大小写查找
found = repo.find_by_email("test@example.com")
assert found is not None
assert found.id == "user-1"
def test_find_by_email_exact_case(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_email("Test@Example.com")
assert found is not None
def test_find_by_email_not_found(self, repo):
assert repo.find_by_email("notfound@example.com") is None
class TestFindByUsername:
def test_find_by_username_case_insensitive(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_username("TESTUSER")
assert found is not None
assert found.id == "user-1"
def test_find_by_username_not_found(self, repo):
assert repo.find_by_username("nobody") is None
def test_find_by_username_empty(self, repo, sample_user):
sample_user.username = ""
repo.save(sample_user)
# 空 username 不应该建立索引,但查找空字符串应该返回None
found = repo.find_by_username("")
assert found is None
class TestFindByVerificationToken:
def test_find_by_verification_token(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_verification_token("verify-token-123")
assert found is not None
assert found.id == "user-1"
def test_find_by_verification_token_not_found(self, repo):
assert repo.find_by_verification_token("bad-token") is None
class TestFindByPasswordResetToken:
def test_find_by_password_reset_token(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_password_reset_token("reset-token-456")
assert found is not None
assert found.id == "user-1"
def test_find_by_password_reset_token_not_found(self, repo):
assert repo.find_by_password_reset_token("bad-token") is None
class TestFindByWechat:
def test_find_by_wechat_openid(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_wechat_openid("wx-openid-abc")
assert found is not None
assert found.id == "user-1"
def test_find_by_wechat_openid_not_found(self, repo):
assert repo.find_by_wechat_openid("bad-openid") is None
def test_find_by_wechat_unionid(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_wechat_unionid("wx-unionid-def")
assert found is not None
assert found.id == "user-1"
def test_find_by_wechat_unionid_not_found(self, repo):
assert repo.find_by_wechat_unionid("bad-unionid") is None
def test_find_by_wechat_unionid_empty(self, repo, sample_user):
sample_user.wechat_unionid = None
repo.save(sample_user)
assert repo.find_by_wechat_unionid("") is None
class TestFindByPhone:
def test_find_by_phone(self, repo, sample_user):
repo.save(sample_user)
found = repo.find_by_phone("13800138000")
assert found is not None
assert found.id == "user-1"
def test_find_by_phone_not_found(self, repo):
assert repo.find_by_phone("13900139000") is None
def test_find_by_phone_empty(self, repo, sample_user):
sample_user.phone = None
repo.save(sample_user)
assert repo.find_by_phone("") is None
class TestDelete:
def test_delete_existing_user(self, repo, sample_user):
repo.save(sample_user)
assert repo.delete("user-1") is True
assert repo.find_by_id("user-1") is None
def test_delete_cleans_all_indexes(self, repo, sample_user):
repo.save(sample_user)
repo.delete("user-1")
assert repo.find_by_email("test@example.com") is None
assert repo.find_by_username("testuser") is None
assert repo.find_by_verification_token("verify-token-123") is None
assert repo.find_by_password_reset_token("reset-token-456") is None
def test_delete_nonexistent_user(self, repo):
assert repo.delete("nonexistent") is False
def test_delete_twice_returns_false(self, repo, sample_user):
repo.save(sample_user)
assert repo.delete("user-1") is True
assert repo.delete("user-1") is False
class TestIndexUpdates:
def test_save_new_user_with_same_email_overwrites_index(self, repo, sample_user):
"""不同用户同邮箱,后者覆盖索引."""
repo.save(sample_user)
user2 = User(
id="user-2",
email="test@example.com", # 同邮箱不同大小写
display_name="User 2",
username="user2",
)
repo.save(user2)
# 邮箱索引指向最后保存的用户
found = repo.find_by_email("test@example.com")
assert found.id == "user-2"
# 原用户仍然可通过ID找到
assert repo.find_by_id("user-1") is not None
File diff suppressed because it is too large Load Diff
+79 -288
View File
@@ -1,9 +1,6 @@
"""通用分页器单元测试."""
from __future__ import annotations
"""pagination 单元测试."""
import pytest
from pydantic import ValidationError
from packages.application.common.pagination import (
PaginatedResponse,
@@ -12,377 +9,171 @@ from packages.application.common.pagination import (
paginate,
)
# ── PaginationParams ────────────────────────────────────────────────────────
class TestPaginationParams:
"""PaginationParams 测试"""
def test_default_values(self):
"""默认值正确"""
params = PaginationParams()
assert params.page == 1
assert params.page_size == 20
def test_offset_first_page(self):
"""第一页 offset 为 0"""
def test_custom_values(self):
params = PaginationParams(page=3, page_size=50)
assert params.page == 3
assert params.page_size == 50
def test_offset_calculation(self):
params = PaginationParams(page=1, page_size=20)
assert params.offset == 0
def test_offset_second_page(self):
"""第二页 offset 计算正确"""
params = PaginationParams(page=2, page_size=20)
assert params.offset == 20
params = PaginationParams(page=3, page_size=20)
assert params.offset == 40
def test_offset_custom_page_size(self):
"""自定义 page_size 的 offset"""
params = PaginationParams(page=3, page_size=10)
assert params.offset == 20
params = PaginationParams(page=10, page_size=50)
assert params.offset == 450
def test_limit_equals_page_size(self):
"""limit 等于 page_size"""
params = PaginationParams(page_size=50)
assert params.limit == 50
params = PaginationParams(page_size=30)
assert params.limit == 30
def test_page_must_be_at_least_1(self):
"""page 不能小于 1"""
with pytest.raises(ValidationError):
with pytest.raises(ValueError):
PaginationParams(page=0)
def test_page_negative_raises(self):
"""page 不能为负数"""
with pytest.raises(ValidationError):
PaginationParams(page=-1)
def test_page_size_must_be_at_least_1(self):
"""page_size 不能小于 1"""
with pytest.raises(ValidationError):
with pytest.raises(ValueError):
PaginationParams(page_size=0)
def test_page_size_max_100(self):
"""page_size 最大 100"""
with pytest.raises(ValidationError):
with pytest.raises(ValueError):
PaginationParams(page_size=101)
def test_page_size_100_is_valid(self):
"""page_size=100 是合法的"""
params = PaginationParams(page_size=100)
assert params.page_size == 100
# ── PaginationMeta ──────────────────────────────────────────────────────────
class TestPaginationMeta:
"""PaginationMeta 测试"""
def test_from_params_first_page(self):
"""第一页元数据"""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=25)
assert meta.page == 1
assert meta.page_size == 10
assert meta.total == 25
assert meta.total_pages == 3
assert meta.total_pages == 3 # ceil(25/10)
assert meta.has_next is True
assert meta.has_prev is False
def test_from_params_last_page(self):
"""最后一页元数据"""
params = PaginationParams(page=3, page_size=10)
meta = PaginationMeta.from_params(params, total=25)
assert meta.page == 3
assert meta.total_pages == 3
assert meta.has_next is False
assert meta.has_prev is True
def test_from_params_middle_page(self):
"""中间页元数据"""
params = PaginationParams(page=2, page_size=10)
meta = PaginationMeta.from_params(params, total=50)
assert meta.page == 2
assert meta.total_pages == 5
meta = PaginationMeta.from_params(params, total=25)
assert meta.has_next is True
assert meta.has_prev is True
def test_from_params_single_page(self):
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=5)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
def test_from_params_zero_total(self):
"""总数为 0 时"""
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total == 0
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is False
def test_from_params_exact_multiple(self):
"""总数刚好是 page_size 的整数倍"""
def test_from_params_exact_page_size(self):
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=30)
assert meta.total_pages == 3
def test_from_params_single_page(self):
"""单页即可放下所有数据"""
params = PaginationParams(page=1, page_size=100)
meta = PaginationMeta.from_params(params, total=50)
meta = PaginationMeta.from_params(params, total=10)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
def test_from_params_one_extra(self):
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=11)
assert meta.total_pages == 2
# ── PaginatedResponse ───────────────────────────────────────────────────────
class TestPaginatedResponse:
"""PaginatedResponse 测试"""
def test_create_success(self):
"""创建分页响应"""
params = PaginationParams(page=1, page_size=10)
data = [1, 2, 3]
response = PaginatedResponse.create(data, params, total=25)
assert response.data == [1, 2, 3]
def test_create_response(self):
params = PaginationParams(page=1, page_size=5)
data = [1, 2, 3, 4, 5]
response = PaginatedResponse.create(data, params, total=15)
assert response.data == data
assert response.pagination.page == 1
assert response.pagination.total == 25
assert response.pagination.total == 15
assert response.pagination.total_pages == 3
def test_create_empty_data(self):
"""空数据分页响应"""
params = PaginationParams(page=1, page_size=20)
response = PaginatedResponse.create([], params, total=0)
assert response.data == []
assert response.pagination.total == 0
assert response.pagination.total_pages == 0
# ── paginate function ───────────────────────────────────────────────────────
class TestPaginateFunction:
"""paginate 函数测试(内存分页)"""
class TestPaginate:
def test_first_page(self):
"""第一页分页"""
items = list(range(30))
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == list(range(10))
assert result.pagination.total == 30
assert result.pagination.total_pages == 3
assert result.pagination.has_next is True
assert result.pagination.has_prev is False
def test_second_page(self):
"""第二页分页"""
items = list(range(30))
params = PaginationParams(page=2, page_size=10)
result = paginate(items, params)
assert result.data == list(range(10, 20))
assert result.pagination.page == 2
def test_last_page(self):
"""最后一页分页"""
items = list(range(25))
params = PaginationParams(page=3, page_size=10)
result = paginate(items, params)
assert result.data == list(range(20, 25))
assert len(result.data) == 5
assert result.pagination.has_next is False
assert result.pagination.has_prev is True
def test_empty_list(self):
"""空列表分页"""
params = PaginationParams(page=1, page_size=20)
result = paginate([], params)
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_beyond_total(self):
"""页码超出总数"""
def test_single_page(self):
items = list(range(5))
params = PaginationParams(page=10, page_size=10)
result = paginate(items, params)
assert result.data == []
assert result.pagination.total == 5
assert result.pagination.total_pages == 1
def test_custom_page_size(self):
"""自定义每页数量"""
items = list(range(100))
params = PaginationParams(page=1, page_size=50)
result = paginate(items, params)
assert len(result.data) == 50
assert result.pagination.total_pages == 2
def test_single_item(self):
"""单条数据"""
items = ["only_one"]
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == ["only_one"]
assert result.pagination.total == 1
assert result.pagination.total_pages == 1
def test_generic_type_preserved(self):
"""泛型类型数据正确"""
items = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert len(result.data) == 2
assert result.data[0]["id"] == 1
# ── PaginationParams 补充边界 ───────────────────────────────────────────────
class TestPaginationParamsEdgeCases:
"""PaginationParams 补充边界场景."""
def test_page_size_1_minimum(self):
"""page_size=1 是允许的最小值."""
params = PaginationParams(page_size=1)
assert params.page_size == 1
assert params.limit == 1
def test_page_size_100_maximum(self):
"""page_size=100 是允许的最大值."""
params = PaginationParams(page_size=100)
assert params.page_size == 100
def test_offset_page_1_size_100(self):
"""第1页每页100条 offset=0."""
params = PaginationParams(page=1, page_size=100)
assert params.offset == 0
def test_offset_page_100_size_100(self):
"""第100页每页100条 offset=9900."""
params = PaginationParams(page=100, page_size=100)
assert params.offset == 9900
def test_large_page_number_accepted(self):
"""极大页码(超过实际页数)允许."""
params = PaginationParams(page=999999, page_size=20)
assert params.page == 999999
assert params.offset == (999999 - 1) * 20
# ── PaginationMeta 补充边界 ─────────────────────────────────────────────────
class TestPaginationMetaEdgeCases:
"""PaginationMeta 补充边界场景."""
def test_total_0_page_1(self):
"""total=0, page=1 时 total_pages=0, 无上下页."""
params = PaginationParams(page=1, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is False
def test_total_0_page_beyond(self):
"""total=0, page>1 时 has_prev=True(因为page>1."""
params = PaginationParams(page=3, page_size=20)
meta = PaginationMeta.from_params(params, total=0)
assert meta.total_pages == 0
assert meta.has_next is False
assert meta.has_prev is True
def test_exact_last_page(self):
"""刚好是最后一页时 has_next=False."""
params = PaginationParams(page=5, page_size=10)
meta = PaginationMeta.from_params(params, total=50)
assert meta.total_pages == 5
assert meta.has_next is False
assert meta.has_prev is True
def test_one_more_than_exact(self):
"""比整数页多1条时总页数+1."""
params = PaginationParams(page=1, page_size=10)
meta = PaginationMeta.from_params(params, total=51)
assert meta.total_pages == 6
def test_page_exactly_total_pages(self):
"""page == total_pages 时 has_next=False."""
params = PaginationParams(page=3, page_size=10)
meta = PaginationMeta.from_params(params, total=30)
assert meta.has_next is False
def test_total_1_page_1_size_1(self):
"""1条数据1页."""
params = PaginationParams(page=1, page_size=1)
meta = PaginationMeta.from_params(params, total=1)
assert meta.total_pages == 1
assert meta.has_next is False
assert meta.has_prev is False
# ── paginate 补充边界 ──────────────────────────────────────────────────────
class TestPaginateEdgeCases:
"""paginate 补充边界场景."""
def test_single_item_list(self):
"""单元素列表."""
result = paginate([42], PaginationParams(page=1, page_size=10))
assert result.data == [42]
assert result.pagination.total == 1
assert result.pagination.total_pages == 1
def test_page_exactly_last(self):
"""刚好在最后一页."""
items = list(range(25))
result = paginate(items, PaginationParams(page=3, page_size=10))
assert result.data == list(range(20, 25))
assert result.pagination.has_next is False
def test_page_past_end_returns_empty(self):
"""页码超过总数返回空."""
items = list(range(5))
result = paginate(items, PaginationParams(page=10, page_size=10))
assert result.data == []
assert result.pagination.total == 5
def test_empty_list_page_1(self):
"""空列表第1页."""
result = paginate([], PaginationParams(page=1, page_size=10))
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_size_1_iterates_all(self):
"""page_size=1 时每页1条."""
items = ["a", "b", "c"]
r1 = paginate(items, PaginationParams(page=1, page_size=1))
r2 = paginate(items, PaginationParams(page=2, page_size=1))
r3 = paginate(items, PaginationParams(page=3, page_size=1))
assert r1.data == ["a"]
assert r2.data == ["b"]
assert r3.data == ["c"]
def test_does_not_mutate_input(self):
"""不修改输入列表."""
items = [1, 2, 3, 4, 5]
original = items[:]
paginate(items, PaginationParams(page=1, page_size=2))
assert items == original
def test_page_size_greater_than_total(self):
"""每页条数大于总数."""
items = list(range(5))
result = paginate(items, PaginationParams(page=1, page_size=100))
assert result.data == items
assert result.pagination.total_pages == 1
def test_empty_list(self):
items = []
params = PaginationParams(page=1, page_size=10)
result = paginate(items, params)
assert result.data == []
assert result.pagination.total == 0
assert result.pagination.total_pages == 0
def test_page_beyond_end(self):
items = list(range(5))
params = PaginationParams(page=10, page_size=10)
result = paginate(items, params)
assert result.data == []
assert result.pagination.total == 5
def test_page_size_larger_than_items(self):
items = list(range(5))
params = PaginationParams(page=1, page_size=100)
result = paginate(items, params)
assert result.data == items
assert result.pagination.total_pages == 1
def test_middle_page(self):
items = list(range(100))
params = PaginationParams(page=5, page_size=10)
result = paginate(items, params)
assert result.data == list(range(40, 50))
assert result.pagination.has_next is True
assert result.pagination.has_prev is True
+318
View File
@@ -0,0 +1,318 @@
"""密码哈希与验证模块单元测试."""
import pytest
from packages.application.auth.password_handler import (
PasswordHandler,
configure_password_handler,
get_password_handler,
)
from packages.application.auth.password_hasher import (
PasswordHasher,
PasswordValidator,
)
# ==================== PasswordValidator ====================
class TestPasswordValidator:
@pytest.fixture
def default_validator(self):
return PasswordValidator()
@pytest.fixture
def strict_validator(self):
return PasswordValidator(
min_length=12,
require_uppercase=True,
require_lowercase=True,
require_digit=True,
require_special=True,
)
class TestBasicValidation:
def test_valid_password(self, default_validator):
ok, msg = default_validator.validate("SecurePass123")
assert ok is True
assert msg is None
def test_empty_password(self, default_validator):
ok, msg = default_validator.validate("")
assert ok is False
assert "empty" in msg.lower()
def test_none_password_treated_as_empty(self, default_validator):
# None被not判定为falsy,返回空密码错误
ok, msg = default_validator.validate(None)
assert ok is False
assert "empty" in msg.lower()
class TestMinLength:
def test_too_short(self, default_validator):
ok, msg = default_validator.validate("Ab1")
assert ok is False
assert "8" in msg
def test_exact_min_length(self, default_validator):
# 刚好8个字符
ok, _ = default_validator.validate("Abcdefg1")
assert ok is True
def test_custom_min_length(self, strict_validator):
ok, msg = strict_validator.validate("Short1!")
assert ok is False
assert "12" in msg
class TestUppercase:
def test_no_uppercase(self, default_validator):
ok, msg = default_validator.validate("password123")
assert ok is False
assert "uppercase" in msg.lower()
def test_with_uppercase(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_uppercase=False)
ok, _ = v.validate("password123")
assert ok is True
class TestLowercase:
def test_no_lowercase(self, default_validator):
ok, msg = default_validator.validate("PASSWORD123")
assert ok is False
assert "lowercase" in msg.lower()
def test_with_lowercase(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_lowercase=False)
ok, _ = v.validate("PASSWORD123")
assert ok is True
class TestDigit:
def test_no_digit(self, default_validator):
ok, msg = default_validator.validate("Passworddd")
assert ok is False
assert "digit" in msg.lower()
def test_with_digit(self, default_validator):
ok, _ = default_validator.validate("Password1")
assert ok is True
def test_disabled_requirement(self):
v = PasswordValidator(require_digit=False)
ok, _ = v.validate("Passworddd")
assert ok is True
class TestSpecialChar:
def test_no_special_when_not_required(self, default_validator):
ok, _ = default_validator.validate("Password123")
assert ok is True
def test_no_special_when_required(self, strict_validator):
ok, msg = strict_validator.validate("Password1234")
assert ok is False
assert "special" in msg.lower()
def test_with_special(self, strict_validator):
ok, _ = strict_validator.validate("Password123!")
assert ok is True
def test_various_special_chars(self, strict_validator):
for char in "!@#$%^&*()_+-=[]{}|;:,.<>?~":
pw = f"LongPassword1{char}" # 13字符,含大小写数字特殊
ok, msg = strict_validator.validate(pw)
assert ok is True, f"special char {char} should be valid: {msg}"
class TestAllDisabled:
def test_all_disabled_min_length_only(self):
v = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
ok, _ = v.validate("a")
assert ok is True
def test_all_disabled_empty_still_fails(self):
v = PasswordValidator(
min_length=1,
require_uppercase=False,
require_lowercase=False,
require_digit=False,
require_special=False,
)
ok, _ = v.validate("")
assert ok is False
# ==================== PasswordHasher ====================
class TestPasswordHasher:
@pytest.fixture
def hasher(self):
return PasswordHasher(rounds=4) # 用最低rounds加速测试
class TestHashPassword:
def test_hash_returns_string(self, hasher):
result = hasher.hash_password("testpassword")
assert isinstance(result, str)
assert len(result) > 0
def test_hash_starts_with_bcrypt_prefix(self, hasher):
result = hasher.hash_password("testpassword")
assert result.startswith("$2")
def test_hash_contains_rounds(self, hasher):
result = hasher.hash_password("testpassword")
parts = result.split("$")
assert parts[2] == "04" # bcrypt rounds格式是两位数
def test_hash_different_salts(self, hasher):
# 同一密码两次哈希结果不同(因为salt随机)
h1 = hasher.hash_password("samepassword")
h2 = hasher.hash_password("samepassword")
assert h1 != h2
def test_hash_empty_password_raises(self, hasher):
with pytest.raises(ValueError, match="empty"):
hasher.hash_password("")
def test_hash_unicode_password(self, hasher):
result = hasher.hash_password("密码Pass123!")
assert isinstance(result, str)
assert len(result) > 0
def test_hash_long_password(self, hasher):
long_pw = "a" * 72 # bcrypt最大72字节
result = hasher.hash_password(long_pw)
assert isinstance(result, str)
class TestVerifyPassword:
def test_verify_correct_password(self, hasher):
hashed = hasher.hash_password("CorrectPass123")
assert hasher.verify_password("CorrectPass123", hashed) is True
def test_verify_wrong_password(self, hasher):
hashed = hasher.hash_password("CorrectPass123")
assert hasher.verify_password("WrongPass123", hashed) is False
def test_verify_empty_password(self, hasher):
hashed = hasher.hash_password("testpass")
assert hasher.verify_password("", hashed) is False
def test_verify_empty_hash(self, hasher):
assert hasher.verify_password("testpass", "") is False
def test_verify_invalid_hash_format(self, hasher):
assert hasher.verify_password("testpass", "invalid-hash-format") is False
def test_verify_none_hash(self, hasher):
assert hasher.verify_password("testpass", None) is False
def test_verify_unicode_password(self, hasher):
pw = "密码Pass123!"
hashed = hasher.hash_password(pw)
assert hasher.verify_password(pw, hashed) is True
class TestNeedsRehash:
def test_same_rounds_no_rehash(self, hasher):
hashed = hasher.hash_password("testpass")
assert hasher.needs_rehash(hashed) is False
def test_lower_rounds_needs_rehash(self):
hasher_low = PasswordHasher(rounds=4)
hashed = hasher_low.hash_password("testpass")
hasher_high = PasswordHasher(rounds=5)
assert hasher_high.needs_rehash(hashed) is True
def test_higher_rounds_needs_rehash(self):
hasher_high = PasswordHasher(rounds=5)
hashed = hasher_high.hash_password("testpass")
hasher_low = PasswordHasher(rounds=4)
assert hasher_low.needs_rehash(hashed) is True
def test_invalid_hash_no_rehash(self, hasher):
assert hasher.needs_rehash("invalid-format") is False
def test_empty_hash_no_rehash(self, hasher):
assert hasher.needs_rehash("") is False
class TestInit:
def test_rounds_below_min_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=3)
def test_rounds_above_max_raises(self):
with pytest.raises(ValueError):
PasswordHasher(rounds=32)
def test_min_rounds_ok(self):
h = PasswordHasher(rounds=4)
assert h.rounds == 4
def test_max_rounds_ok(self):
h = PasswordHasher(rounds=31)
assert h.rounds == 31
# ==================== PasswordHandler ====================
class TestPasswordHandler:
@pytest.fixture
def handler(self):
return PasswordHandler(rounds=4)
def test_hash_and_verify_roundtrip(self, handler):
hashed = handler.hash_password("MySecurePass123")
assert handler.verify_password("MySecurePass123", hashed) is True
assert handler.verify_password("WrongPass", hashed) is False
def test_needs_rehash(self, handler):
# 用当前rounds哈希,不需要rehash
hashed = handler.hash_password("testpass")
assert handler.needs_rehash(hashed) is False
def test_validate_strength(self, handler):
# 强密码通过
ok, msg = handler.validate_strength("StrongPass123")
assert ok is True
assert msg is None
# 弱密码不通过
ok, msg = handler.validate_strength("weak")
assert ok is False
assert msg is not None
def test_hash_empty_raises(self, handler):
with pytest.raises(ValueError):
handler.hash_password("")
class TestGlobalHandler:
def test_get_password_handler_returns_instance(self):
# 重置全局实例
import packages.application.auth.password_handler as ph
ph._default_handler = None
handler = get_password_handler()
assert isinstance(handler, PasswordHandler)
def test_configure_password_handler(self):
handler = configure_password_handler(rounds=4)
assert isinstance(handler, PasswordHandler)
# get应该返回同一个配置好的实例
same_handler = get_password_handler()
assert same_handler is handler
+224 -209
View File
@@ -1,18 +1,14 @@
"""路径安全校验工具单元测试 — 路径遍历防护."""
from __future__ import annotations
"""path_security 单元测试."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
import pytest
from video_processing.path_security import ( # noqa: E402
from apps.worker.video_processing.path_security import (
LOCAL_SCHEMA_PREFIX,
MAX_PATH_LENGTH,
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
is_path_safe,
safe_resolve_path,
@@ -21,223 +17,242 @@ from video_processing.path_security import ( # noqa: E402
)
class TestSafeResolvePath(unittest.TestCase):
"""安全路径解析测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
# ── 正常路径 ─────────────────────────────────────────────────────────
def test_simple_relative_path(self):
"""简单相对路径应该正常解析."""
result = safe_resolve_path("test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
self.assertTrue(str(result).startswith(self.tmpdir))
def test_subdirectory_path(self):
"""子目录路径应该正常解析."""
result = safe_resolve_path("sub/dir/file.mp4", self.tmpdir)
self.assertTrue(str(result).startswith(self.tmpdir))
self.assertIn("sub/dir/file.mp4", str(result).replace("\\", "/"))
def test_dot_slash_path(self):
"""./ 开头的路径应该正常解析."""
result = safe_resolve_path("./test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
# ── 路径遍历防护 ─────────────────────────────────────────────────────
def test_parent_traversal_rejected(self):
"""../ 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../etc/passwd", self.tmpdir)
def test_multiple_parent_traversal_rejected(self):
"""多级 ../ 遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../../etc/passwd", self.tmpdir)
def test_mixed_traversal_rejected(self):
"""混合路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("./sub/../../etc/shadow", self.tmpdir)
def test_absolute_path_rejected(self):
"""绝对路径(超出基目录)应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/etc/passwd", self.tmpdir)
# ── 空字节注入 ───────────────────────────────────────────────────────
def test_null_byte_rejected(self):
"""空字节注入应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("test\x00.mp4", self.tmpdir)
# ── 空路径 ──────────────────────────────────────────────────────────
def test_empty_path_rejected(self):
"""空路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("", self.tmpdir)
def test_none_path_rejected(self):
"""None 路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(None, self.tmpdir) # type: ignore
def test_whitespace_path_rejected(self):
"""空白路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(" ", self.tmpdir)
# ── 路径长度 ────────────────────────────────────────────────────────
def test_too_long_path_rejected(self):
"""超长路径应该被拒绝."""
long_path = "a" * 5000 + ".mp4"
with self.assertRaises(PathSecurityError):
safe_resolve_path(long_path, self.tmpdir)
# ── 系统路径防护 ─────────────────────────────────────────────────────
def test_proc_path_rejected_when_absolute(self):
"""/proc/ 路径在绝对路径模式下应该被拒绝(因为超出基目录)."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/proc/self/environ", self.tmpdir)
# ── 扩展名校验 ───────────────────────────────────────────────────────
def test_extension_whitelist_pass(self):
"""白名单内的扩展名应该通过."""
result = safe_resolve_path(
"test.mp4",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
self.assertEqual(result.suffix.lower(), ".mp4")
def test_extension_whitelist_reject(self):
"""白名单外的扩展名应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(
"test.exe",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
@pytest.fixture
def base_dir():
with tempfile.TemporaryDirectory() as tmpdir:
# 创建一个子文件用于测试
with open(os.path.join(tmpdir, "test.mp4"), "w") as f:
f.write("test")
subdir = os.path.join(tmpdir, "subdir")
os.makedirs(subdir)
with open(os.path.join(subdir, "audio.mp3"), "w") as f:
f.write("test")
yield tmpdir
class TestLocalSchemaPath(unittest.TestCase):
"""local:// schema 路径测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_valid_local_schema(self):
"""有效的 local:// 相对路径应该通过."""
# 创建测试文件
test_file = Path(self.tmpdir) / "test.mp4"
test_file.touch()
result = validate_local_schema_path("local://test.mp4", self.tmpdir)
self.assertTrue(result.exists())
def test_local_schema_absolute_rejected(self):
"""local:// + 绝对路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", self.tmpdir)
def test_local_schema_traversal_rejected(self):
"""local:// + 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local://../etc/passwd", self.tmpdir)
def test_non_local_schema_rejected(self):
"""非 local:// 开头的路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("http://example.com/test", self.tmpdir)
# ── safe_resolve_path ────────────────────────────────────────────────────────
class TestSanitizeFilename(unittest.TestCase):
"""文件名清理测试."""
class TestSafeResolvePath:
def test_none_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(None, base_dir)
def test_empty_string_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path("", base_dir)
def test_whitespace_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(" ", base_dir)
def test_too_long_path_raises(self, base_dir):
long_path = "a" * (MAX_PATH_LENGTH + 1)
with pytest.raises(PathSecurityError, match="路径过长"):
safe_resolve_path(long_path, base_dir)
def test_null_byte_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="空字节"):
safe_resolve_path("file\x00.mp4", base_dir)
def test_relative_path_within_base(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_subdirectory_path(self, base_dir):
result = safe_resolve_path("subdir/audio.mp3", base_dir)
assert result.name == "audio.mp3"
assert "subdir" in str(result)
def test_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("../etc/passwd", base_dir)
def test_nested_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("subdir/../../etc/passwd", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("/etc/passwd", base_dir)
def test_absolute_path_with_allow_outside(self, base_dir):
# allow_outside=True 时允许绝对路径(但会被危险路径模式检查)
with pytest.raises(PathSecurityError, match="系统路径"):
safe_resolve_path("/etc/passwd", base_dir, allow_outside=True)
def test_local_schema_relative(self, base_dir):
result = safe_resolve_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_local_schema_absolute_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("local:///etc/passwd", base_dir)
def test_local_schema_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("local://../secret", base_dir)
def test_invalid_base_dir_raises(self):
with pytest.raises(PathSecurityError, match="基路径"):
safe_resolve_path("file.txt", "/nonexistent/dir")
def test_allowed_extensions_valid(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp4"})
assert result.suffix.lower() == ".mp4"
def test_allowed_extensions_invalid_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="文件类型"):
safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp3"})
def test_no_extension_restriction(self, base_dir):
# allowed_extensions=None 时不检查
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions=None)
assert result is not None
def test_path_object_input(self, base_dir):
from pathlib import Path
result = safe_resolve_path(Path("test.mp4"), base_dir)
assert result.name == "test.mp4"
def test_path_object_base_dir(self, base_dir):
from pathlib import Path
result = safe_resolve_path("test.mp4", Path(base_dir))
assert result.name == "test.mp4"
# ── is_path_safe ────────────────────────────────────────────────────────────
class TestIsPathSafe:
def test_safe_path_returns_true(self, base_dir):
assert is_path_safe("test.mp4", base_dir) is True
def test_unsafe_path_returns_false(self, base_dir):
assert is_path_safe("../etc/passwd", base_dir) is False
def test_none_returns_false(self, base_dir):
assert is_path_safe(None, base_dir) is False
# ── validate_local_schema_path ──────────────────────────────────────────────
class TestValidateLocalSchemaPath:
def test_valid_local_path(self, base_dir):
result = validate_local_schema_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
def test_missing_prefix_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="开头"):
validate_local_schema_path("test.mp4", base_dir)
def test_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local://../secret", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", base_dir)
# ── sanitize_filename ───────────────────────────────────────────────────────
class TestSanitizeFilename:
def test_normal_filename(self):
"""正常文件名应该保持不变."""
self.assertEqual(sanitize_filename("video.mp4"), "video.mp4")
assert sanitize_filename("hello.mp4") == "hello.mp4"
def test_path_separators_removed(self):
"""路径分隔符应该被替换."""
self.assertNotIn("/", sanitize_filename("../path/to/file.mp4"))
self.assertNotIn("\\", sanitize_filename("..\\path\\file.mp4"))
def test_empty_returns_unnamed(self):
assert sanitize_filename("") == "unnamed"
def test_leading_dots_removed(self):
"""开头的点应该被移除."""
result = sanitize_filename(".hidden")
self.assertFalse(result.startswith("."))
self.assertEqual(result, "hidden")
def test_none_default(self):
# 空字符串会返回unnamed
assert sanitize_filename("") == "unnamed"
def test_multiple_leading_dots_removed(self):
"""多个开头的点应该全部被移除."""
result = sanitize_filename("...hidden")
self.assertFalse(result.startswith("."))
def test_removes_path_separators(self):
assert "/" not in sanitize_filename("path/to/file.mp4")
assert "\\" not in sanitize_filename("path\\to\\file.mp4")
def test_empty_filename_default(self):
"""空文件名应该返回 unnamed."""
self.assertEqual(sanitize_filename(""), "unnamed")
def test_removes_control_characters(self):
result = sanitize_filename("file\x01\x02name.mp4")
assert "\x01" not in result
assert "\x02" not in result
def test_special_chars_removed(self):
"""特殊字符应该被替换."""
result = sanitize_filename('file<name>:"test|?*.mp4')
self.assertNotIn("<", result)
self.assertNotIn(">", result)
self.assertNotIn(":", result)
self.assertNotIn('"', result)
self.assertNotIn("|", result)
self.assertNotIn("?", result)
self.assertNotIn("*", result)
def test_removes_dangerous_chars(self):
result = sanitize_filename("file<name>.mp4")
assert "<" not in result
assert ">" not in result
def test_chinese_filename_preserved(self):
"""中文文件名应该保留."""
result = sanitize_filename("视频素材.mp4")
self.assertIn("视频素材", result)
def test_removes_leading_dots(self):
assert not sanitize_filename(".hidden").startswith(".")
assert not sanitize_filename("..hidden").startswith(".")
def test_chinese_characters_preserved(self):
result = sanitize_filename("视频文件.mp4")
assert "视频文件" in result
def test_long_filename_truncated(self):
"""超长文件名应该被截断."""
long_name = "a" * 300 + ".mp4"
result = sanitize_filename(long_name)
self.assertLessEqual(len(result), 255)
self.assertTrue(result.endswith(".mp4"))
assert len(result) <= 255
assert result.endswith(".mp4")
def test_spaces_preserved(self):
result = sanitize_filename("my file.mp4")
assert "my file.mp4" == result
def test_underscores_hyphens_preserved(self):
result = sanitize_filename("my_file-name.mp4")
assert result == "my_file-name.mp4"
def test_all_dots_returns_unnamed(self):
assert sanitize_filename("...") == "unnamed"
class TestAllowedDirs(unittest.TestCase):
"""允许目录配置测试."""
def test_get_allowed_dirs_returns_list(self):
"""get_allowed_local_dirs 应该返回列表."""
dirs = get_allowed_local_dirs()
self.assertIsInstance(dirs, list)
def test_is_in_allowed_dirs_tmp(self):
"""/tmp 应该在默认允许目录内."""
self.assertTrue(is_in_allowed_dirs("/tmp/test.mp4"))
def test_is_path_safe_convenience(self):
"""is_path_safe 便捷函数应该正常工作."""
with tempfile.TemporaryDirectory() as tmpdir:
self.assertTrue(is_path_safe("test.mp4", tmpdir))
self.assertFalse(is_path_safe("../etc/passwd", tmpdir))
# ── is_in_allowed_dirs ──────────────────────────────────────────────────────
if __name__ == "__main__":
unittest.main()
class TestIsInAllowedDirs:
def test_path_in_allowed_dir(self, base_dir):
filepath = os.path.join(base_dir, "test.mp4")
from pathlib import Path
assert is_in_allowed_dirs(filepath, [Path(base_dir)]) is True
def test_path_not_in_allowed_dir(self, base_dir):
from pathlib import Path
assert is_in_allowed_dirs("/etc/passwd", [Path(base_dir)]) is False
def test_subdirectory_in_allowed(self, base_dir):
from pathlib import Path
sub = os.path.join(base_dir, "subdir", "audio.mp3")
assert is_in_allowed_dirs(sub, [Path(base_dir)]) is True
def test_none_allowed_dirs_uses_default(self):
# None 使用默认配置(包含 /tmp)
result = is_in_allowed_dirs("/tmp/test.mp4")
assert isinstance(result, bool)
def test_allowed_dirs_list_is_empty(self):
from pathlib import Path
assert is_in_allowed_dirs("/tmp/test", []) is False
# ── PathSecurityError class ─────────────────────────────────────────────────
class TestPathSecurityError:
def test_is_value_error(self):
assert issubclass(PathSecurityError, ValueError)
def test_message_preserved(self):
err = PathSecurityError("test message")
assert str(err) == "test message"
File diff suppressed because it is too large Load Diff
+453
View File
@@ -0,0 +1,453 @@
"""shared.ai_service 单元测试.
主要测试纯逻辑部分_parse_recommend_response / _fallback_recommend_clips / _call_ai_cover_service.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from shared.ai_service import (
_call_ai_cover_service,
_fallback_recommend_clips,
_parse_recommend_response,
)
# ── _parse_recommend_response 测试 ────────────────────────────────────────
class TestParseRecommendResponseBasic:
"""基础解析测试."""
def test_parse_valid_json(self):
content = json.dumps(
{
"clips": [
{
"clip_type": "intro",
"order": 0,
"text_content": "开场",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": "asset1",
"start_time": 0.0,
"config": {},
},
{
"clip_type": "outro",
"order": 1,
"text_content": "结尾",
"duration": 2.0,
"transition_effect": "fade",
"asset_id": "",
"start_time": 0.0,
"config": {},
},
],
"title": "测试视频",
"confidence": 0.85,
}
)
result = _parse_recommend_response(content, ["asset1"], 30.0)
assert result is not None
assert len(result["clips"]) == 2
assert result["confidence"] == 0.85
assert result["total_duration"] == 5.0
assert result["config"]["title"]["text"] == "测试视频"
assert result["config"]["title"]["ai_auto"] is True
def test_parse_none_returns_none(self):
result = _parse_recommend_response(None, ["a1"], 30.0) # type: ignore[arg-type]
assert result is None
def test_parse_empty_string_returns_none(self):
result = _parse_recommend_response("", ["a1"], 30.0)
assert result is None
def test_parse_whitespace_only_returns_none(self):
result = _parse_recommend_response(" ", ["a1"], 30.0)
assert result is None
def test_parse_invalid_json_returns_none(self):
result = _parse_recommend_response("not json", ["a1"], 30.0)
assert result is None
def test_parse_non_dict_json_returns_none(self):
result = _parse_recommend_response("[1, 2, 3]", ["a1"], 30.0)
assert result is None
class TestParseRecommendResponseClips:
"""clips 解析测试."""
def test_parse_no_clips_returns_none(self):
content = json.dumps({"title": "test", "clips": []})
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is None
def test_parse_clips_not_list_returns_none(self):
content = json.dumps({"clips": "not a list"})
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is None
def test_parse_clips_sorted_by_order(self):
content = json.dumps(
{
"clips": [
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
{"clip_type": "intro", "order": 0, "duration": 3, "asset_id": "a1"},
{"clip_type": "showcase", "order": 1, "duration": 5, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 3
assert result["clips"][0]["clip_type"] == "intro"
assert result["clips"][1]["clip_type"] == "showcase"
assert result["clips"][2]["clip_type"] == "outro"
def test_parse_clips_renumbered_continuously(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 10, "duration": 2, "asset_id": "a1"},
{"clip_type": "outro", "order": 20, "duration": 2, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["order"] == 0
assert result["clips"][1]["order"] == 1
def test_parse_skips_invalid_clip_dicts(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"},
"not a dict",
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 2
class TestParseRecommendResponseFields:
"""各字段解析与边界测试."""
def test_parse_duration_clamped_min(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 0.5, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["duration"] == 1.0
def test_parse_duration_clamped_max(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 100, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["duration"] == 30.0
def test_parse_start_time_clamped_min(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1", "start_time": -5.0},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["clips"][0]["start_time"] == 0.0
def test_parse_asset_id_not_in_list_empty(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "unknown_asset"},
],
}
)
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
assert result is not None
assert result["clips"][0]["asset_id"] == ""
def test_parse_asset_id_in_list_kept(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a2"},
],
}
)
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
assert result is not None
assert result["clips"][0]["asset_id"] == "a2"
def test_parse_default_values(self):
content = json.dumps(
{
"clips": [
{"order": 0},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
clip = result["clips"][0]
assert clip["clip_type"] == "showcase"
assert clip["text_content"] == ""
assert clip["duration"] == 3.0
assert clip["transition_effect"] == "cut"
assert clip["asset_id"] == ""
assert clip["start_time"] == 0.0
assert clip["config"] == {}
class TestParseRecommendResponseMarkdown:
"""Markdown 代码块包裹的 JSON 测试."""
def test_parse_markdown_json(self):
content = (
"```json\n"
+ json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"title": "md test",
}
)
+ "\n```"
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 1
assert result["config"]["title"]["text"] == "md test"
def test_parse_backticks_no_language(self):
content = (
"```\n"
+ json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
+ "\n```"
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert len(result["clips"]) == 1
class TestParseRecommendResponseConfidence:
"""confidence 解析测试."""
def test_parse_confidence_normal(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": 0.85,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.85
def test_parse_confidence_clamped_min(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": -0.5,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.0
def test_parse_confidence_clamped_max(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"confidence": 1.5,
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 1.0
def test_parse_confidence_default(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["confidence"] == 0.7
class TestParseRecommendResponseConfig:
"""config 生成测试."""
def test_parse_no_title_no_ai_auto(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
# 没有 title 时,config 的 title.text 保持默认(DEFAULT_EDIT_PLAN_CONFIG 中的值)
assert "title" in result["config"]
def test_parse_config_is_deep_copy(self):
content = json.dumps(
{
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
"title": "test",
}
)
result1 = _parse_recommend_response(content, ["a1"], 30.0)
result2 = _parse_recommend_response(content, ["a1"], 30.0)
# 修改其中一个不影响另一个
result1["config"]["title"]["text"] = "modified"
assert result2["config"]["title"]["text"] != "modified"
class TestParseRecommendResponseTotalDuration:
"""total_duration 计算测试."""
def test_parse_total_duration_sum(self):
content = json.dumps(
{
"clips": [
{"clip_type": "intro", "order": 0, "duration": 3.5, "asset_id": "a1"},
{"clip_type": "showcase", "order": 1, "duration": 5.2, "asset_id": "a1"},
{"clip_type": "outro", "order": 2, "duration": 2.0, "asset_id": "a1"},
],
}
)
result = _parse_recommend_response(content, ["a1"], 30.0)
assert result is not None
assert result["total_duration"] == pytest.approx(10.7, abs=0.01)
# ── _fallback_recommend_clips 测试 ────────────────────────────────────────
class TestFallbackRecommendClips:
"""本地降级推荐方案测试."""
def test_fallback_returns_dict_with_clips(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
assert "clips" in result
assert "config" in result
assert "total_duration" in result
assert "confidence" in result
def test_fallback_has_intro_and_outro(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
clips = result["clips"]
assert clips[0]["clip_type"] == "intro"
assert clips[-1]["clip_type"] == "outro"
def test_fallback_showcase_count_matches_assets(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
showcase_clips = [c for c in result["clips"] if c["clip_type"] == "showcase"]
assert len(showcase_clips) == 3
def test_fallback_no_assets_still_works(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", [], "one_take", 30.0)
assert len(result["clips"]) >= 2 # 至少有intro和outro
def test_fallback_intro_uses_first_asset(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
assert result["clips"][0]["asset_id"] == "a1"
def test_fallback_outro_has_empty_asset(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
assert result["clips"][-1]["asset_id"] == ""
def test_fallback_confidence_in_range(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
assert 0.75 <= result["confidence"] <= 0.95
def test_fallback_title_contains_asset_count(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
assert "3" in result["config"]["title"]["text"]
assert result["config"]["title"]["ai_auto"] is True
def test_fallback_total_duration_matches(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
total = sum(c["duration"] for c in result["clips"])
assert result["total_duration"] == round(total, 1)
def test_fallback_orders_are_sequential(self):
with patch("shared.ai_service.time.sleep"):
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
orders = [c["order"] for c in result["clips"]]
assert orders == list(range(len(result["clips"])))
# ── _call_ai_cover_service 测试 ───────────────────────────────────────────
class TestAiCoverService:
"""AI封面生成服务测试."""
def test_cover_type_upload(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "upload")
assert result["type"] == "upload"
assert result["image_url"] == ""
def test_cover_type_manual_with_frame_time(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
assert result["type"] == "manual"
assert result["frame_time"] == 5.5
assert "5.5" in result["image_url"]
def test_cover_type_ai_frame(self):
with patch("shared.ai_service.time.sleep"):
with patch("shared.ai_service.random.uniform", side_effect=[5.0, 0.9]):
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
assert result["type"] == "ai_frame"
assert result["frame_time"] == 5.0
assert result["confidence"] == 0.9
assert "plan1" in result["image_url"]
def test_cover_type_ai_regenerate(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
assert result["type"] == "ai_frame"
def test_cover_frame_time_in_range(self):
with patch("shared.ai_service.time.sleep"):
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
assert 1.0 <= result["frame_time"] <= 10.0
File diff suppressed because it is too large Load Diff
+81 -393
View File
@@ -1,412 +1,100 @@
"""文本分段工具单元测试."""
from __future__ import annotations
import pytest
"""text_splitter 单元测试."""
from packages.application.tts_job.text_splitter import split_text
class TestSplitText:
"""split_text 函数测试"""
def test_empty_string_returns_empty_list(self):
"""空字符串返回空列表"""
def test_empty_text_returns_empty(self):
assert split_text("") == []
def test_whitespace_only_returns_empty_list(self):
"""纯空白字符返回空列表"""
assert split_text(" \n \t ") == []
def test_short_text_returns_single_segment(self):
"""短文本直接返回单段"""
text = "这是一段短文本。"
result = split_text(text, max_chars=500)
assert result == [text]
def test_text_length_equals_max_chars(self):
"""文本长度恰好等于 max_chars 时返回单段"""
text = "a" * 100
result = split_text(text, max_chars=100)
assert len(result) == 1
assert len(result[0]) == 100
def test_splits_on_sentence_boundary(self):
"""在句子边界处分段"""
# 构造长文本,确保超过 max_chars
sentences = ["今天天气真好。我们一起去公园散步吧。", "公园里有很多花。还有很多小朋友在玩耍。"] * 10
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) >= 2
# 每段都不超过 max_chars
for seg in result:
assert len(seg) <= 200
def test_all_segments_within_max_chars(self):
"""所有分段都不超过 max_chars"""
text = "这是第一句话。这是第二句话。这是第三句话。这是第四句话。这是第五句话。" * 10
result = split_text(text, max_chars=100)
for seg in result:
assert len(seg) <= 100
def test_long_single_sentence_hard_cut(self):
"""超长单句会被硬切"""
text = "a" * 1000 # 没有标点
result = split_text(text, max_chars=200)
assert len(result) > 1
for seg in result:
assert len(seg) <= 200
def test_newline_is_sentence_end(self):
"""换行符作为句子结束符"""
text = "第一行内容\n第二行内容\n第三行内容" * 10
result = split_text(text, max_chars=50)
assert len(result) > 1
for seg in result:
assert len(seg) <= 50
def test_chinese_punctuation(self):
"""中文标点(。!?;)作为句子结束符"""
text = "你好!今天吃什么?我吃米饭;你呢?我也吃米饭。" * 10
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
def test_english_punctuation(self):
"""英文标点(.!?;)作为句子结束符"""
text = "Hello! How are you? I'm fine; thank you. Good bye." * 10
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
def test_merged_short_segments(self):
"""过短的段落会被合并"""
# 构造很多短句
text = "你好。再见。谢谢。抱歉。好的。不行。可以。去吧。" * 5 # 每句3-4字
result = split_text(text, max_chars=100)
# 合并后段数应该比单纯按句切的少
assert len(result) < len(text) // 3 # 粗略估计
for seg in result:
assert len(seg) <= 100
def test_preserves_content(self):
"""分段后内容总和与原文基本一致(忽略strip的空白)"""
text = "这是测试文本。包含多个句子。用来验证分段正确性。" * 5
result = split_text(text, max_chars=50)
# 合并所有分段,去掉空白后应该与原文去掉空白后基本一致
combined = "".join(result).replace(" ", "")
original = text.strip().replace(" ", "")
assert combined == original
def test_custom_max_chars(self):
"""支持自定义 max_chars"""
text = "测试" * 100 # 200字
result_50 = split_text(text, max_chars=50)
result_100 = split_text(text, max_chars=100)
# max_chars 越小,段数应该越多
assert len(result_50) >= len(result_100)
def test_single_char_text(self):
"""单字符文本"""
assert split_text("", max_chars=10) == [""]
def test_text_with_only_punctuation(self):
"""纯标点文本"""
text = "。。。。。。。。。。" # 10个句号
result = split_text(text, max_chars=5)
assert len(result) >= 1
for seg in result:
assert len(seg) <= 5
def test_mixed_content(self):
"""中英文混合内容"""
text = "今天的天气是 sunny and warm。我们去了 park 玩。真的很开心!" * 5
result = split_text(text, max_chars=80)
for seg in result:
assert len(seg) <= 80
# ── 短文本与空文本补充 ──────────────────────────────────────────────────────
class TestSplitTextEmptyAndShort:
"""空文本与短文本补充场景."""
def test_whitespace_only_returns_empty(self):
"""纯空白文本返回空列表."""
def test_whitespace_only(self):
assert split_text(" \n\t ") == []
def test_single_char(self):
"""单字符文本."""
assert split_text("", max_chars=10) == [""]
def test_exactly_max_chars_no_split(self):
"""刚好等于 max_chars 不分割."""
text = "a" * 100
result = split_text(text, max_chars=100)
def test_short_text_single_segment(self):
text = "你好世界。"
result = split_text(text, max_chars=500)
assert len(result) == 1
assert result[0] == text
def test_one_over_max_chars_splits(self):
"""超过 max_chars 1 个字符就会分割."""
text = "a" * 101
def test_exact_max_chars(self):
text = "a" * 500
result = split_text(text, max_chars=500)
assert len(result) == 1
assert len(result[0]) == 500
def test_splits_on_sentence_boundary(self):
# 两个长句子,各300字左右,超过50字阈值
sent1 = "" * 300 + ""
sent2 = "" * 300 + ""
text = sent1 + sent2
result = split_text(text, max_chars=500)
assert len(result) == 2
assert result[0] == sent1
assert result[1] == sent2
def test_long_sentence_hard_cut(self):
# 一个超长句子,没有句末标点,会被硬切
text = "" * 800
result = split_text(text, max_chars=500)
assert len(result) >= 2
assert all(len(seg) <= 500 for seg in result)
# 合起来应该等于原文本
assert "".join(result) == text
def test_short_segments_merged(self):
# 多个短句应该被合并
sentences = [f"{i}句。" for i in range(10)]
text = "".join(sentences)
result = split_text(text, max_chars=200)
# 每句5字左右,10句才50字,应该合并成1段
assert len(result) < 10
assert len(result[0]) <= 200
def test_preserves_content(self):
text = "今天天气真好。我们去公园玩吧!你觉得怎么样?好的,走吧。"
result = split_text(text, max_chars=20)
# 合并后内容应一致
assert "".join(result) == text
def test_multiple_punctuation_types(self):
# 构造足够长的文本触发分段
text = "第一" * 30 + "" + "第二" * 30 + "" + "第三" * 30 + "" + "第四" * 30 + ""
result = split_text(text, max_chars=100)
assert len(result) >= 2
assert "".join(result) == text
def test_none_raises(self):
"""None 输入抛 AttributeErrorstrip 失败)."""
with pytest.raises(AttributeError):
split_text(None)
def test_custom_max_chars(self):
text = "a" * 100 + "" + "b" * 100 + ""
result = split_text(text, max_chars=150)
assert len(result) == 2
assert "a" in result[0]
assert "b" in result[1]
# ── 句子边界分段补充 ──────────────────────────────────────────────────────
class TestSplitTextSentenceBoundaries:
"""句子边界分段补充场景."""
def test_split_on_fullwidth_period(self):
"""全角句号分段."""
text = "第一句很长的内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
for seg in result:
assert len(seg) <= 60
def test_split_on_fullwidth_question(self):
"""全角问号分段."""
text = "你知道这是为什么吗?" + "是的。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_fullwidth_exclamation(self):
"""全角感叹号分段."""
text = "真是太棒了!" + "内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_split_on_newline(self):
"""换行符分段."""
lines = ["这是第一行很长的一段文字内容" * 3 for _ in range(5)]
text = "\n".join(lines)
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_split_on_semicolon(self):
"""全角分号分段."""
text = "第一项内容;" + "其他内容。" * 20
result = split_text(text, max_chars=60)
assert len(result) > 1
def test_english_period_splits(self):
"""英文句号分段."""
text = "Hello world. " * 30
result = split_text(text, max_chars=80)
assert len(result) > 1
def test_short_sentences_stay_merged(self):
"""短句(都 < 50字的句子不会单独成段,会累积到一起."""
text = "你好。我好。大家好。"
result = split_text(text, max_chars=200)
assert len(result) == 1
# ── 长句强制切段补充 ──────────────────────────────────────────────────────
class TestSplitTextLongSentenceForce:
"""超长单句强制切段补充."""
def test_no_punctuation_forced_split(self):
"""完全没有标点的超长文本硬切."""
text = "" * 300
result = split_text(text, max_chars=100)
assert len(result) == 3
for seg in result:
assert len(seg) == 100
def test_force_split_preserves_content(self):
"""硬切不丢字符."""
text = "a" * 250
result = split_text(text, max_chars=100)
assert sum(len(s) for s in result) == 250
def test_mixed_long_and_short(self):
"""长句短句混合."""
long_part = "非常长的句子没有标点符号" * 15
text = long_part + "。结尾。"
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
# ── 短段合并补充 ─────────────────────────────────────────────────────────
class TestSplitTextShortSegmentMerge:
"""短段合并补充场景."""
def test_multiple_short_sentences_merged(self):
"""多个短句合并成一段."""
sentences = ["你好。", "我好。", "大家好。", "天气好。", "心情好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_short_tail_merged(self):
"""尾部短段被合并到前一段."""
# 前面一段接近 max_chars,尾部很短
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "完。" # 2字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾部短的应该被合并
assert len(result) <= 2
# ── 边界情况补充 ─────────────────────────────────────────────────────────
class TestSplitTextEdgeCases:
"""边界情况补充."""
def test_only_punctuation(self):
"""纯标点符号."""
text = "。。。。。"
result = split_text(text, max_chars=10)
assert len(result) == 1
def test_mixed_chinese_english(self):
"""中英文混合."""
text = "你好Hello。World!" * 20
result = split_text(text, max_chars=100)
assert len(result) > 1
for seg in result:
assert len(seg) <= 100
def test_strip_whitespace(self):
"""首尾空白被去除."""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_total_length_preserved(self):
"""分段后总长度等于原文 strip 后长度."""
text = "这是一段用于测试的文本内容。" * 20
result = split_text(text, max_chars=100)
def test_newline_as_sentence_end(self):
text = "第一段\n第二段\n第三段"
result = split_text(text, max_chars=50)
assert len(result) >= 1
assert "".join(result) == text.strip()
def test_custom_small_max_chars(self):
"""很小的 max_chars."""
text = "一二三四五六七八九十。" * 5
def test_minimum_segment_length(self):
# 句子太短(<50字)不会立即分段
text = "短句一。短句二。短句三。"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_trailing_content_added(self):
# 最后一段不完整的句子也要加上
text = "完整的句子。剩余内容"
result = split_text(text, max_chars=50)
assert "".join(result) == text
def test_no_empty_segments(self):
text = "。。。。。" # 全是标点
result = split_text(text, max_chars=2)
assert all(len(seg) > 0 for seg in result)
def test_chinese_and_english_mixed(self):
text = "Hello世界。这是测试Test文本。Mixed混合。"
result = split_text(text, max_chars=20)
assert len(result) > 1
for seg in result:
assert len(seg) <= 20
# ── 更多边界场景补充 ─────────────────────────────────────────────────────────
class TestSplitTextMoreEdgeCases:
"""更多边界场景补充"""
def test_max_chars_one(self):
"""max_chars=1 每个字符一段"""
text = "一二三四五"
result = split_text(text, max_chars=1)
assert len(result) == 5
for seg in result:
assert len(seg) == 1
def test_consecutive_newlines(self):
"""连续多个换行符"""
text = "第一段\n\n\n第二段\n\n第三段"
result = split_text(text, max_chars=100)
# 合并后应该是一段(内容不长且合并逻辑会被合并)
assert len(result) >= 1
assert "第一段" in result[0]
for seg in result:
assert len(seg) <= 100
def test_only_newlines_only(self):
"""只有换行符(纯空白被strip掉返回空"""
assert split_text("\n\n\n\n") == []
def test_leading_trailing_whitespace(self):
"""首尾空白被去除"""
text = " 你好世界。 "
result = split_text(text, max_chars=100)
assert result == ["你好世界。"]
def test_very_long_single_sentence_many_segments(self):
"""超长单句被切成很多段"""
text = "" * 1000
result = split_text(text, max_chars=100)
assert len(result) == 10
for seg in result:
assert len(seg) == 100
def test_mixed_punctuation_types(self):
"""全角半角标点混合"""
text = "你好!再见。谢谢?抱歉;好的"
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_last_segment_short_merged_to_previous(self):
"""尾部极短段被合并到前一段"""
# 构造第一段接近max_chars,结尾有个短句尾巴
long_part = "一二三四五六七八九十" * 9 + "" # ~90字
tail = "" # 1字
text = long_part + tail
result = split_text(text, max_chars=100)
# 尾巴应该被合并
combined = "".join(result)
assert combined == text.strip()
assert len(result) <= 2
def test_all_short_sentences_merged_into_one(self):
"""大量短句全部合并成一段"""
sentences = ["你好。", "我好。", "他好。", "大家好。", "才是真的好。"]
text = "".join(sentences)
result = split_text(text, max_chars=200)
assert len(result) == 1
def test_punctuation_only_long(self):
"""很长的纯标点文本"""
text = "" * 200
result = split_text(text, max_chars=50)
assert len(result) >= 4
for seg in result:
assert len(seg) <= 50
def test_tab_not_sentence_end(self):
"""制表符不是句子结束符"""
text = "这是一段\t包含制表符的文本内容" + "" * 100
result = split_text(text, max_chars=50)
# 制表符不在句子结束符集合中,不会触发分段
# 制表符会保留在分段内容中
has_tab = any("\t" in seg for seg in result)
assert has_tab
assert len(result) >= 2
assert "".join(result) == text
+462 -201
View File
@@ -1,7 +1,6 @@
"""验证码服务单元测试."""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
@@ -9,9 +8,9 @@ import pytest
from packages.application.auth.verification_code_service import (
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_BIND,
DAILY_LIMIT,
DEFAULT_TTL_SECONDS,
MAX_ATTEMPTS,
RESEND_COOLDOWN_SECONDS,
VerificationCodeService,
@@ -21,298 +20,560 @@ from packages.application.auth.verification_code_service import (
)
from packages.domain.verification_code import VerificationCode
# ── Test Fixtures ────────────────────────────────────────────────────────────
@pytest.fixture
def mock_repo():
return MagicMock()
"""mock 验证码仓储."""
repo = MagicMock()
repo.find_latest.return_value = None
repo.count_today.return_value = 0
return repo
@pytest.fixture
def code_service(mock_repo):
return VerificationCodeService(mock_repo)
def service(mock_repo):
"""验证码服务实例."""
return VerificationCodeService(repo=mock_repo)
@pytest.fixture
def sample_code():
code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=300,
def _make_code(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
code="123456",
ttl=300,
used=False,
attempts=0,
created_at=None,
):
"""创建一个测试用验证码实体."""
now = created_at or datetime.now(timezone.utc)
vc = VerificationCode(
id="test-code-id",
recipient=recipient,
code=code,
code_type=code_type,
expires_at=now + timedelta(seconds=ttl),
used_at=now if used else None,
attempts=attempts,
created_at=now,
)
return code
return vc
class TestVerificationCodeServiceGenerate:
# ── generate 方法测试 ───────────────────────────────────────────────────────
class TestGenerate:
"""generate 方法测试"""
def test_generate_success(self, code_service, mock_repo, sample_code):
"""生成验证码成功"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
def test_generate_success(self, service, mock_repo):
"""成功生成验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
assert code.recipient == "test@example.com"
assert code.recipient == "user@example.com"
assert code.code_type == CODE_TYPE_EMAIL_BIND
assert len(code.code) == 6
assert code.code.isdigit()
assert not code.is_used
mock_repo.save.assert_called_once()
def test_generate_empty_recipient(self, code_service):
"""空接收方返回错误"""
code, error = code_service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "接收方不能为空" in error
def test_generate_with_custom_code(self, service, mock_repo):
"""使用自定义验证码."""
code, error = service.generate("user@example.com", CODE_TYPE_EMAIL_LOGIN, custom_code="999999")
def test_generate_invalid_type(self, code_service):
"""无效验证码类型返回错误"""
code, error = code_service.generate("test@example.com", "invalid_type")
assert error is None
assert code.code == "999999"
def test_generate_custom_ttl(self, service, mock_repo):
"""自定义 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 600
def test_generate_default_ttl(self, service, mock_repo):
"""默认 TTL."""
code, _ = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 300 # 默认5分钟
def test_generate_empty_recipient(self, service):
"""空接收方."""
code, error = service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
def test_generate_whitespace_recipient(self, service):
"""全空白接收方."""
code, error = service.generate(" ", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in error
def test_generate_invalid_type(self, service):
"""无效验证码类型."""
code, error = service.generate("u@e.com", "invalid_type")
assert code is None
assert "无效的验证码类型" in error
def test_generate_cooldown(self, code_service, mock_repo, sample_code):
"""冷却期内返回频控错误"""
# 最新的验证码刚创建10秒前
sample_code.created_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = sample_code
mock_repo.count_today.return_value = 1
def test_generate_recipient_stripped(self, service, mock_repo):
"""接收方前后空格会被清理."""
code, _ = service.generate(" user@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "user@e.com"
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
def test_generate_phone_code(self, service, mock_repo):
"""手机验证码生成."""
code, error = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
assert error is None
assert code.code_type == CODE_TYPE_PHONE_BIND
assert len(code.code) == 6
# ── generate 频控测试 ───────────────────────────────────────────────────────
class TestGenerateRateLimit:
"""generate 频控测试"""
def test_cooldown_active_rejects(self, service, mock_repo):
"""冷却期内拒绝重发."""
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in error
assert "秒后再试" in error
# 等待时间应该接近 50 秒 (60-10)
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 45 <= wait <= 55
def test_generate_daily_limit_exceeded(self, code_service, mock_repo):
"""超过每日上限返回错误"""
mock_repo.find_latest.return_value = None # 没有冷却期问题
def test_cooldown_expired_allows(self, service, mock_repo):
"""冷却期过后允许重发."""
old = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
mock_repo.find_latest.return_value = old
mock_repo.count_today.return_value = 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
def test_daily_limit_reached(self, service, mock_repo):
"""达到每日上限."""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT
code, error = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in error
def test_generate_recipient_stripped(self, code_service, mock_repo, sample_code):
"""recipient 会被 strip"""
def test_daily_limit_one_below_allows(self, service, mock_repo):
"""未达到上限时允许."""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT - 1
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert error is None
assert code is not None
def test_custom_daily_limit(self, mock_repo):
"""自定义每日上限."""
svc = VerificationCodeService(repo=mock_repo, daily_limit=3)
mock_repo.count_today.return_value = 3
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "已达上限" in error
def test_custom_cooldown(self, mock_repo):
"""自定义冷却时间."""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=30)
recent = _make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
code, error = svc.generate("u@e.com", CODE_TYPE_EMAIL_BIND)
assert code is None
match = re.search(r"(\d+)\s*秒", error)
assert match
wait = int(match.group(1))
assert 15 <= wait <= 25
def test_cooldown_different_types_independent(self, service, mock_repo):
"""不同类型的验证码冷却独立."""
# email_bind 类型有一个近期验证码
recent = _make_code(code_type=CODE_TYPE_EMAIL_BIND)
mock_repo.find_latest.side_effect = lambda r, t: recent if t == CODE_TYPE_EMAIL_BIND else None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code_service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
# 传给 repo 的应该是 strip 后的值
save_call = mock_repo.save.call_args[0][0]
assert save_call.recipient == "test@example.com"
def test_generate_custom_code(self, code_service, mock_repo):
"""使用自定义验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="123456")
assert code.code == "123456"
def test_generate_custom_ttl(self, code_service, mock_repo):
"""自定义 TTL"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
mock_repo.save.return_value = None
code, _ = code_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=600)
# email_login 类型应该可以正常发送
code, error = service.generate("u@e.com", CODE_TYPE_EMAIL_LOGIN)
assert error is None
assert code is not None
class TestVerificationCodeServiceVerify:
# ── verify 方法测试 ─────────────────────────────────────────────────────────
class TestVerify:
"""verify 方法测试"""
def test_verify_success(self, code_service, mock_repo, sample_code):
"""验证成功"""
mock_repo.find_latest.return_value = sample_code
def test_verify_success(self, service, mock_repo):
"""验证码正确."""
code = _make_code(code="654321")
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
assert success is True
ok, error = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "654321")
assert ok is True
assert error is None
assert sample_code.is_used is True
assert code.is_used # 标记为已使用
assert mock_repo.save.call_count >= 2 # increment + mark_used
def test_verify_wrong_code(self, code_service, mock_repo, sample_code):
"""验证码错误"""
mock_repo.find_latest.return_value = sample_code
def test_verify_wrong_code(self, service, mock_repo):
"""验证码错误."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrongcode")
assert success is False
ok, error = service.verify("test@e.com", CODE_TYPE_EMAIL_BIND, "000000")
assert ok is False
assert "验证码错误" in error
assert not code.is_used # 不标记为已使用
assert code.attempts == 1 # 尝试次数+1
def test_verify_not_found(self, code_service, mock_repo):
"""验证码不存在"""
def test_verify_no_code_found(self, service, mock_repo):
"""找不到验证码."""
mock_repo.find_latest.return_value = None
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
assert "不存在或已过期" in error
def test_verify_expired(self, code_service, mock_repo):
"""验证码已过期"""
expired_code = VerificationCode.create(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
ttl_seconds=1, # 1秒过期
)
# 手动设置过期时间
expired_code.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10)
mock_repo.find_latest.return_value = expired_code
def test_verify_empty_params(self, service):
"""参数为空."""
ok, error = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert ok is False
assert "参数不完整" in error
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, expired_code.code)
ok2, error2 = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "")
assert ok2 is False
assert "参数不完整" in error2
assert success is False
assert "已过期" in error
def test_verify_whitespace_params(self, service, mock_repo):
"""参数前后空格会被清理."""
code = _make_code(recipient="u@e.com", code="111111")
mock_repo.find_latest.return_value = code
def test_verify_already_used(self, code_service, mock_repo, sample_code):
"""验证码已使用"""
sample_code.mark_used()
mock_repo.find_latest.return_value = sample_code
ok, error = service.verify(" u@e.com ", CODE_TYPE_EMAIL_BIND, " 111111 ")
assert ok is True
assert error is None
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
def test_verify_already_used(self, service, mock_repo):
"""验证码已使用."""
code = _make_code(used=True)
mock_repo.find_latest.return_value = code
assert success is False
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已使用" in error
def test_verify_max_attempts_exceeded(self, code_service, mock_repo, sample_code):
"""尝试次数过多"""
# 先把尝试次数加到超过上限
for _ in range(MAX_ATTEMPTS + 1):
sample_code.increment_attempts()
mock_repo.find_latest.return_value = sample_code
def test_verify_expired(self, service, mock_repo):
"""验证码已过期."""
code = _make_code(ttl=-60) # 已过期
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code)
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已过期" in error
assert success is False
def test_verify_too_many_attempts(self, service, mock_repo):
"""尝试次数过多."""
code = _make_code(attempts=MAX_ATTEMPTS + 1)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "验证次数过多" in error
def test_verify_empty_params(self, code_service):
"""空参数返回错误"""
success, error = code_service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert success is False
assert "参数不完整" in error
def test_verify_attempts_increment_each_time(self, service, mock_repo):
"""每次错误尝试都增加尝试次数."""
code = _make_code(code="123456", attempts=0)
mock_repo.find_latest.return_value = code
success, error = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
assert success is False
assert "参数不完整" in error
for _ in range(3):
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "wrong")
def test_verify_increments_attempts(self, code_service, mock_repo, sample_code):
"""验证会增加尝试次数"""
initial_attempts = sample_code.attempts
mock_repo.find_latest.return_value = sample_code
assert code.attempts == 3
code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "wrong")
def test_verify_without_consume(self, service, mock_repo):
"""验证成功但不标记为已使用(consume=False."""
code = _make_code(code="999999")
mock_repo.find_latest.return_value = code
assert sample_code.attempts == initial_attempts + 1
def test_verify_no_consume(self, code_service, mock_repo, sample_code):
"""consume=False 时不标记为已使用"""
mock_repo.find_latest.return_value = sample_code
success, _ = code_service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, sample_code.code, consume=False)
assert success is True
assert sample_code.is_used is False
class TestVerifyPhone:
"""validate_phone 函数测试"""
def test_valid_phone(self):
"""有效手机号"""
ok, err = validate_phone("13800000001")
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "999999", consume=False)
assert ok is True
assert err == ""
assert error is None
assert not code.is_used # 不标记为已使用
def test_valid_phone_with_plus86(self):
"""带 +86 前缀的手机号"""
ok, err = validate_phone("+8613800000001")
def test_verify_consume_default_true(self, service, mock_repo):
"""默认 consume=True."""
code = _make_code(code="123456")
mock_repo.find_latest.return_value = code
service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, "123456")
assert code.is_used
def test_verify_used_checked_before_attempts(self, service, mock_repo):
"""已使用优先于其他检查."""
code = _make_code(used=True, attempts=0)
mock_repo.find_latest.return_value = code
ok, error = service.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "已使用" in error
# attempts 会被 increment,但错误原因是已使用
assert code.attempts == 1
def test_custom_max_attempts(self, mock_repo):
"""自定义最大尝试次数."""
svc = VerificationCodeService(repo=mock_repo, max_attempts=2)
code = _make_code(attempts=2)
mock_repo.find_latest.return_value = code
ok, error = svc.verify("u@e.com", CODE_TYPE_EMAIL_BIND, code.code)
assert ok is False
assert "验证次数过多" in error
# ── validate_phone 测试 ─────────────────────────────────────────────────────
class TestValidatePhone:
"""手机号格式校验测试"""
def test_valid_11_digit(self):
"""标准11位手机号."""
ok, msg = validate_phone("13800138000")
assert ok is True
assert msg == ""
def test_valid_with_plus_86(self):
"""带+86前缀."""
ok, msg = validate_phone("+8613800138000")
assert ok is True
def test_invalid_phone_short(self):
"""太短的手机号"""
ok, err = validate_phone("123")
def test_invalid_too_short(self):
"""位数不足."""
ok, msg = validate_phone("1380013800")
assert ok is False
assert "格式不正确" in err
assert "格式不正确" in msg
def test_invalid_phone_wrong_prefix(self):
"""号段不对的手机号"""
ok, err = validate_phone("11000000000")
def test_invalid_too_long(self):
"""位数过多."""
ok, msg = validate_phone("138001380001")
assert ok is False
def test_empty_phone(self):
"""空手机号"""
ok, err = validate_phone("")
def test_invalid_starts_with_2(self):
"""开头不是1."""
ok, msg = validate_phone("23800138000")
assert ok is False
assert "不能为空" in err
def test_phone_with_spaces(self):
"""带空格的手机号会被 strip"""
ok, _ = validate_phone(" 13800000001 ")
def test_invalid_starts_with_12(self):
"""第二位不在3-9."""
ok, msg = validate_phone("12800138000")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_phone("")
assert ok is False
assert "不能为空" in msg
def test_invalid_whitespace_only(self):
"""仅空白."""
ok, msg = validate_phone(" ")
assert ok is False
assert "不能为空" in msg
def test_valid_all_prefixes_3_to_9(self):
"""第二位3-9都有效."""
for n in range(3, 10):
ok, _ = validate_phone(f"1{n}800138000")
assert ok is True, f"1{n} prefix should be valid"
def test_invalid_contains_letters(self):
"""包含字母."""
ok, msg = validate_phone("13800abc000")
assert ok is False
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_phone(" 13800138000 ")
assert ok is True
# ── normalize_phone 测试 ────────────────────────────────────────────────────
class TestNormalizePhone:
"""normalize_phone 函数测试"""
"""手机号标准化测试"""
def test_removes_plus86(self):
"""去掉 +86 前缀"""
assert normalize_phone("+8613800000001") == "13800000001"
def test_strip_plus_86(self):
"""去掉+86前缀."""
assert normalize_phone("+8613800138000") == "13800138000"
def test_no_prefix_stays_same(self):
"""没有前缀保持不变"""
assert normalize_phone("13800000001") == "13800000001"
"""前缀保持不变."""
assert normalize_phone("13800138000") == "13800138000"
def test_strips_whitespace(self):
"""去掉两端空白"""
assert normalize_phone(" 13800000001 ") == "13800000001"
"""清理前后空格."""
assert normalize_phone(" 13800138000 ") == "13800138000"
def test_plus_86_with_spaces(self):
"""带空格的+86."""
assert normalize_phone(" +8613800138000 ") == "13800138000"
# ── validate_email 测试 ─────────────────────────────────────────────────────
class TestValidateEmail:
"""validate_email 函数测试"""
"""邮箱格式校验测试"""
def test_valid_email(self):
"""有效邮箱"""
ok, err = validate_email("test@example.com")
def test_valid_simple(self):
"""标准邮箱."""
ok, msg = validate_email("user@example.com")
assert ok is True
assert err == ""
assert msg == ""
def test_valid_email_with_subdomain(self):
"""子域名的邮箱"""
ok, _ = validate_email("user@mail.example.com")
def test_valid_with_dots(self):
"""点号的用户名."""
ok, _ = validate_email("user.name@example.com")
assert ok is True
def test_valid_email_with_plus(self):
""" + 号的邮箱"""
def test_valid_with_plus(self):
"""号的邮箱."""
ok, _ = validate_email("user+tag@example.com")
assert ok is True
def test_invalid_email_no_at(self):
"""没有 @ 的邮箱"""
ok, err = validate_email("notanemail")
assert ok is False
assert "格式不正确" in err
def test_invalid_email_no_domain(self):
"""没有域名的邮箱"""
ok, err = validate_email("user@")
assert ok is False
def test_empty_email(self):
"""空邮箱"""
ok, err = validate_email("")
assert ok is False
assert "不能为空" in err
def test_email_with_spaces(self):
"""带空格的邮箱会被 strip"""
ok, _ = validate_email(" test@example.com ")
def test_valid_with_underscore(self):
"""带下划线."""
ok, _ = validate_email("user_name@example.com")
assert ok is True
def test_valid_subdomain(self):
"""多级域名."""
ok, _ = validate_email("user@mail.example.com")
assert ok is True
def test_invalid_no_at(self):
"""没有@."""
ok, msg = validate_email("userexample.com")
assert ok is False
assert "格式不正确" in msg
def test_invalid_empty_local(self):
"""@前为空."""
ok, _ = validate_email("@example.com")
assert ok is False
def test_invalid_empty_domain(self):
"""@后为空."""
ok, _ = validate_email("user@")
assert ok is False
def test_invalid_no_tld(self):
"""没有顶级域名."""
ok, _ = validate_email("user@example")
assert ok is False
def test_invalid_empty(self):
"""空字符串."""
ok, msg = validate_email("")
assert ok is False
assert "不能为空" in msg
def test_invalid_spaces_only(self):
"""仅空白."""
ok, msg = validate_email(" ")
assert ok is False
assert "不能为空" in msg
def test_strips_whitespace(self):
"""前后空格会被清理."""
ok, msg = validate_email(" user@e.com ")
assert ok is True
def test_invalid_special_chars(self):
"""特殊字符."""
ok, _ = validate_email("user name@e.com")
assert ok is False
def test_valid_numbers(self):
"""数字邮箱."""
ok, _ = validate_email("12345@example.com")
assert ok is True
# ── VerificationCode 实体辅助验证 ──────────────────────────────────────────
class TestVerificationCodeEntity:
"""VerificationCode 实体属性测试"""
def test_is_expired_false_when_fresh(self):
code = _make_code(ttl=300)
assert code.is_expired is False
def test_is_expired_true_when_past(self):
code = _make_code(ttl=-1)
assert code.is_expired is True
def test_is_used_false_initially(self):
code = _make_code()
assert code.is_used is False
def test_is_used_after_mark_used(self):
code = _make_code()
code.mark_used()
assert code.is_used is True
assert code.used_at is not None
def test_is_valid_fresh(self):
code = _make_code()
assert code.is_valid is True
def test_is_valid_when_expired(self):
code = _make_code(ttl=-100)
assert code.is_valid is False
def test_is_valid_when_used(self):
code = _make_code(used=True)
assert code.is_valid is False
def test_increment_attempts(self):
code = _make_code(attempts=0)
code.increment_attempts()
assert code.attempts == 1
code.increment_attempts()
assert code.attempts == 2
def test_create_generates_6_digit_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND)
assert len(code.code) == 6
assert code.code.isdigit()
def test_create_custom_code(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, custom_code="555555")
assert code.code == "555555"
def test_create_strips_recipient(self):
code = VerificationCode.create(" u@e.com ", CODE_TYPE_EMAIL_BIND)
assert code.recipient == "u@e.com"
def test_create_sets_expiry(self):
code = VerificationCode.create("u@e.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=120)
delta = code.expires_at - code.created_at
assert delta.total_seconds() == 120