test(workflow): erp-workflow 单元测试从 16 增至 63 — 覆盖 model/error/parser/expression/executor
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- model.rs: 13 个 FlowGraph 测试(build/outgoing/incoming/start-end 边界)
- error.rs: 7 个 WorkflowError → AppError 转换测试
- parser.rs: 11 个新增验证边界测试(空图/多起点/幽灵边/网关约束)
- expression.rs: 13 个新增求值测试(float/bool/string/复合表达式/空白容错)
- executor.rs: 3 个 is_join_gateway 纯函数测试
This commit is contained in:
iven
2026-04-28 18:04:06 +08:00
parent 5941a6b764
commit dde6b09017
5 changed files with 733 additions and 0 deletions

View File

@@ -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));
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -51,3 +51,78 @@ impl From<WorkflowError> for AppError {
}
pub type WorkflowResult<T> = Result<T, WorkflowError>;
#[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),
}
}
}