"""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