use serde::{Deserialize, Serialize}; use crate::error::{PluginError, PluginResult}; /// 插件清单 — 从 TOML 文件解析 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginManifest { pub metadata: PluginMetadata, pub schema: Option, pub events: Option, pub ui: Option, pub permissions: Option>, /// 插件配置项声明 — 平台自动生成配置页面 #[serde(default)] pub settings: Option, /// 编号规则声明 — 绑定实体字段到自动编号 #[serde(default)] pub numbering: Option>, /// 打印模板声明 #[serde(default)] pub templates: Option>, /// 触发事件声明 — 数据 CRUD 时自动发布域事件 #[serde(default)] pub trigger_events: Option>, } /// 插件元数据 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginMetadata { pub id: String, pub name: String, pub version: String, #[serde(default)] pub description: String, #[serde(default)] pub author: String, #[serde(default)] pub min_platform_version: Option, #[serde(default)] pub dependencies: Vec, } /// 插件 Schema — 定义动态实体 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginSchema { pub entities: Vec, } /// 插件实体定义 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginEntity { pub name: String, pub display_name: String, #[serde(default)] pub fields: Vec, #[serde(default)] pub indexes: Vec, #[serde(default)] pub relations: Vec, #[serde(default)] pub data_scope: Option, // 是否启用行级数据权限 #[serde(default)] pub is_public: Option, // 是否可被其他插件引用 #[serde(default)] pub importable: Option, // 是否支持数据导入 #[serde(default)] pub exportable: Option, // 是否支持数据导出 } /// 字段校验规则 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldValidation { pub pattern: Option, // 正则表达式 pub message: Option, // 校验失败提示 } /// 插件字段定义 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginField { pub name: String, pub field_type: PluginFieldType, #[serde(default)] pub required: bool, #[serde(default)] pub unique: bool, pub default: Option, pub display_name: Option, pub ui_widget: Option, pub options: Option>, #[serde(default)] pub searchable: Option, #[serde(default)] pub filterable: Option, #[serde(default)] pub sortable: Option, #[serde(default)] pub visible_when: Option, pub ref_entity: Option, // 外键引用的实体名 pub ref_label_field: Option, // entity_select 下拉显示的字段名 pub ref_search_fields: Option>, // entity_select 搜索匹配的字段列表 pub cascade_from: Option, // 级联过滤的来源字段(当前实体) pub cascade_filter: Option, // 级联过滤的目标字段(引用实体的字段) pub validation: Option, // 字段校验规则 #[serde(default)] pub no_cycle: Option, // 禁止循环引用 #[serde(default)] pub scope_role: Option, // 标记为数据权限的"所有者"字段 pub ref_plugin: Option, // 跨插件引用的目标插件 manifest ID(如 "erp-crm") pub ref_fallback_label: Option, // 目标插件未安装时的降级显示文本 } /// 字段类型 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginFieldType { String, Integer, Float, Boolean, Date, DateTime, Json, Uuid, Decimal, } impl PluginFieldType { /// Generated Column 的 SQL 类型 pub fn generated_sql_type(&self) -> &'static str { match self { Self::String | Self::Json => "TEXT", Self::Integer => "INTEGER", Self::Float => "DOUBLE PRECISION", Self::Decimal => "NUMERIC", Self::Boolean => "BOOLEAN", Self::Date => "DATE", // TIMESTAMPTZ cast 不是 immutable,generated column 不支持类型转换,存为 TEXT Self::DateTime => "TEXT", Self::Uuid => "UUID", } } /// Generated Column 的表达式 pub fn generated_expr(&self, field_name: &str) -> String { match self { Self::String | Self::Json | Self::DateTime => format!("data->>'{}'", field_name), _ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()), } } /// 该类型是否适合生成 Generated Column pub fn supports_generated_column(&self) -> bool { !matches!(self, Self::Json) } } impl PluginField { /// 测试辅助:构造一个全默认值的 PluginField #[cfg(test)] pub fn default_for_field() -> Self { Self { name: String::new(), field_type: PluginFieldType::String, required: false, unique: false, default: None, display_name: None, ui_widget: None, options: None, searchable: None, filterable: None, sortable: None, visible_when: None, ref_entity: None, ref_label_field: None, ref_search_fields: None, cascade_from: None, cascade_filter: None, validation: None, no_cycle: None, scope_role: None, ref_plugin: None, ref_fallback_label: None, } } } /// 索引定义 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginIndex { pub name: String, pub fields: Vec, #[serde(default)] pub unique: bool, } /// 级联删除策略 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum OnDeleteStrategy { Nullify, // 置空外键字段 Cascade, // 级联软删除 Restrict, // 存在关联时拒绝删除 } /// 实体关联关系声明 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginRelation { pub entity: String, pub foreign_key: String, pub on_delete: OnDeleteStrategy, #[serde(default)] pub name: Option, // 关联名称(UI 显示用) #[serde(default, alias = "type")] pub relation_type: Option, // "one_to_many" | "many_to_one" | "many_to_many" #[serde(default)] pub display_field: Option, // 关联记录的显示字段 } /// 事件订阅配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginEvents { pub subscribe: Vec, } /// UI 页面配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginUi { pub pages: Vec, } /// 插件页面类型(tagged enum,TOML 中通过 type 字段区分) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum PluginPageType { #[serde(rename = "crud")] Crud { entity: String, label: String, #[serde(default)] icon: Option, #[serde(default)] enable_search: Option, #[serde(default)] enable_views: Option>, }, #[serde(rename = "tree")] Tree { entity: String, label: String, #[serde(default)] icon: Option, id_field: String, parent_field: String, label_field: String, }, #[serde(rename = "detail")] Detail { entity: String, label: String, sections: Vec, }, #[serde(rename = "tabs")] Tabs { label: String, #[serde(default)] icon: Option, tabs: Vec, }, #[serde(rename = "graph")] Graph { entity: String, label: String, #[serde(default)] icon: Option, relationship_entity: String, source_field: String, target_field: String, edge_label_field: String, node_label_field: String, }, #[serde(rename = "dashboard")] Dashboard { label: String, #[serde(default)] icon: Option, #[serde(default)] widgets: Vec, }, #[serde(rename = "kanban")] Kanban { entity: String, label: String, #[serde(default)] icon: Option, lane_field: String, #[serde(default)] lane_order: Vec, card_title_field: String, #[serde(default)] card_subtitle_field: Option, #[serde(default)] card_fields: Vec, #[serde(default)] enable_drag: Option, }, } /// Dashboard Widget 类型 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PluginWidget { #[serde(rename = "stat_cards")] StatCards { label: String, cards: Vec, }, #[serde(rename = "action_list")] ActionList { label: String, #[serde(default)] max_items: Option, queries: Vec, }, #[serde(rename = "funnel")] Funnel { label: String, entity: String, lane_field: String, #[serde(default)] value_field: Option, lane_order: Vec, }, #[serde(rename = "card_list")] CardList { label: String, entity: String, #[serde(default)] filter: Option, #[serde(default)] max_items: Option, title_field: String, #[serde(default)] subtitle_field: Option, #[serde(default)] tags: Vec, }, } /// 统计卡片 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct StatCard { pub entity: String, #[serde(default)] pub aggregate: Option, #[serde(default)] pub field: Option, #[serde(default)] pub filter: Option, pub label: String, #[serde(default)] pub icon: Option, #[serde(default)] pub color: Option, } /// 待办行动查询 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ActionQuery { pub entity: String, #[serde(default)] pub filter: Option, #[serde(default)] pub sort: Option, pub label_field: String, #[serde(default)] pub subtitle_field: Option, pub action: String, #[serde(default)] pub icon: Option, } /// 插件页面区段(用于 detail 页面类型) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum PluginSection { #[serde(rename = "fields")] Fields { label: String, fields: Vec, }, #[serde(rename = "crud")] Crud { label: String, entity: String, #[serde(default)] filter_field: Option, #[serde(default)] enable_views: Option>, }, } /// 权限定义 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginPermission { pub code: String, pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub data_scope_levels: Option>, // 支持的数据范围等级 } // ============================================================ // P2 平台通用服务 — manifest 扩展 // ============================================================ /// 插件配置项声明 — 平台根据此声明自动生成配置页面 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginSettings { pub fields: Vec, } /// 单个配置字段 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginSettingField { pub name: String, pub display_name: String, #[serde(default)] pub field_type: PluginSettingType, #[serde(default)] pub default_value: Option, #[serde(default)] pub required: bool, #[serde(default)] pub description: Option, /// select/multiselect 类型的选项列表 #[serde(default)] pub options: Option>, /// 数值范围 [min, max] #[serde(default)] pub range: Option<(f64, f64)>, /// 分组名称 — 同组的字段在 UI 上放在一起 #[serde(default)] pub group: Option, } /// 配置字段类型 #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum PluginSettingType { #[default] Text, Number, Boolean, Select, Multiselect, Color, Date, Datetime, Json, } /// 编号规则声明 — 绑定实体字段到自动编号 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginNumbering { pub entity: String, pub field: String, #[serde(default)] pub prefix: String, #[serde(default = "default_numbering_format")] pub format: String, #[serde(default)] pub reset_rule: PluginNumberingReset, #[serde(default = "default_seq_length")] pub seq_length: u32, #[serde(default)] pub separator: Option, } fn default_numbering_format() -> String { "{PREFIX}-{YEAR}-{SEQ:4}".to_string() } fn default_seq_length() -> u32 { 4 } /// 编号重置周期 #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum PluginNumberingReset { #[default] Never, Daily, Monthly, Yearly, } /// 打印模板声明 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginTemplate { pub name: String, pub display_name: String, pub entity: String, #[serde(default = "default_template_format")] pub format: String, /// 模板文件路径(相对于插件根目录) #[serde(default)] pub template_file: Option, /// 内联 HTML 模板(与 template_file 二选一) #[serde(default)] pub template_html: Option, } fn default_template_format() -> String { "pdf".to_string() } /// 触发事件声明 — 数据 CRUD 操作时自动发布域事件 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginTriggerEvent { pub name: String, pub display_name: String, #[serde(default)] pub description: String, pub entity: String, pub on: PluginTriggerOn, } /// 触发时机 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginTriggerOn { Create, Update, Delete, CreateOrUpdate, } /// 从 TOML 字符串解析插件清单 pub fn parse_manifest(toml_str: &str) -> PluginResult { let manifest: PluginManifest = toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?; // 验证必填字段 if manifest.metadata.id.is_empty() { return Err(PluginError::InvalidManifest("metadata.id 不能为空".to_string())); } if manifest.metadata.name.is_empty() { return Err(PluginError::InvalidManifest( "metadata.name 不能为空".to_string(), )); } // 验证实体名称 if let Some(schema) = &manifest.schema { for entity in &schema.entities { if entity.name.is_empty() { return Err(PluginError::InvalidManifest( "entity.name 不能为空".to_string(), )); } // 验证实体名称只包含合法字符 if !entity .name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_') { return Err(PluginError::InvalidManifest(format!( "entity.name '{}' 只能包含字母、数字和下划线", entity.name ))); } } } // 验证页面类型配置 if let Some(ui) = &manifest.ui { validate_pages(&ui.pages)?; } // 验证编号规则引用的实体存在 if let Some(numbering) = &manifest.numbering { let entity_names: Vec<&str> = manifest .schema .as_ref() .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) .unwrap_or_default(); for rule in numbering { if !entity_names.contains(&rule.entity.as_str()) { return Err(PluginError::InvalidManifest(format!( "numbering 引用了不存在的 entity '{}'", rule.entity ))); } } } // 验证触发事件引用的实体存在 if let Some(triggers) = &manifest.trigger_events { let entity_names: Vec<&str> = manifest .schema .as_ref() .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) .unwrap_or_default(); for trigger in triggers { if !entity_names.contains(&trigger.entity.as_str()) { return Err(PluginError::InvalidManifest(format!( "trigger_events 引用了不存在的 entity '{}'", trigger.entity ))); } } } Ok(manifest) } /// 递归验证页面配置 fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> { for page in pages { match page { PluginPageType::Crud { entity, .. } => { if entity.is_empty() { return Err(PluginError::InvalidManifest( "crud page 的 entity 不能为空".into(), )); } } PluginPageType::Tree { entity: _, label: _, icon: _, id_field, parent_field, label_field, } => { if id_field.is_empty() || parent_field.is_empty() || label_field.is_empty() { return Err(PluginError::InvalidManifest( "tree page 的 id_field/parent_field/label_field 不能为空".into(), )); } } PluginPageType::Detail { entity, sections, .. } => { if entity.is_empty() { return Err(PluginError::InvalidManifest( "detail page 的 entity 不能为空".into(), )); } if sections.is_empty() { return Err(PluginError::InvalidManifest( "detail page 的 sections 不能为空".into(), )); } } PluginPageType::Tabs { tabs, .. } => { if tabs.is_empty() { return Err(PluginError::InvalidManifest( "tabs page 的 tabs 不能为空".into(), )); } validate_pages(tabs)?; } PluginPageType::Graph { entity, relationship_entity, source_field, target_field, .. } => { if entity.is_empty() || relationship_entity.is_empty() { return Err(PluginError::InvalidManifest( "graph page 的 entity/relationship_entity 不能为空".into(), )); } if source_field.is_empty() || target_field.is_empty() { return Err(PluginError::InvalidManifest( "graph page 的 source_field/target_field 不能为空".into(), )); } } PluginPageType::Dashboard { .. } => { // dashboard 无需额外验证 } PluginPageType::Kanban { entity, lane_field, card_title_field, .. } => { if entity.is_empty() { return Err(PluginError::InvalidManifest( "kanban page 的 entity 不能为空".into(), )); } if lane_field.is_empty() { return Err(PluginError::InvalidManifest( "kanban page 的 lane_field 不能为空".into(), )); } if card_title_field.is_empty() { return Err(PluginError::InvalidManifest( "kanban page 的 card_title_field 不能为空".into(), )); } } } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_minimal_manifest() { let toml = r#" [metadata] id = "test-plugin" name = "测试插件" version = "0.1.0" "#; let manifest = parse_manifest(toml).unwrap(); assert_eq!(manifest.metadata.id, "test-plugin"); assert_eq!(manifest.metadata.name, "测试插件"); assert!(manifest.schema.is_none()); } #[test] fn parse_full_manifest() { let toml = r#" [metadata] id = "inventory" name = "进销存" version = "1.0.0" description = "简单进销存管理" author = "ERP Team" [schema] [[schema.entities]] name = "product" display_name = "商品" [[schema.entities.fields]] name = "sku" field_type = "string" required = true unique = true display_name = "SKU 编码" [[schema.entities.fields]] name = "price" field_type = "decimal" required = true display_name = "价格" [events] subscribe = ["workflow.task.completed", "order.*"] [ui] [[ui.pages]] type = "crud" entity = "product" label = "商品管理" icon = "ShoppingOutlined" [[permissions]] code = "product.list" name = "查看商品" description = "查看商品列表" "#; let manifest = parse_manifest(toml).unwrap(); assert_eq!(manifest.metadata.id, "inventory"); let schema = manifest.schema.unwrap(); assert_eq!(schema.entities.len(), 1); assert_eq!(schema.entities[0].name, "product"); assert_eq!(schema.entities[0].fields.len(), 2); let events = manifest.events.unwrap(); assert_eq!(events.subscribe.len(), 2); let ui = manifest.ui.unwrap(); assert_eq!(ui.pages.len(), 1); // 验证新格式解析正确 match &ui.pages[0] { PluginPageType::Crud { entity, label, .. } => { assert_eq!(entity, "product"); assert_eq!(label, "商品管理"); } _ => panic!("Expected Crud page type"), } } #[test] fn reject_empty_id() { let toml = r#" [metadata] id = "" name = "测试" version = "0.1.0" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn reject_invalid_entity_name() { let toml = r#" [metadata] id = "test" name = "测试" version = "0.1.0" [schema] [[schema.entities]] name = "my-table" display_name = "表格" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn parse_manifest_with_new_fields_and_page_types() { let toml = r#" [metadata] id = "test-plugin" name = "Test" version = "0.1.0" description = "Test" author = "Test" min_platform_version = "0.1.0" [[schema.entities]] name = "customer" display_name = "客户" [[schema.entities.fields]] name = "code" field_type = "string" required = true display_name = "编码" unique = true searchable = true filterable = true visible_when = "customer_type == 'enterprise'" [[ui.pages]] type = "tabs" label = "客户管理" icon = "team" [[ui.pages.tabs]] label = "客户列表" type = "crud" entity = "customer" enable_search = true enable_views = ["table", "timeline"] [[ui.pages]] type = "detail" entity = "customer" label = "客户详情" [[ui.pages.sections]] type = "fields" label = "基本信息" fields = ["code", "name"] "#; let manifest = parse_manifest(toml).expect("should parse"); let field = &manifest.schema.as_ref().unwrap().entities[0].fields[0]; assert_eq!(field.searchable, Some(true)); assert_eq!(field.filterable, Some(true)); assert_eq!( field.visible_when.as_deref(), Some("customer_type == 'enterprise'") ); // 验证页面类型解析 let ui = manifest.ui.as_ref().unwrap(); assert_eq!(ui.pages.len(), 2); // tabs 页面 match &ui.pages[0] { PluginPageType::Tabs { label, tabs, .. } => { assert_eq!(label, "客户管理"); assert_eq!(tabs.len(), 1); } _ => panic!("Expected Tabs page type"), } // detail 页面 match &ui.pages[1] { PluginPageType::Detail { entity, sections, .. } => { assert_eq!(entity, "customer"); assert_eq!(sections.len(), 1); } _ => panic!("Expected Detail page type"), } } #[test] fn reject_empty_entity_in_crud_page() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [ui] [[ui.pages]] type = "crud" entity = "" label = "测试" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn reject_empty_tabs_in_tabs_page() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [ui] [[ui.pages]] type = "tabs" label = "空标签页" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn field_type_to_sql_mapping() { assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT"); assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER"); assert_eq!(PluginFieldType::Float.generated_sql_type(), "DOUBLE PRECISION"); assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC"); assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN"); assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE"); assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TEXT"); assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID"); assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); } #[test] fn field_type_generated_expression() { assert_eq!(PluginFieldType::String.generated_expr("name"), "data->>'name'"); assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER"); assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID"); } #[test] fn parse_field_with_ref_entity() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "contact" display_name = "联系人" [[schema.entities.fields]] name = "customer_id" field_type = "uuid" required = true display_name = "所属客户" ref_entity = "customer" "#; let manifest = parse_manifest(toml).unwrap(); let field = &manifest.schema.unwrap().entities[0].fields[0]; assert_eq!(field.ref_entity.as_deref(), Some("customer")); } #[test] fn parse_field_with_validation() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "contact" display_name = "联系人" [[schema.entities.fields]] name = "phone" field_type = "string" display_name = "手机号" validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" } "#; let manifest = parse_manifest(toml).unwrap(); let field = &manifest.schema.unwrap().entities[0].fields[0]; let v = field.validation.as_ref().unwrap(); assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$")); assert_eq!(v.message.as_deref(), Some("手机号格式不正确")); } #[test] fn parse_field_with_no_cycle() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "customer" display_name = "客户" [[schema.entities.fields]] name = "parent_id" field_type = "uuid" display_name = "上级客户" ref_entity = "customer" no_cycle = true "#; let manifest = parse_manifest(toml).unwrap(); let field = &manifest.schema.unwrap().entities[0].fields[0]; assert_eq!(field.no_cycle, Some(true)); assert_eq!(field.ref_entity.as_deref(), Some("customer")); } #[test] fn parse_entity_with_relations() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "customer" display_name = "客户" [[schema.entities.fields]] name = "code" field_type = "string" required = true display_name = "编码" [[schema.entities.relations]] entity = "contact" foreign_key = "customer_id" on_delete = "cascade" [[schema.entities.relations]] entity = "customer_tag" foreign_key = "customer_id" on_delete = "cascade" "#; let manifest = parse_manifest(toml).unwrap(); let entity = &manifest.schema.unwrap().entities[0]; assert_eq!(entity.relations.len(), 2); assert_eq!(entity.relations[0].entity, "contact"); assert_eq!(entity.relations[0].foreign_key, "customer_id"); assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade)); } #[test] fn parse_entity_with_data_scope() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "customer" display_name = "客户" data_scope = true [[schema.entities.fields]] name = "owner_id" field_type = "uuid" display_name = "负责人" scope_role = "owner" "#; let manifest = parse_manifest(toml).unwrap(); let entity = &manifest.schema.unwrap().entities[0]; assert_eq!(entity.data_scope, Some(true)); assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner")); } #[test] fn parse_permission_with_data_scope_levels() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [[permissions]] code = "customer.list" name = "查看客户" data_scope_levels = ["self", "department", "department_tree", "all"] "#; let manifest = parse_manifest(toml).unwrap(); let perm = &manifest.permissions.unwrap()[0]; assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4); } #[test] fn parse_field_with_entity_select() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "contact" display_name = "联系人" [[schema.entities.fields]] name = "customer_id" field_type = "uuid" required = true display_name = "所属客户" ui_widget = "entity_select" ref_label_field = "name" ref_search_fields = ["name", "code"] "#; let manifest = parse_manifest(toml).unwrap(); let field = &manifest.schema.unwrap().entities[0].fields[0]; assert_eq!(field.ui_widget.as_deref(), Some("entity_select")); assert_eq!(field.ref_label_field.as_deref(), Some("name")); assert_eq!( field.ref_search_fields.as_deref(), Some( &["name".to_string(), "code".to_string()][..] ) ); } #[test] fn parse_field_with_cascade() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "communication" display_name = "沟通记录" [[schema.entities.fields]] name = "customer_id" field_type = "uuid" required = true display_name = "关联客户" [[schema.entities.fields]] name = "contact_id" field_type = "uuid" display_name = "关联联系人" ui_widget = "entity_select" ref_label_field = "name" ref_search_fields = ["name"] cascade_from = "customer_id" cascade_filter = "customer_id" "#; let manifest = parse_manifest(toml).unwrap(); let fields = &manifest.schema.unwrap().entities[0].fields; let contact_field = &fields[1]; assert_eq!(contact_field.ui_widget.as_deref(), Some("entity_select")); assert_eq!(contact_field.cascade_from.as_deref(), Some("customer_id")); assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id")); } #[test] fn parse_field_with_cross_plugin_ref() { let toml = r#" [metadata] id = "erp-inventory" name = "进销存" version = "0.2.0" dependencies = ["erp-crm"] [schema] [[schema.entities]] name = "sales_order" display_name = "销售订单" [[schema.entities.fields]] name = "customer_id" field_type = "uuid" display_name = "客户" ui_widget = "entity_select" ref_plugin = "erp-crm" ref_entity = "customer" ref_label_field = "name" ref_search_fields = ["name", "code"] ref_fallback_label = "CRM 客户" "#; let manifest = parse_manifest(toml).unwrap(); let field = &manifest.schema.unwrap().entities[0].fields[0]; assert_eq!(field.ref_plugin.as_deref(), Some("erp-crm")); assert_eq!(field.ref_entity.as_deref(), Some("customer")); assert_eq!(field.ref_label_field.as_deref(), Some("name")); assert_eq!(field.ref_fallback_label.as_deref(), Some("CRM 客户")); assert_eq!(manifest.metadata.dependencies, vec!["erp-crm"]); } #[test] fn parse_entity_with_is_public() { let toml = r#" [metadata] id = "erp-crm" name = "CRM" version = "0.1.0" [schema] [[schema.entities]] name = "customer" display_name = "客户" is_public = true [[schema.entities]] name = "internal_config" display_name = "内部配置" "#; let manifest = parse_manifest(toml).unwrap(); let entities = &manifest.schema.unwrap().entities; assert_eq!(entities[0].is_public, Some(true)); assert_eq!(entities[1].is_public, None); } #[test] fn parse_relation_with_name_and_type() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "customer" display_name = "客户" [[schema.entities.fields]] name = "code" field_type = "string" display_name = "编码" [[schema.entities.relations]] entity = "contact" foreign_key = "customer_id" on_delete = "cascade" name = "contacts" type = "one_to_many" display_field = "name" "#; let manifest = parse_manifest(toml).unwrap(); let relation = &manifest.schema.unwrap().entities[0].relations[0]; assert_eq!(relation.entity, "contact"); assert_eq!(relation.name.as_deref(), Some("contacts")); assert_eq!(relation.relation_type.as_deref(), Some("one_to_many")); assert_eq!(relation.display_field.as_deref(), Some("name")); } #[test] fn parse_kanban_page() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [ui] [[ui.pages]] type = "kanban" entity = "customer" label = "销售漏斗" icon = "swap" lane_field = "level" lane_order = ["potential", "normal", "vip", "svip"] card_title_field = "name" card_subtitle_field = "code" card_fields = ["region", "status"] enable_drag = true "#; let manifest = parse_manifest(toml).unwrap(); let ui = manifest.ui.unwrap(); assert_eq!(ui.pages.len(), 1); match &ui.pages[0] { PluginPageType::Kanban { entity, label, icon, lane_field, lane_order, card_title_field, card_subtitle_field, card_fields, enable_drag, } => { assert_eq!(entity, "customer"); assert_eq!(label, "销售漏斗"); assert_eq!(icon.as_deref(), Some("swap")); assert_eq!(lane_field, "level"); assert_eq!(lane_order, &["potential", "normal", "vip", "svip"]); assert_eq!(card_title_field, "name"); assert_eq!(card_subtitle_field.as_deref(), Some("code")); assert_eq!(card_fields, &["region", "status"]); assert_eq!(*enable_drag, Some(true)); } _ => panic!("Expected Kanban page type"), } } #[test] fn reject_empty_entity_in_kanban_page() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [ui] [[ui.pages]] type = "kanban" entity = "" label = "测试" lane_field = "status" card_title_field = "name" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn reject_empty_lane_field_in_kanban_page() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [ui] [[ui.pages]] type = "kanban" entity = "customer" label = "测试" lane_field = "" card_title_field = "name" "#; let result = parse_manifest(toml); assert!(result.is_err()); } // ============================================================ // P2 manifest 扩展测试 // ============================================================ #[test] fn parse_settings_section() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [settings] [[settings.fields]] name = "default_tax_rate" display_name = "默认税率" field_type = "number" default_value = 0.13 range = [0.0, 1.0] group = "财务" [[settings.fields]] name = "invoice_prefix" display_name = "发票前缀" field_type = "text" default_value = "INV" [[settings.fields]] name = "auto_notify" display_name = "自动通知" field_type = "boolean" default_value = true description = "发票创建后是否自动发送通知" "#; let manifest = parse_manifest(toml).unwrap(); let settings = manifest.settings.unwrap(); assert_eq!(settings.fields.len(), 3); assert_eq!(settings.fields[0].name, "default_tax_rate"); assert_eq!(settings.fields[0].range, Some((0.0, 1.0))); assert_eq!(settings.fields[0].group.as_deref(), Some("财务")); assert_eq!(settings.fields[1].name, "invoice_prefix"); assert_eq!(settings.fields[2].name, "auto_notify"); assert!(matches!(settings.fields[2].field_type, PluginSettingType::Boolean)); } #[test] fn parse_numbering_section() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "invoice" display_name = "发票" [[numbering]] entity = "invoice" field = "invoice_no" prefix = "INV" format = "{PREFIX}-{YEAR}-{SEQ:4}" reset_rule = "yearly" seq_length = 4 "#; let manifest = parse_manifest(toml).unwrap(); let numbering = manifest.numbering.unwrap(); assert_eq!(numbering.len(), 1); assert_eq!(numbering[0].entity, "invoice"); assert_eq!(numbering[0].field, "invoice_no"); assert_eq!(numbering[0].prefix, "INV"); assert!(matches!(numbering[0].reset_rule, PluginNumberingReset::Yearly)); } #[test] fn reject_numbering_with_unknown_entity() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [[numbering]] entity = "nonexistent" field = "code" prefix = "T" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn parse_trigger_events_section() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "invoice" display_name = "发票" [[trigger_events]] name = "invoice.created" display_name = "发票创建" description = "新发票创建时触发" entity = "invoice" on = "create" [[trigger_events]] name = "invoice.overdue" display_name = "发票逾期" description = "发票超过付款期限未收款" entity = "invoice" on = "update" "#; let manifest = parse_manifest(toml).unwrap(); let triggers = manifest.trigger_events.unwrap(); assert_eq!(triggers.len(), 2); assert_eq!(triggers[0].name, "invoice.created"); assert!(matches!(triggers[0].on, PluginTriggerOn::Create)); assert_eq!(triggers[1].name, "invoice.overdue"); } #[test] fn reject_trigger_event_with_unknown_entity() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [[trigger_events]] name = "test.trigger" display_name = "测试" entity = "nonexistent" on = "create" "#; let result = parse_manifest(toml); assert!(result.is_err()); } #[test] fn parse_entity_with_import_export() { let toml = r#" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "product" display_name = "商品" importable = true exportable = true [[schema.entities]] name = "internal_log" display_name = "内部日志" "#; let manifest = parse_manifest(toml).unwrap(); let entities = &manifest.schema.unwrap().entities; assert_eq!(entities[0].importable, Some(true)); assert_eq!(entities[0].exportable, Some(true)); assert_eq!(entities[1].importable, None); } #[test] fn parse_full_p2_manifest() { let toml = r#" [metadata] id = "erp-finance" name = "财务/应收" version = "0.1.0" description = "财务管理与应收账款" author = "ERP Team" [schema] [[schema.entities]] name = "invoice" display_name = "发票" importable = true exportable = true [[schema.entities.fields]] name = "invoice_no" field_type = "string" required = true unique = true display_name = "发票编号" [[schema.entities.fields]] name = "customer_id" field_type = "uuid" display_name = "客户" ref_plugin = "erp-crm" ref_entity = "customer" ref_label_field = "name" ref_search_fields = ["name"] ref_fallback_label = "外部客户" [[schema.entities]] name = "payment" display_name = "收款" [settings] [[settings.fields]] name = "default_tax_rate" display_name = "默认税率" field_type = "number" default_value = 0.13 group = "税务" [[settings.fields]] name = "invoice_prefix" display_name = "发票前缀" field_type = "text" default_value = "INV" [[numbering]] entity = "invoice" field = "invoice_no" prefix = "INV" format = "{PREFIX}-{YEAR}-{SEQ:4}" reset_rule = "yearly" [[trigger_events]] name = "invoice.created" display_name = "发票创建" entity = "invoice" on = "create" [[permissions]] code = "invoice.list" name = "查看发票" [[permissions]] code = "invoice.manage" name = "管理发票" "#; let manifest = parse_manifest(toml).unwrap(); assert_eq!(manifest.metadata.id, "erp-finance"); // settings let settings = manifest.settings.unwrap(); assert_eq!(settings.fields.len(), 2); // numbering let numbering = manifest.numbering.unwrap(); assert_eq!(numbering.len(), 1); assert_eq!(numbering[0].entity, "invoice"); // trigger_events let triggers = manifest.trigger_events.unwrap(); assert_eq!(triggers.len(), 1); // import/export on entity let entities = &manifest.schema.unwrap().entities; assert_eq!(entities[0].importable, Some(true)); assert_eq!(entities[0].exportable, Some(true)); } #[test] fn parse_dashboard_with_widgets() { let toml = r##" [metadata] id = "test" name = "Test" version = "0.1.0" [schema] [[schema.entities]] name = "invoice" display_name = "发票" [[schema.entities.fields]] name = "status" field_type = "string" display_name = "状态" [[schema.entities.fields]] name = "amount" field_type = "decimal" display_name = "金额" [ui] [[ui.pages]] type = "dashboard" label = "工作台" icon = "DashboardOutlined" [[ui.pages.widgets]] type = "stat_cards" label = "财务概览" [[ui.pages.widgets.cards]] entity = "invoice" aggregate = "count" label = "总发票" icon = "FileTextOutlined" color = "#1890ff" [[ui.pages.widgets.cards]] entity = "invoice" aggregate = "sum" field = "amount" filter = "status == 'pending'" label = "待收金额" icon = "DollarOutlined" color = "#faad14" [[ui.pages.widgets]] type = "action_list" label = "紧急待办" max_items = 5 [[ui.pages.widgets.queries]] entity = "invoice" filter = "status == 'overdue'" sort = "due_date asc" label_field = "invoice_number" subtitle_field = "amount" action = "open_invoice" icon = "warning" [[ui.pages.widgets]] type = "funnel" label = "商机漏斗" entity = "invoice" lane_field = "status" value_field = "amount" lane_order = ["pending", "issued", "paid"] [[ui.pages.widgets]] type = "card_list" label = "活跃项目" entity = "invoice" filter = "status == 'active'" max_items = 10 title_field = "invoice_number" subtitle_field = "amount" tags = ["status"] "##; let manifest = parse_manifest(toml).unwrap(); let ui = manifest.ui.unwrap(); assert_eq!(ui.pages.len(), 1); match &ui.pages[0] { PluginPageType::Dashboard { label, icon, widgets, } => { assert_eq!(label, "工作台"); assert_eq!(icon.as_deref(), Some("DashboardOutlined")); assert_eq!(widgets.len(), 4); // stat_cards match &widgets[0] { PluginWidget::StatCards { label, cards } => { assert_eq!(label, "财务概览"); assert_eq!(cards.len(), 2); assert_eq!(cards[0].entity, "invoice"); assert_eq!(cards[0].aggregate.as_deref(), Some("count")); assert_eq!(cards[1].aggregate.as_deref(), Some("sum")); assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'")); } _ => panic!("Expected StatCards"), } // action_list match &widgets[1] { PluginWidget::ActionList { label, max_items, queries, } => { assert_eq!(label, "紧急待办"); assert_eq!(*max_items, Some(5)); assert_eq!(queries.len(), 1); assert_eq!(queries[0].entity, "invoice"); assert_eq!(queries[0].action, "open_invoice"); } _ => panic!("Expected ActionList"), } // funnel match &widgets[2] { PluginWidget::Funnel { label, entity, lane_field, value_field, lane_order, } => { assert_eq!(label, "商机漏斗"); assert_eq!(entity, "invoice"); assert_eq!(lane_field, "status"); assert_eq!(value_field.as_deref(), Some("amount")); assert_eq!(lane_order, &["pending", "issued", "paid"]); } _ => panic!("Expected Funnel"), } // card_list match &widgets[3] { PluginWidget::CardList { label, entity, title_field, .. } => { assert_eq!(label, "活跃项目"); assert_eq!(entity, "invoice"); assert_eq!(title_field, "invoice_number"); } _ => panic!("Expected CardList"), } } _ => panic!("Expected Dashboard page type"), } } }