diff --git a/alembic/versions/016_phase8_edit_template_plan.py b/alembic/versions/016_phase8_edit_template_plan.py new file mode 100644 index 000000000..0c83a8f7a --- /dev/null +++ b/alembic/versions/016_phase8_edit_template_plan.py @@ -0,0 +1,115 @@ +"""phase8 edit template plan + +Revision ID: 016 +Revises: 015 +Create Date: 2026-07-01 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "016" +down_revision = "015" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- edit_templates: 替换为 Phase 8 新 schema --- + # 删除旧列 + op.drop_column("edit_templates", "project_id") + op.drop_column("edit_templates", "target_duration") + op.drop_column("edit_templates", "clip_count") + op.drop_column("edit_templates", "is_active") + op.drop_column("edit_templates", "created_by_user_id") + op.drop_column("edit_templates", "metadata") + + # 添加新列 + op.add_column( + "edit_templates", + sa.Column("template_type", sa.String(50), nullable=False, server_default="default"), + ) + op.add_column( + "edit_templates", + sa.Column("config", sa.JSON(), nullable=False, server_default="{}"), + ) + op.add_column( + "edit_templates", + sa.Column("preview_url", sa.String(1000), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("sort_weight", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "edit_templates", + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + ) + + # 添加索引 + op.create_index("ix_edit_templates_template_type", "edit_templates", ["template_type"]) + op.create_index("ix_edit_templates_sort_weight", "edit_templates", ["sort_weight"]) + op.create_index("ix_edit_templates_status", "edit_templates", ["status"]) + + # --- edit_plans: 重建表(在 011 中被删除) --- + op.create_table( + "edit_plans", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("template_id", sa.String(32), nullable=False, index=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True), + sa.Column("total_duration", sa.Float(), nullable=False, server_default="0"), + sa.Column("config", sa.JSON(), nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("edit_plans") + + op.drop_index("ix_edit_templates_status", "edit_templates") + op.drop_index("ix_edit_templates_sort_weight", "edit_templates") + op.drop_index("ix_edit_templates_template_type", "edit_templates") + + op.drop_column("edit_templates", "status") + op.drop_column("edit_templates", "sort_weight") + op.drop_column("edit_templates", "preview_url") + op.drop_column("edit_templates", "config") + op.drop_column("edit_templates", "template_type") + + # 恢复旧列 + op.add_column( + "edit_templates", + sa.Column("project_id", sa.String(32), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"), + ) + op.add_column( + "edit_templates", + sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"), + ) + op.add_column( + "edit_templates", + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "edit_templates", + sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"), + ) diff --git a/packages/adapters/sqlalchemy_impl/__init__.py b/packages/adapters/sqlalchemy_impl/__init__.py index 12a24f279..be20854ca 100644 --- a/packages/adapters/sqlalchemy_impl/__init__.py +++ b/packages/adapters/sqlalchemy_impl/__init__.py @@ -3,6 +3,8 @@ from .asset_library_repository import SQLAlchemyAssetLibraryRepository from .asset_repository import SQLAlchemyAssetRepository from .classification_job_repository import SQLAlchemyClassificationJobRepository +from .edit_plan_repository import SQLAlchemyEditPlanRepository +from .edit_template_repository import SQLAlchemyEditTemplateRepository from .generated_video_repository import SQLAlchemyGeneratedVideoRepository from .generation_task_repository import SQLAlchemyGenerationTaskRepository from .ingest_job_repository import SQLAlchemyIngestJobRepository @@ -20,6 +22,8 @@ __all__ = [ "SQLAlchemyAssetLibraryRepository", "SQLAlchemyAssetRepository", "SQLAlchemyClassificationJobRepository", + "SQLAlchemyEditPlanRepository", + "SQLAlchemyEditTemplateRepository", "SQLAlchemyGeneratedVideoRepository", "SQLAlchemyGenerationTaskRepository", "SQLAlchemyIngestJobRepository", diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py new file mode 100644 index 000000000..9cb9e5d16 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py @@ -0,0 +1,128 @@ +"""SQLAlchemy implementation of EditPlanRepository.""" + +from __future__ import annotations + +from typing import List, Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import EditPlanModel +from packages.domain.edit_plan import EditPlan, EditPlanStatus + + +class SQLAlchemyEditPlanRepository: + """SQLAlchemy 剪辑计划仓储""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list_by_template( + self, + template_id: str, + *, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + """按模板列出剪辑计划""" + query = self.session.query(EditPlanModel).filter( + EditPlanModel.template_id == template_id, + ) + if status: + query = query.filter(EditPlanModel.status == status) + query = query.order_by(EditPlanModel.created_at.desc()) + models = query.offset(skip).limit(limit).all() + return [self._model_to_entity(m) for m in models] + + def list_all( + self, + *, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + """列出所有剪辑计划""" + query = self.session.query(EditPlanModel) + if status: + query = query.filter(EditPlanModel.status == status) + query = query.order_by(EditPlanModel.created_at.desc()) + models = query.offset(skip).limit(limit).all() + return [self._model_to_entity(m) for m in models] + + def get(self, plan_id: str) -> Optional[EditPlan]: + """根据 ID 获取计划""" + model = ( + self.session.query(EditPlanModel) + .filter(EditPlanModel.id == plan_id) + .first() + ) + if model is None: + return None + return self._model_to_entity(model) + + def create(self, plan: EditPlan) -> EditPlan: + """创建计划""" + model = EditPlanModel( + id=plan.id, + template_id=plan.template_id, + name=plan.name, + status=plan.status, + total_duration=plan.total_duration, + config=plan.config, + ) + self.session.add(model) + self.session.commit() + self.session.refresh(model) + return self._model_to_entity(model) + + def update(self, plan: EditPlan) -> EditPlan: + """更新计划""" + model = ( + self.session.query(EditPlanModel) + .filter(EditPlanModel.id == plan.id) + .first() + ) + if model is None: + raise ValueError(f"EditPlan {plan.id} not found") + model.template_id = plan.template_id + model.name = plan.name + model.status = plan.status + model.total_duration = plan.total_duration + model.config = plan.config + model.updated_at = plan.updated_at + self.session.commit() + self.session.refresh(model) + return self._model_to_entity(model) + + def delete(self, plan_id: str) -> bool: + """删除计划""" + model = ( + self.session.query(EditPlanModel) + .filter(EditPlanModel.id == plan_id) + .first() + ) + if model is None: + return False + self.session.delete(model) + self.session.commit() + return True + + def count(self, *, status: Optional[EditPlanStatus] = None) -> int: + """统计计划数量""" + query = self.session.query(EditPlanModel) + if status: + query = query.filter(EditPlanModel.status == status) + return query.count() + + @staticmethod + def _model_to_entity(model: EditPlanModel) -> EditPlan: + return EditPlan( + id=model.id, + template_id=model.template_id, + name=model.name, + status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT, + total_duration=model.total_duration or 0.0, + config=model.config or {}, + created_at=model.created_at, + updated_at=model.updated_at, + ) diff --git a/packages/adapters/sqlalchemy_impl/edit_template_repository.py b/packages/adapters/sqlalchemy_impl/edit_template_repository.py new file mode 100644 index 000000000..668088db2 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/edit_template_repository.py @@ -0,0 +1,142 @@ +"""SQLAlchemy implementation of EditTemplateRepository.""" + +from __future__ import annotations + +from typing import List, Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import EditTemplateModel +from packages.domain.edit_template import EditTemplate, EditTemplateStatus + + +class SQLAlchemyEditTemplateRepository: + """SQLAlchemy 剪辑模板仓储""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list_active( + self, + *, + template_type: Optional[str] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditTemplate]: + """列出所有激活的模板""" + query = self.session.query(EditTemplateModel).filter( + EditTemplateModel.status == EditTemplateStatus.ACTIVE, + ) + if template_type: + query = query.filter(EditTemplateModel.template_type == template_type) + query = query.order_by( + EditTemplateModel.sort_weight.desc(), + EditTemplateModel.created_at.desc(), + ) + models = query.offset(skip).limit(limit).all() + return [self._model_to_entity(m) for m in models] + + def list_all( + self, + *, + template_type: Optional[str] = None, + status: Optional[EditTemplateStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditTemplate]: + """列出所有模板(含非激活)""" + query = self.session.query(EditTemplateModel) + if template_type: + query = query.filter(EditTemplateModel.template_type == template_type) + if status: + query = query.filter(EditTemplateModel.status == status) + query = query.order_by( + EditTemplateModel.sort_weight.desc(), + EditTemplateModel.created_at.desc(), + ) + models = query.offset(skip).limit(limit).all() + return [self._model_to_entity(m) for m in models] + + def get(self, template_id: str) -> Optional[EditTemplate]: + """根据 ID 获取模板""" + model = ( + self.session.query(EditTemplateModel) + .filter(EditTemplateModel.id == template_id) + .first() + ) + if model is None: + return None + return self._model_to_entity(model) + + def create(self, template: EditTemplate) -> EditTemplate: + """创建模板""" + model = EditTemplateModel( + id=template.id, + name=template.name, + description=template.description, + template_type=template.template_type, + config=template.config, + preview_url=template.preview_url, + sort_weight=template.sort_weight, + status=template.status, + ) + self.session.add(model) + self.session.commit() + self.session.refresh(model) + return self._model_to_entity(model) + + def update(self, template: EditTemplate) -> EditTemplate: + """更新模板""" + model = ( + self.session.query(EditTemplateModel) + .filter(EditTemplateModel.id == template.id) + .first() + ) + if model is None: + raise ValueError(f"EditTemplate {template.id} not found") + model.name = template.name + model.description = template.description + model.template_type = template.template_type + model.config = template.config + model.preview_url = template.preview_url + model.sort_weight = template.sort_weight + model.status = template.status + model.updated_at = template.updated_at + self.session.commit() + self.session.refresh(model) + return self._model_to_entity(model) + + def delete(self, template_id: str) -> bool: + """删除模板""" + model = ( + self.session.query(EditTemplateModel) + .filter(EditTemplateModel.id == template_id) + .first() + ) + if model is None: + return False + self.session.delete(model) + self.session.commit() + return True + + def count(self, *, status: Optional[EditTemplateStatus] = None) -> int: + """统计模板数量""" + query = self.session.query(EditTemplateModel) + if status: + query = query.filter(EditTemplateModel.status == status) + return query.count() + + @staticmethod + def _model_to_entity(model: EditTemplateModel) -> EditTemplate: + return EditTemplate( + id=model.id, + name=model.name, + description=model.description or "", + template_type=model.template_type or "default", + config=model.config or {}, + preview_url=model.preview_url or "", + sort_weight=model.sort_weight or 0, + status=EditTemplateStatus(model.status) if model.status else EditTemplateStatus.ACTIVE, + created_at=model.created_at, + updated_at=model.updated_at, + ) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 961f9359f..8d69241a5 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -86,17 +86,39 @@ class AssetModel(Base): class EditTemplateModel(Base): + """Phase 8 剪辑模板 ORM 模型 + + 全局模板库中的模板,定义剪辑风格、配置参数和预览信息。 + """ + __tablename__ = "edit_templates" id = Column(String(32), primary_key=True) - project_id = Column(String(32), nullable=False, index=True) name = Column(String(120), nullable=False) description = Column(Text, nullable=False, default="") - target_duration = Column(Float, nullable=False, default=30) - clip_count = Column(Integer, nullable=False, default=3) - is_active = Column(Boolean, nullable=False, default=True) - created_by_user_id = Column(String(32), nullable=False, default="") - extra_meta = Column("metadata", JSON, nullable=False, default=dict) + template_type = Column(String(50), nullable=False, default="default", index=True) + config = Column(JSON, nullable=False, default=dict) + preview_url = Column(String(1000), nullable=False, default="") + sort_weight = Column(Integer, nullable=False, default=0, index=True) + status = Column(String(20), nullable=False, default="active", index=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class EditPlanModel(Base): + """Phase 8 剪辑计划 ORM 模型 + + 基于某个 EditTemplate 创建的剪辑计划,包含具体的配置和状态追踪。 + """ + + __tablename__ = "edit_plans" + + id = Column(String(32), primary_key=True) + template_id = Column(String(32), nullable=False, index=True) + name = Column(String(200), nullable=False) + status = Column(String(20), nullable=False, default="draft", index=True) + total_duration = Column(Float, nullable=False, default=0.0) + config = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py index f056ec3f2..a5eb07de8 100644 --- a/packages/domain/__init__.py +++ b/packages/domain/__init__.py @@ -7,6 +7,8 @@ from .classification import ( ) from .duplication import DuplicateSegment, DuplicationRecord from .editing_mode import EditingMode +from .edit_plan import EditPlan, EditPlanStatus +from .edit_template import EditTemplate, EditTemplateStatus from .entities import ( Asset, AssetLibrary, @@ -35,6 +37,10 @@ __all__ = [ "DuplicateSegment", "DuplicationRecord", "EditingMode", + "EditPlan", + "EditPlanStatus", + "EditTemplate", + "EditTemplateStatus", "GeneratedVideo", "GenerationTask", "GenerationTaskStatus", diff --git a/packages/domain/edit_plan.py b/packages/domain/edit_plan.py new file mode 100644 index 000000000..9e8b62527 --- /dev/null +++ b/packages/domain/edit_plan.py @@ -0,0 +1,106 @@ +"""EditPlan domain entity for Phase 8 模板编排引擎.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +from typing import Any +from uuid import uuid4 + + +class EditPlanStatus(StrEnum): + """剪辑计划状态""" + + DRAFT = "draft" + EDITING = "editing" + RENDERING = "rendering" + COMPLETED = "completed" + FAILED = "failed" + + +@dataclass(slots=True) +class EditPlan: + """Phase 8 剪辑计划实体 + + 基于某个 EditTemplate 创建的剪辑计划,包含具体的配置和状态追踪。 + 状态流转:draft → editing → rendering → completed / failed + """ + + id: str + template_id: str + name: str + status: EditPlanStatus = EditPlanStatus.DRAFT + total_duration: float = 0.0 + config: dict[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + template_id: str, + name: str, + *, + config: dict[str, Any] | None = None, + total_duration: float = 0.0, + ) -> EditPlan: + """创建新剪辑计划实例""" + clean_name = name.strip() + if not clean_name: + raise ValueError("计划名称不能为空") + if not template_id.strip(): + raise ValueError("template_id 不能为空") + return cls( + id=uuid4().hex, + template_id=template_id.strip(), + name=clean_name, + status=EditPlanStatus.DRAFT, + total_duration=total_duration, + config=config or {}, + ) + + def start_editing(self) -> None: + """开始编辑""" + if self.status != EditPlanStatus.DRAFT: + raise ValueError(f"只有 draft 状态的计划可以开始编辑,当前状态: {self.status}") + self.status = EditPlanStatus.EDITING + self.updated_at = datetime.now(timezone.utc) + + def start_rendering(self) -> None: + """开始渲染""" + if self.status != EditPlanStatus.EDITING: + raise ValueError(f"只有 editing 状态的计划可以开始渲染,当前状态: {self.status}") + self.status = EditPlanStatus.RENDERING + self.updated_at = datetime.now(timezone.utc) + + def mark_completed(self) -> None: + """标记为完成""" + if self.status != EditPlanStatus.RENDERING: + raise ValueError(f"只有 rendering 状态的计划可以标记完成,当前状态: {self.status}") + self.status = EditPlanStatus.COMPLETED + self.updated_at = datetime.now(timezone.utc) + + def mark_failed(self) -> None: + """标记为失败""" + if self.status != EditPlanStatus.RENDERING: + raise ValueError(f"只有 rendering 状态的计划可以标记失败,当前状态: {self.status}") + self.status = EditPlanStatus.FAILED + self.updated_at = datetime.now(timezone.utc) + + def reset_to_draft(self) -> None: + """重置为草稿状态(仅从 failed 状态可重置)""" + if self.status != EditPlanStatus.FAILED: + raise ValueError(f"只有 failed 状态的计划可以重置,当前状态: {self.status}") + self.status = EditPlanStatus.DRAFT + self.updated_at = datetime.now(timezone.utc) diff --git a/packages/domain/edit_template.py b/packages/domain/edit_template.py new file mode 100644 index 000000000..259515003 --- /dev/null +++ b/packages/domain/edit_template.py @@ -0,0 +1,88 @@ +"""EditTemplate domain entity for Phase 8 模板编排引擎.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +from typing import Any +from uuid import uuid4 + + +class EditTemplateStatus(StrEnum): + """模板状态""" + + ACTIVE = "active" + INACTIVE = "inactive" + + +@dataclass(slots=True) +class EditTemplate: + """Phase 8 剪辑模板实体 + + 全局模板库中的模板,定义剪辑风格、配置参数和预览信息。 + 不绑定到具体项目,可被多个 EditPlan 引用。 + """ + + id: str + name: str + description: str = "" + template_type: str = "default" + config: dict[str, Any] = field(default_factory=dict) + preview_url: str = "" + sort_weight: int = 0 + status: EditTemplateStatus = EditTemplateStatus.ACTIVE + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + name: str, + *, + description: str = "", + template_type: str = "default", + config: dict[str, Any] | None = None, + preview_url: str = "", + sort_weight: int = 0, + status: EditTemplateStatus = EditTemplateStatus.ACTIVE, + ) -> EditTemplate: + """创建新模板实例""" + clean_name = name.strip() + if not clean_name: + raise ValueError("模板名称不能为空") + return cls( + id=uuid4().hex, + name=clean_name, + description=description.strip(), + template_type=template_type.strip() or "default", + config=config or {}, + preview_url=preview_url.strip(), + sort_weight=sort_weight, + status=status, + ) + + def activate(self) -> None: + """激活模板""" + self.status = EditTemplateStatus.ACTIVE + self.updated_at = datetime.now(timezone.utc) + + def deactivate(self) -> None: + """停用模板""" + self.status = EditTemplateStatus.INACTIVE + self.updated_at = datetime.now(timezone.utc) + + @property + def is_active(self) -> bool: + """模板是否处于激活状态""" + return self.status == EditTemplateStatus.ACTIVE diff --git a/tests/unit/test_phase8_edit_models.py b/tests/unit/test_phase8_edit_models.py new file mode 100644 index 000000000..bb84b0788 --- /dev/null +++ b/tests/unit/test_phase8_edit_models.py @@ -0,0 +1,209 @@ +"""Unit tests for Phase 8 EditTemplate and EditPlan domain entities + repositories.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( + SQLAlchemyEditPlanRepository, +) +from packages.adapters.sqlalchemy_impl.edit_template_repository import ( + SQLAlchemyEditTemplateRepository, +) +from packages.adapters.sqlalchemy_impl.models import Base +from packages.domain.edit_plan import EditPlan, EditPlanStatus +from packages.domain.edit_template import EditTemplate, EditTemplateStatus + + +# ── EditTemplate 领域实体测试 ────────────────────────────────────────── + + +class TestEditTemplate: + def test_create_success(self): + t = EditTemplate.create("vlog模板", description="日常vlog", template_type="vlog") + assert t.name == "vlog模板" + assert t.description == "日常vlog" + assert t.template_type == "vlog" + assert t.status == EditTemplateStatus.ACTIVE + assert t.config == {} + assert t.sort_weight == 0 + assert t.id # 自动生成 + + def test_create_strips_whitespace(self): + t = EditTemplate.create(" 模板 ") + assert t.name == "模板" + + def test_create_empty_name_raises(self): + try: + EditTemplate.create(" ") + assert False, "应该抛出 ValueError" + except ValueError as e: + assert "模板名称不能为空" in str(e) + + def test_activate_deactivate(self): + t = EditTemplate.create("test") + assert t.is_active is True + t.deactivate() + assert t.is_active is False + assert t.status == EditTemplateStatus.INACTIVE + t.activate() + assert t.is_active is True + assert t.status == EditTemplateStatus.ACTIVE + + +# ── EditPlan 领域实体测试 ────────────────────────────────────────────── + + +class TestEditPlan: + def test_create_success(self): + p = EditPlan.create("tpl-1", "我的计划", config={"bgm": "happy"}) + assert p.template_id == "tpl-1" + assert p.name == "我的计划" + assert p.status == EditPlanStatus.DRAFT + assert p.config == {"bgm": "happy"} + assert p.total_duration == 0.0 + + def test_create_empty_name_raises(self): + try: + EditPlan.create("tpl-1", " ") + assert False, "应该抛出 ValueError" + except ValueError as e: + assert "计划名称不能为空" in str(e) + + def test_create_empty_template_id_raises(self): + try: + EditPlan.create(" ", "test") + assert False, "应该抛出 ValueError" + except ValueError as e: + assert "template_id 不能为空" in str(e) + + def test_status_transitions_happy_path(self): + p = EditPlan.create("tpl-1", "test") + assert p.status == EditPlanStatus.DRAFT + + p.start_editing() + assert p.status == EditPlanStatus.EDITING + + p.start_rendering() + assert p.status == EditPlanStatus.RENDERING + + p.mark_completed() + assert p.status == EditPlanStatus.COMPLETED + + def test_status_transitions_failure_path(self): + p = EditPlan.create("tpl-1", "test") + p.start_editing() + p.start_rendering() + p.mark_failed() + assert p.status == EditPlanStatus.FAILED + + p.reset_to_draft() + assert p.status == EditPlanStatus.DRAFT + + def test_invalid_transition_raises(self): + p = EditPlan.create("tpl-1", "test") + try: + p.start_rendering() # draft → rendering 不合法 + assert False, "应该抛出 ValueError" + except ValueError: + pass + + def test_mark_completed_from_non_rendering_raises(self): + p = EditPlan.create("tpl-1", "test") + try: + p.mark_completed() # draft → completed 不合法 + assert False, "应该抛出 ValueError" + except ValueError: + pass + + def test_reset_from_non_failed_raises(self): + p = EditPlan.create("tpl-1", "test") + try: + p.reset_to_draft() # draft → draft 不合法 + assert False, "应该抛出 ValueError" + except ValueError: + pass + + +# ── Repository 集成测试(内存 SQLite) ───────────────────────────────── + + +class TestRepositories: + def _make_session(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine) + return SessionLocal() + + def test_edit_template_repository_crud(self): + session = self._make_session() + try: + repo = SQLAlchemyEditTemplateRepository(session) + + t = EditTemplate.create("模板A", description="测试模板") + repo.create(t) + + fetched = repo.get(t.id) + assert fetched is not None + assert fetched.name == "模板A" + assert fetched.description == "测试模板" + + # list + active = repo.list_active() + assert len(active) == 1 + assert active[0].id == t.id + + # update + fetched.deactivate() + repo.update(fetched) + assert repo.get(t.id).status == EditTemplateStatus.INACTIVE + assert len(repo.list_active()) == 0 + + # count + assert repo.count() == 1 + + # delete + assert repo.delete(t.id) is True + assert repo.get(t.id) is None + assert repo.count() == 0 + finally: + session.close() + + def test_edit_plan_repository_crud(self): + session = self._make_session() + try: + repo = SQLAlchemyEditPlanRepository(session) + + p = EditPlan.create("tpl-1", "计划A", config={"key": "val"}) + repo.create(p) + + fetched = repo.get(p.id) + assert fetched is not None + assert fetched.name == "计划A" + assert fetched.config == {"key": "val"} + assert fetched.status == EditPlanStatus.DRAFT + + # list_by_template + plans = repo.list_by_template("tpl-1") + assert len(plans) == 1 + + # update status + fetched.start_editing() + repo.update(fetched) + editing = repo.list_by_template("tpl-1", status=EditPlanStatus.EDITING) + assert len(editing) == 1 + + # count + assert repo.count() == 1 + assert repo.count(status=EditPlanStatus.EDITING) == 1 + assert repo.count(status=EditPlanStatus.DRAFT) == 0 + + # delete + assert repo.delete(p.id) is True + assert repo.get(p.id) is None + finally: + session.close()