Files
xiaoxia-saas/tests/unit/test_phase8_clip_models.py
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

504 lines
20 KiB
Python

"""Unit tests for Phase 8 任务 2.02: TemplateClipConfig + EditPlanClip 数据模型."""
import pytest
from packages.domain.edit_plan_clip import (
EditPlanClip,
EditPlanClipStatus,
)
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
# ── TemplateClipConfig 领域实体测试 ─────────────────────────────────────────
class TestTemplateClipConfig:
"""TemplateClipConfig 领域实体测试"""
def test_create_basic(self):
"""基本创建"""
config = TemplateClipConfig.create(
template_id="tpl_001",
clip_type=ClipType.INTRO,
order=0,
)
assert config.template_id == "tpl_001"
assert config.clip_type == ClipType.INTRO
assert config.order == 0
assert config.min_duration == 0.0
assert config.max_duration == 0.0
assert config.text_template == ""
assert config.material_requirements == {}
assert config.transition_effect == TransitionEffect.CUT
assert config.config == {}
assert config.id # 自动生成 ID
def test_create_with_all_fields(self):
"""完整字段创建"""
config = TemplateClipConfig.create(
template_id="tpl_001",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=10.0,
text_template="欢迎使用{product_name}",
material_requirements={"type": "video", "min_resolution": "1080p"},
transition_effect=TransitionEffect.FADE,
config={"speed": 1.0},
)
assert config.min_duration == 3.0
assert config.max_duration == 10.0
assert config.text_template == "欢迎使用{product_name}"
assert config.material_requirements == {"type": "video", "min_resolution": "1080p"}
assert config.transition_effect == TransitionEffect.FADE
assert config.config == {"speed": 1.0}
def test_create_with_string_enum_values(self):
"""字符串枚举值创建"""
config = TemplateClipConfig.create(
template_id="tpl_001",
clip_type="outro",
order=2,
transition_effect="slide_left",
)
assert config.clip_type == ClipType.OUTRO
assert config.transition_effect == TransitionEffect.SLIDE_LEFT
def test_create_empty_template_id_raises(self):
"""空 template_id 报错"""
with pytest.raises(ValueError, match="template_id 不能为空"):
TemplateClipConfig.create(template_id="", clip_type=ClipType.INTRO, order=0)
def test_create_whitespace_template_id_raises(self):
"""空白 template_id 报错"""
with pytest.raises(ValueError, match="template_id 不能为空"):
TemplateClipConfig.create(template_id=" ", clip_type=ClipType.INTRO, order=0)
def test_create_negative_min_duration_raises(self):
"""负数 min_duration 报错"""
with pytest.raises(ValueError, match="min_duration 不能为负数"):
TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=-1.0)
def test_create_negative_max_duration_raises(self):
"""负数 max_duration 报错"""
with pytest.raises(ValueError, match="max_duration 不能为负数"):
TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=-1.0)
def test_create_min_greater_than_max_raises(self):
"""min_duration > max_duration 报错"""
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
TemplateClipConfig.create(
template_id="tpl_001",
clip_type=ClipType.INTRO,
order=0,
min_duration=10.0,
max_duration=5.0,
)
def test_has_duration_range(self):
"""has_duration_range 属性"""
config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
assert config_no_range.has_duration_range is False
config_with_range = TemplateClipConfig.create(
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=3.0, max_duration=10.0
)
assert config_with_range.has_duration_range is True
def test_default_duration(self):
"""default_duration 属性"""
# 无时长范围
config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
assert config_no_range.default_duration == 0.0
# 只有 min
config_min_only = TemplateClipConfig.create(
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=5.0
)
assert config_min_only.default_duration == 5.0
# 只有 max
config_max_only = TemplateClipConfig.create(
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=10.0
)
assert config_max_only.default_duration == 10.0
# 两者都有 → 平均值
config_both = TemplateClipConfig.create(
template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=4.0, max_duration=10.0
)
assert config_both.default_duration == 7.0
def test_clip_type_enum_values(self):
"""ClipType 枚举值"""
assert ClipType.INTRO == "intro"
assert ClipType.MAIN == "main"
assert ClipType.TRANSITION == "transition"
assert ClipType.OUTRO == "outro"
assert ClipType.TITLE == "title"
assert ClipType.SUBTITLE == "subtitle"
def test_transition_effect_enum_values(self):
"""TransitionEffect 枚举值"""
assert TransitionEffect.CUT == "cut"
assert TransitionEffect.FADE == "fade"
assert TransitionEffect.SLIDE_LEFT == "slide_left"
assert TransitionEffect.SLIDE_RIGHT == "slide_right"
assert TransitionEffect.DISSOLVE == "dissolve"
assert TransitionEffect.WIPE == "wipe"
def test_timestamps_auto_set(self):
"""创建时自动设置时间戳"""
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
assert config.created_at is not None
assert config.updated_at is not None
# ── EditPlanClip 领域实体测试 ───────────────────────────────────────────────
class TestEditPlanClip:
"""EditPlanClip 领域实体测试"""
def test_create_basic(self):
"""基本创建"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
assert clip.plan_id == "plan_001"
assert clip.clip_type == "main"
assert clip.order == 0
assert clip.status == EditPlanClipStatus.PENDING
assert clip.template_clip_config_id == ""
assert clip.asset_id == ""
assert clip.text_content == ""
assert clip.start_time == 0.0
assert clip.duration == 0.0
assert clip.transition_effect == "cut"
assert clip.config == {}
assert clip.id
def test_create_with_all_fields(self):
"""完整字段创建"""
clip = EditPlanClip.create(
plan_id="plan_001",
clip_type="intro",
order=0,
template_clip_config_id="cfg_001",
asset_id="asset_001",
text_content="欢迎",
start_time=0.0,
duration=5.0,
transition_effect="fade",
config={"zoom": 1.2},
)
assert clip.template_clip_config_id == "cfg_001"
assert clip.asset_id == "asset_001"
assert clip.text_content == "欢迎"
assert clip.duration == 5.0
assert clip.transition_effect == "fade"
assert clip.config == {"zoom": 1.2}
def test_create_empty_plan_id_raises(self):
"""空 plan_id 报错"""
with pytest.raises(ValueError, match="plan_id 不能为空"):
EditPlanClip.create(plan_id="", clip_type="main", order=0)
def test_create_empty_clip_type_raises(self):
"""空 clip_type 报错"""
with pytest.raises(ValueError, match="clip_type 不能为空"):
EditPlanClip.create(plan_id="plan_001", clip_type="", order=0)
def test_create_negative_start_time_raises(self):
"""负数 start_time 报错"""
with pytest.raises(ValueError, match="start_time 不能为负数"):
EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, start_time=-1.0)
def test_create_negative_duration_raises(self):
"""负数 duration 报错"""
with pytest.raises(ValueError, match="duration 不能为负数"):
EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, duration=-1.0)
def test_assign_asset(self):
"""分配素材"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
assert clip.has_asset is False
clip.assign_asset("asset_001")
assert clip.asset_id == "asset_001"
assert clip.has_asset is True
def test_assign_asset_empty_raises(self):
"""分配空素材报错"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
with pytest.raises(ValueError, match="asset_id 不能为空"):
clip.assign_asset("")
def test_status_transitions(self):
"""状态流转: pending → ready → rendered"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
assert clip.status == EditPlanClipStatus.PENDING
clip.mark_ready()
assert clip.status == EditPlanClipStatus.READY
clip.mark_rendered()
assert clip.status == EditPlanClipStatus.RENDERED
def test_status_transition_pending_to_failed(self):
"""状态流转: pending → ready → failed"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
clip.mark_ready()
clip.mark_failed()
assert clip.status == EditPlanClipStatus.FAILED
def test_mark_ready_from_non_pending_raises(self):
"""非 pending 状态标记 ready 报错"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
clip.mark_ready()
with pytest.raises(ValueError, match="只有 pending 状态"):
clip.mark_ready()
def test_mark_rendered_from_non_ready_raises(self):
"""非 ready 状态标记 rendered 报错"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
with pytest.raises(ValueError, match="只有 ready 状态"):
clip.mark_rendered()
def test_mark_failed_from_non_ready_raises(self):
"""非 ready 状态标记 failed 报错"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
with pytest.raises(ValueError, match="只有 ready 状态"):
clip.mark_failed()
def test_end_time_property(self):
"""end_time 属性"""
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, start_time=5.0, duration=10.0)
assert clip.end_time == 15.0
def test_has_asset_property(self):
"""has_asset 属性"""
clip_no_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
assert clip_no_asset.has_asset is False
clip_with_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, asset_id="asset_001")
assert clip_with_asset.has_asset is True
def test_edit_plan_clip_status_enum(self):
"""EditPlanClipStatus 枚举值"""
assert EditPlanClipStatus.PENDING == "pending"
assert EditPlanClipStatus.READY == "ready"
assert EditPlanClipStatus.RENDERED == "rendered"
assert EditPlanClipStatus.FAILED == "failed"
# ── Repository 集成测试(使用 SQLite 内存数据库)────────────────────────────
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
SQLAlchemyEditPlanClipRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
@pytest.fixture
def db_session():
"""创建内存数据库 session"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
class TestTemplateClipConfigRepository:
"""TemplateClipConfig 仓储测试"""
def test_create_and_get(self, db_session):
"""创建并获取"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=2.0)
created = repo.create(config)
assert created.id == config.id
fetched = repo.get(config.id)
assert fetched is not None
assert fetched.template_id == "tpl_001"
assert fetched.clip_type == ClipType.INTRO
assert fetched.min_duration == 2.0
def test_list_by_template(self, db_session):
"""按模板列出"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
for i in range(3):
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i))
repo.create(TemplateClipConfig.create(template_id="tpl_002", clip_type=ClipType.INTRO, order=0))
results = repo.list_by_template("tpl_001")
assert len(results) == 3
assert all(r.template_id == "tpl_001" for r in results)
# 按 order 排序
assert results[0].order == 0
assert results[1].order == 1
assert results[2].order == 2
def test_list_by_template_with_clip_type_filter(self, db_session):
"""按模板+类型过滤"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0))
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1))
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=2))
results = repo.list_by_template("tpl_001", clip_type=ClipType.MAIN)
assert len(results) == 2
assert all(r.clip_type == ClipType.MAIN for r in results)
def test_update(self, db_session):
"""更新"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
repo.create(config)
config.min_duration = 5.0
config.max_duration = 15.0
config.text_template = "Hello {name}"
updated = repo.update(config)
assert updated.min_duration == 5.0
assert updated.max_duration == 15.0
assert updated.text_template == "Hello {name}"
def test_delete(self, db_session):
"""删除"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0)
repo.create(config)
assert repo.delete(config.id) is True
assert repo.get(config.id) is None
assert repo.delete("nonexistent") is False
def test_delete_by_template(self, db_session):
"""按模板批量删除"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
for i in range(3):
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i))
deleted = repo.delete_by_template("tpl_001")
assert deleted == 3
assert repo.count(template_id="tpl_001") == 0
def test_count(self, db_session):
"""统计"""
repo = SQLAlchemyTemplateClipConfigRepository(db_session)
assert repo.count() == 0
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0))
repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=1))
assert repo.count() == 2
assert repo.count(template_id="tpl_001") == 2
assert repo.count(template_id="tpl_999") == 0
class TestEditPlanClipRepository:
"""EditPlanClip 仓储测试"""
def test_create_and_get(self, db_session):
"""创建并获取"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, duration=5.0)
created = repo.create(clip)
assert created.id == clip.id
fetched = repo.get(clip.id)
assert fetched is not None
assert fetched.plan_id == "plan_001"
assert fetched.clip_type == "main"
assert fetched.duration == 5.0
assert fetched.status == EditPlanClipStatus.PENDING
def test_list_by_plan(self, db_session):
"""按计划列出"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
for i in range(3):
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i))
repo.create(EditPlanClip.create(plan_id="plan_002", clip_type="intro", order=0))
results = repo.list_by_plan("plan_001")
assert len(results) == 3
assert all(r.plan_id == "plan_001" for r in results)
assert results[0].order == 0
assert results[1].order == 1
assert results[2].order == 2
def test_list_by_plan_with_status_filter(self, db_session):
"""按计划+状态过滤"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
clip1 = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
clip2 = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=1)
repo.create(clip1)
repo.create(clip2)
clip1.mark_ready()
repo.update(clip1)
results = repo.list_by_plan("plan_001", status=EditPlanClipStatus.READY)
assert len(results) == 1
assert results[0].status == EditPlanClipStatus.READY
def test_update(self, db_session):
"""更新"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
repo.create(clip)
clip.assign_asset("asset_001")
clip.duration = 10.0
updated = repo.update(clip)
assert updated.asset_id == "asset_001"
assert updated.duration == 10.0
def test_delete(self, db_session):
"""删除"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
repo.create(clip)
assert repo.delete(clip.id) is True
assert repo.get(clip.id) is None
assert repo.delete("nonexistent") is False
def test_delete_by_plan(self, db_session):
"""按计划批量删除"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
for i in range(3):
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i))
deleted = repo.delete_by_plan("plan_001")
assert deleted == 3
assert repo.count(plan_id="plan_001") == 0
def test_count(self, db_session):
"""统计"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
assert repo.count() == 0
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0))
repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=1))
assert repo.count() == 2
assert repo.count(plan_id="plan_001") == 2
assert repo.count(plan_id="plan_999") == 0
def test_count_with_status(self, db_session):
"""按状态统计"""
repo = SQLAlchemyEditPlanClipRepository(db_session)
clip1 = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0)
clip2 = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=1)
repo.create(clip1)
repo.create(clip2)
clip1.mark_ready()
repo.update(clip1)
assert repo.count(status=EditPlanClipStatus.PENDING) == 1
assert repo.count(status=EditPlanClipStatus.READY) == 1