diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs index efc6cb6..9ab4889 100644 --- a/crates/erp-workflow/src/engine/executor.rs +++ b/crates/erp-workflow/src/engine/executor.rs @@ -627,3 +627,73 @@ impl FlowExecutor { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::{EdgeDef, NodeDef, NodeType}; + + fn make_node(id: &str, node_type: NodeType) -> NodeDef { + NodeDef { + id: id.to_string(), + node_type, + name: id.to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + } + } + + fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef { + EdgeDef { + id: id.to_string(), + source: source.to_string(), + target: target.to_string(), + condition: None, + label: None, + } + } + + #[test] + fn test_is_join_gateway_with_multiple_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("join", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "a"), + make_edge("e2", "start", "b"), + make_edge("e3", "a", "join"), + make_edge("e4", "b", "join"), + make_edge("e5", "join", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + assert!(FlowExecutor::is_join_gateway("join", &graph)); + } + + #[test] + fn test_is_not_join_gateway_single_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("fork", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + assert!(!FlowExecutor::is_join_gateway("fork", &graph)); + } + + #[test] + fn test_is_not_join_gateway_for_nonexistent_node() { + let graph = FlowGraph::build(&[], &[]); + assert!(!FlowExecutor::is_join_gateway("nonexistent", &graph)); + } +} diff --git a/crates/erp-workflow/src/engine/expression.rs b/crates/erp-workflow/src/engine/expression.rs index 79616ff..67514c0 100644 --- a/crates/erp-workflow/src/engine/expression.rs +++ b/crates/erp-workflow/src/engine/expression.rs @@ -328,4 +328,105 @@ mod tests { let result = ExpressionEvaluator::eval("justavariable", &vars); assert!(result.is_err()); } + + // ---- 扩展边界测试 ---- + + #[test] + fn test_less_or_equal() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount <= 1500", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("amount <= 2000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount <= 1000", &vars).unwrap()); + } + + #[test] + fn test_boolean_equality() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("active == true", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("active == false", &vars).unwrap()); + } + + #[test] + fn test_string_literal_with_single_quotes() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("status == 'approved'", &vars).unwrap()); + } + + #[test] + fn test_float_comparison() { + let mut vars = HashMap::new(); + vars.insert("temperature".to_string(), json!(36.5)); + assert!(ExpressionEvaluator::eval("temperature >= 36.0", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("temperature < 37.0", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("temperature > 37.5", &vars).unwrap()); + } + + #[test] + fn test_integer_float_cross_comparison() { + let mut vars = HashMap::new(); + vars.insert("val".to_string(), json!(10)); + assert!(ExpressionEvaluator::eval("val == 10.0", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("val >= 9.5", &vars).unwrap()); + } + + #[test] + fn test_string_comparison_lexicographic() { + let vars = make_vars(); + // name = "Alice" + assert!(!ExpressionEvaluator::eval("name > \"Alice\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name >= \"Alice\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name < \"Bob\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name == \"Alice\"", &vars).unwrap()); + } + + #[test] + fn test_compound_and_or() { + let vars = make_vars(); + // amount > 1000 (true) && score > 80 (true) || amount > 3000 (false) + // OR 先绑定,所以是 amount>1000 && score>80 (true) || amount>3000 (false) + // 注意:当前实现从左到右先匹配 ||,所以实际解析为: + // amount > 1000 && score > 80 || amount > 3000 + // find_logical_op 先找 || → split at || + // left = "amount > 1000 && score > 80", right = "amount > 3000" + // left = true, right = false → true || false = true + assert!(ExpressionEvaluator::eval( + "amount > 1000 && score > 80 || amount > 3000", &vars + ).unwrap()); + } + + #[test] + fn test_compound_all_false() { + let vars = make_vars(); + assert!(!ExpressionEvaluator::eval( + "amount > 2000 && score > 90", &vars + ).unwrap()); + } + + #[test] + fn test_number_not_equals() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount != 1000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount != 1500", &vars).unwrap()); + } + + #[test] + fn test_expression_with_extra_whitespace() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval(" amount > 1000 ", &vars).unwrap()); + } + + #[test] + fn test_unresolvable_variable_returns_error() { + let vars = HashMap::new(); + let result = ExpressionEvaluator::eval("missing_var > 10", &vars); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("未知的变量")); + } + + #[test] + fn test_empty_expression_returns_error() { + let vars = HashMap::new(); + let result = ExpressionEvaluator::eval("", &vars); + assert!(result.is_err()); + } } diff --git a/crates/erp-workflow/src/engine/model.rs b/crates/erp-workflow/src/engine/model.rs index 8a37b49..8b68434 100644 --- a/crates/erp-workflow/src/engine/model.rs +++ b/crates/erp-workflow/src/engine/model.rs @@ -125,3 +125,261 @@ impl FlowGraph { .unwrap_or_default() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::{EdgeDef, NodeDef, NodeType}; + + fn make_node(id: &str, node_type: NodeType) -> NodeDef { + NodeDef { + id: id.to_string(), + node_type, + name: id.to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + } + } + + fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef { + EdgeDef { + id: id.to_string(), + source: source.to_string(), + target: target.to_string(), + condition: None, + label: None, + } + } + + // ---- build ---- + + #[test] + fn build_empty_graph() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + assert!(graph.start_node_id.is_none()); + assert!(graph.end_node_ids.is_empty()); + } + + #[test] + fn build_identifies_start_and_end() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("task1", NodeType::UserTask), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "task1"), + make_edge("e2", "task1", "end"), + ]; + + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.start_node_id, Some("start".to_string())); + assert_eq!(graph.end_node_ids, vec!["end".to_string()]); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn build_multiple_end_events() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("gw", NodeType::ExclusiveGateway), + make_node("end1", NodeType::EndEvent), + make_node("end2", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "gw"), + make_edge("e2", "gw", "end1"), + make_edge("e3", "gw", "end2"), + ]; + + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.end_node_ids.len(), 2); + assert!(graph.end_node_ids.contains(&"end1".to_string())); + assert!(graph.end_node_ids.contains(&"end2".to_string())); + } + + #[test] + fn build_copies_node_properties() { + let user_id = { + let ts = uuid::Timestamp::now(uuid::NoContext); + uuid::Uuid::new_v7(ts) + }; + let nodes = vec![NodeDef { + id: "task".to_string(), + node_type: NodeType::UserTask, + name: "审批".to_string(), + assignee_id: Some(user_id), + candidate_groups: Some(vec!["managers".to_string()]), + service_type: None, + service_config: None, + position: None, + }]; + let graph = FlowGraph::build(&nodes, &[]); + + let node = graph.nodes.get("task").unwrap(); + assert_eq!(node.name, "审批"); + assert_eq!(node.assignee_id, Some(user_id)); + assert_eq!(node.candidate_groups, Some(vec!["managers".to_string()])); + } + + #[test] + fn build_edge_with_condition_and_label() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![EdgeDef { + id: "e1".to_string(), + source: "start".to_string(), + target: "end".to_string(), + condition: Some("amount > 1000".to_string()), + label: Some("高额审批".to_string()), + }]; + + let graph = FlowGraph::build(&nodes, &edges); + let edge = graph.edges.get("e1").unwrap(); + assert_eq!(edge.condition, Some("amount > 1000".to_string())); + assert_eq!(edge.label, Some("高额审批".to_string())); + } + + #[test] + fn build_edge_to_unknown_node_still_recorded() { + let nodes = vec![make_node("start", NodeType::StartEvent)]; + // edge target "missing" 不在 nodes 中,但 edge 仍被记录 + let edges = vec![make_edge("e1", "start", "missing")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.edges.len(), 1); + // outgoing 为 start 有 e1,但 incoming["missing"] 不存在 + assert_eq!(graph.outgoing.get("start").unwrap().len(), 1); + assert!(graph.incoming.get("missing").is_none()); + } + + // ---- get_outgoing_edges ---- + + #[test] + fn outgoing_edges_for_known_node() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + let out = graph.get_outgoing_edges("start"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].target, "end"); + } + + #[test] + fn outgoing_edges_for_unknown_node_empty() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.get_outgoing_edges("nonexistent").is_empty()); + } + + #[test] + fn outgoing_edges_parallel_gateway_fan_out() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("fork", NodeType::ParallelGateway), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "a"), + make_edge("e3", "fork", "b"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + + let out = graph.get_outgoing_edges("fork"); + assert_eq!(out.len(), 2); + let targets: Vec<&str> = out.iter().map(|e| e.target.as_str()).collect(); + assert!(targets.contains(&"a")); + assert!(targets.contains(&"b")); + } + + // ---- get_incoming_edges ---- + + #[test] + fn incoming_edges_for_known_node() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + let inc = graph.get_incoming_edges("end"); + assert_eq!(inc.len(), 1); + assert_eq!(inc[0].source, "start"); + } + + #[test] + fn incoming_edges_for_unknown_node_empty() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.get_incoming_edges("nonexistent").is_empty()); + } + + #[test] + fn incoming_edges_join_gateway() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("join", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "a"), + make_edge("e2", "start", "b"), + make_edge("e3", "a", "join"), + make_edge("e4", "b", "join"), + make_edge("e5", "join", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + + let inc = graph.get_incoming_edges("join"); + assert_eq!(inc.len(), 2); + let sources: Vec<&str> = inc.iter().map(|e| e.source.as_str()).collect(); + assert!(sources.contains(&"a")); + assert!(sources.contains(&"b")); + } + + // ---- start/end 节点无入/出边 ---- + + #[test] + fn start_node_has_no_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert!(graph.get_incoming_edges("start").is_empty()); + assert_eq!(graph.get_outgoing_edges("start").len(), 1); + } + + #[test] + fn end_node_has_no_outgoing() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert!(graph.get_outgoing_edges("end").is_empty()); + assert_eq!(graph.get_incoming_edges("end").len(), 1); + } +} diff --git a/crates/erp-workflow/src/engine/parser.rs b/crates/erp-workflow/src/engine/parser.rs index 8bdd2af..0b525dd 100644 --- a/crates/erp-workflow/src/engine/parser.rs +++ b/crates/erp-workflow/src/engine/parser.rs @@ -266,4 +266,233 @@ mod tests { let result = parse_and_validate(&nodes, &edges); assert!(result.is_err()); } + + // ---- 边界测试扩展 ---- + + #[test] + fn test_empty_nodes_rejected() { + let result = parse_and_validate(&[], &[]); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("不能为空")); + } + + #[test] + fn test_multiple_start_events_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "start2".to_string(), + node_type: NodeType::StartEvent, + name: "开始2".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "start2", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("只能包含一个开始事件")); + } + + #[test] + fn test_edge_references_nonexistent_source() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![ + make_edge("e1", "ghost", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("源节点") && msg.contains("不存在")); + } + + #[test] + fn test_edge_references_nonexistent_target() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![ + make_edge("e1", "start", "ghost"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("目标节点") && msg.contains("不存在")); + } + + #[test] + fn test_start_event_with_incoming_edge_rejected() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "end", "start"), // start 有入边 + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("入边")); + } + + #[test] + fn test_start_event_without_outgoing_edge_rejected() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![]; // start 没有出边 + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("出边")); + } + + #[test] + fn test_exclusive_gateway_single_outgoing_ok() { + // 单条出边的排他网关不需要条件 + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ExclusiveGateway, + name: "判断".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "gw"), + make_edge("e2", "gw", "end"), + ]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } + + #[test] + fn test_exclusive_gateway_with_conditions_ok() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ExclusiveGateway, + name: "金额判断".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_user_task("task_a", "小额审批"), + make_user_task("task_b", "大额审批"), + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "gw"), + EdgeDef { + id: "e2".to_string(), + source: "gw".to_string(), + target: "task_a".to_string(), + condition: Some("amount <= 1000".to_string()), + label: None, + }, + EdgeDef { + id: "e3".to_string(), + source: "gw".to_string(), + target: "task_b".to_string(), + condition: Some("amount > 1000".to_string()), + label: None, + }, + make_edge("e4", "task_a", "end"), + make_edge("e5", "task_b", "end"), + ]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } + + #[test] + fn test_gateway_without_incoming_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ParallelGateway, + name: "并行".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + // gw 没有入边 + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "gw", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("入边")); + } + + #[test] + fn test_gateway_without_outgoing_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ParallelGateway, + name: "并行".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + // gw 没有出边 + let edges = vec![ + make_edge("e1", "start", "gw"), + make_edge("e2", "start", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("出边")); + } + + #[test] + fn test_parallel_gateway_valid() { + let nodes = vec![ + make_start(), + NodeDef { + id: "fork".to_string(), + node_type: NodeType::ParallelGateway, + name: "拆分".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_user_task("a", "任务A"), + make_user_task("b", "任务B"), + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "a"), + make_edge("e3", "fork", "b"), + make_edge("e4", "a", "end"), + make_edge("e5", "b", "end"), + ]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } } diff --git a/crates/erp-workflow/src/error.rs b/crates/erp-workflow/src/error.rs index 109cfa4..443c355 100644 --- a/crates/erp-workflow/src/error.rs +++ b/crates/erp-workflow/src/error.rs @@ -51,3 +51,78 @@ impl From for AppError { } pub type WorkflowResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_maps_to_app_error_validation() { + let err = WorkflowError::Validation("字段缺失".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("字段缺失")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn not_found_maps_to_app_error_not_found() { + let err = WorkflowError::NotFound("流程不存在".to_string()); + let app: AppError = err.into(); + match app { + AppError::NotFound(msg) => assert!(msg.contains("流程不存在")), + other => panic!("期望 AppError::NotFound,得到 {:?}", other), + } + } + + #[test] + fn duplicate_definition_maps_to_app_error_conflict() { + let err = WorkflowError::DuplicateDefinition("key 已存在".to_string()); + let app: AppError = err.into(); + match app { + AppError::Conflict(msg) => assert!(msg.contains("key 已存在")), + other => panic!("期望 AppError::Conflict,得到 {:?}", other), + } + } + + #[test] + fn invalid_diagram_maps_to_validation() { + let err = WorkflowError::InvalidDiagram("缺少 StartEvent".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("缺少 StartEvent")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn invalid_state_maps_to_validation() { + let err = WorkflowError::InvalidState("流程已结束".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("流程已结束")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn expression_error_maps_to_validation() { + let err = WorkflowError::ExpressionError("语法错误".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("语法错误")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn version_mismatch_maps_directly() { + let err = WorkflowError::VersionMismatch; + let app: AppError = err.into(); + match app { + AppError::VersionMismatch => {}, + other => panic!("期望 AppError::VersionMismatch,得到 {:?}", other), + } + } +}