build-flow.api/tests/BuildFlow.Workflow.Tests/Engine/TreeToGraphCompilerTests.cs
xiangning 74e99850e5 feat(workflow): 添加钉钉式节点级字段权限和依次审批功能
- 实现钉钉式节点级字段权限:从审批节点config解析fieldPermissions进行表单校验
- 添加依次审批模式支持:按顺序逐个创建审批任务并推进流程
- 扩展工作流定义实体:新增DefinitionTree字段存储钉式审批流树JSON
- 更新表单验证器:支持节点级字段权限配置影响必填校验逻辑
- 重构后台服务:修复OperationCanceledException处理防止进程崩溃
- 扩展脚本执行服务:支持加载各模块Application/Domain程序集
- 更新菜单配置:调整组件管理和脚本函数菜单顺序
- 完善工作流引擎:修复实例图加载和任务完成逻辑
2026-07-17 17:05:35 +08:00

290 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using FluentAssertions;
using Workflow.Application.Engine;
using Workflow.Domain.Engine;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.Engine;
/// <summary>
/// TreeToGraphCompiler 单元测试:覆盖顺序/条件/并行/嵌套四种拓扑,
/// 并断言编译产出的图能通过 WorkflowGraphValidator(发布校验)。
/// </summary>
public class TreeToGraphCompilerTests
{
// 测试用固定 Guid(前端真实环境用 crypto.randomUUID())
private const string RootId = "00000000-0000-0000-0000-000000000001";
private const string A1Id = "00000000-0000-0000-0000-000000000011";
private const string C1Id = "00000000-0000-0000-0000-000000000021";
private const string CondId = "00000000-0000-0000-0000-000000000031";
private const string ParId = "00000000-0000-0000-0000-000000000041";
private const string BigId = "00000000-0000-0000-0000-000000000051";
private const string SmallId = "00000000-0000-0000-0000-000000000061";
private const string LegalId = "00000000-0000-0000-0000-000000000071";
private const string FinId = "00000000-0000-0000-0000-000000000081";
private const string EndCcId = "00000000-0000-0000-0000-000000000091";
private const string LeadId = "00000000-0000-0000-0000-0000000000a1";
private static TreeNode Node(string id, TreeNodeType type, string? title = null, object? config = null) =>
new()
{
Id = id,
Type = type,
Title = title,
Config = config is null ? null : JsonDocument.Parse(JsonSerializer.Serialize(config)).RootElement.Clone(),
};
private static TreeNode Approval(string id, string title, params string[] assignees) =>
Node(id, TreeNodeType.Approval, title, new
{
approvalMode = "countersign",
assigneeRules = assignees,
});
private static TreeNode Cc(string id, string title, params string[] recipients) =>
Node(id, TreeNodeType.Cc, title, new { recipients });
private static TreeNode ConditionBranch(string id, string title, params TreeBranch[] branches) =>
new()
{
Id = id,
Type = TreeNodeType.ConditionBranch,
Title = title,
Branches = branches.ToList(),
};
private static TreeNode ParallelBranch(string id, string title, params TreeBranch[] branches) =>
new()
{
Id = id,
Type = TreeNodeType.ParallelBranch,
Title = title,
Branches = branches.ToList(),
};
private static TreeBranch Branch(string id, string label, string? condition, TreeNode? child) =>
new() { Id = id, Label = label, Condition = condition, Child = child };
/// <summary>把节点链按 child 顺序串起来,返回链头。</summary>
private static TreeNode Chain(params TreeNode[] nodes)
{
for (var i = 0; i < nodes.Length - 1; i++)
{
nodes[i].Child = nodes[i + 1];
}
return nodes[0];
}
// ====== 1. 空树 ======
[Fact]
public void Compile_EmptyTree_ProducesMinimalStartEndGraph()
{
var graph = TreeToGraphCompiler.Compile(null);
var startNodes = graph.Nodes.Where(n => n.Shape == WorkflowShapeMap.Shapes.Start).ToList();
var endNodes = graph.Nodes.Where(n => n.Shape == WorkflowShapeMap.Shapes.End).ToList();
startNodes.Should().HaveCount(1);
endNodes.Should().HaveCount(1);
graph.Edges.Should().HaveCount(1);
graph.Edges[0].Source.Cell.Should().Be(startNodes[0].Id);
graph.Edges[0].Target.Cell.Should().Be(endNodes[0].Id);
// 必须能通过校验
WorkflowGraphValidator.Validate(graph);
}
// ====== 2. 顺序链:发起人 → 审批 → 抄送 ======
[Fact]
public void Compile_SequentialChain_SingleStartSingleEnd()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
Approval(A1Id, "主管审批", "user:11111111-1111-1111-1111-111111111111"),
Cc(C1Id, "抄送财务", "user:22222222-2222-2222-2222-222222222222")),
};
var graph = TreeToGraphCompiler.Compile(tree);
// 唯一 Start / 唯一 End
graph.Nodes.Count(n => n.Shape == WorkflowShapeMap.Shapes.Start).Should().Be(1);
graph.Nodes.Count(n => n.Shape == WorkflowShapeMap.Shapes.End).Should().Be(1);
// 审批 + 抄送各一个
graph.Nodes.Should().Contain(n => n.Shape == WorkflowShapeMap.Shapes.Approval && n.Id == A1Id);
graph.Nodes.Should().Contain(n => n.Shape == WorkflowShapeMap.Shapes.Cc && n.Id == C1Id);
// 校验通过(证明是引擎可跑的合法图)
var act = () => WorkflowGraphValidator.Validate(graph);
act.Should().NotThrow();
}
// ====== 3. 条件分支:审批后按金额分流,再汇合到抄送 ======
[Fact]
public void Compile_ConditionBranch_ProducesConditionGatewayWithOrderedConditionEdges()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
ConditionBranch(CondId, "金额分支",
Branch("b1", "大于5000", "amount > 5000",
Chain(Approval(BigId, "总监审批", "user:big"))),
Branch("b2", "默认", null,
Chain(Approval(SmallId, "组长审批", "user:small")))),
Cc(C1Id, "抄送财务", "user:fin")),
};
var graph = TreeToGraphCompiler.Compile(tree);
// 恰好一个 wf-condition 网关
var gateways = graph.Nodes.Where(n => n.Shape == WorkflowShapeMap.Shapes.Condition).ToList();
gateways.Should().HaveCount(1);
// 网关的两条出边都有 order;条件分支条件写进 data.condition
var outEdges = graph.Edges.Where(e => e.Source.Cell == gateways[0].Id).ToList();
outEdges.Should().HaveCount(2);
outEdges.Should().AllSatisfy(e => e.GetOrder().Should().BeGreaterThanOrEqualTo(0));
outEdges.Should().Contain(e => (e.GetCondition() ?? "").Contains("amount"));
// 校验通过(条件节点出边必须带 condition,且不全空)
var act = () => WorkflowGraphValidator.Validate(graph);
act.Should().NotThrow();
}
// ====== 4. 并行分支 ======
[Fact]
public void Compile_ParallelBranch_ProducesForkAndJoin()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
ParallelBranch(ParId, "并行审批",
Branch("p1", "分支1", null, Chain(Approval(LegalId, "法务", "user:legal"))),
Branch("p2", "分支2", null, Chain(Approval(FinId, "财务", "user:fin")))),
Cc(EndCcId, "结束抄送", "user:end")),
};
var graph = TreeToGraphCompiler.Compile(tree);
// 两个 Parallel 节点:Fork(多出边)+ Join(多入边)
var parallels = graph.Nodes.Where(n => n.Shape == WorkflowShapeMap.Shapes.Parallel).ToList();
parallels.Should().HaveCount(2);
var fork = parallels.First(p => graph.Edges.Count(e => e.Source.Cell == p.Id) > 1);
var join = parallels.First(p => graph.Edges.Count(e => e.Target.Cell == p.Id) > 1);
fork.Should().NotBeNull();
join.Should().NotBeNull();
fork.Id.Should().NotBe(join.Id);
var act = () => WorkflowGraphValidator.Validate(graph);
act.Should().NotThrow();
}
// ====== 5. 嵌套:条件分支内再套并行分支 ======
[Fact]
public void Compile_NestedBranches_StillValidates()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
ConditionBranch(CondId, "金额",
Branch("b1", "大额", "amount > 10000",
Chain(ParallelBranch(ParId, "并行",
Branch("p1", "A", null, Chain(Approval(LegalId, "法务", "user:legal"))),
Branch("p2", "B", null, Chain(Approval(FinId, "财务", "user:fin")))))),
Branch("b2", "默认", null,
Chain(Approval(LeadId, "组长", "user:lead")))),
Cc(C1Id, "抄送", "user:fin")),
};
var graph = TreeToGraphCompiler.Compile(tree);
// 复杂嵌套仍应通过校验
var act = () => WorkflowGraphValidator.Validate(graph);
act.Should().NotThrow();
// 且只有一个 Start / 一个 End
graph.Nodes.Count(n => n.Shape == WorkflowShapeMap.Shapes.Start).Should().Be(1);
graph.Nodes.Count(n => n.Shape == WorkflowShapeMap.Shapes.End).Should().Be(1);
}
// ====== 6. 节点 id 必须全部是合法 Guid(引擎 ParseGuid 硬约束) ======
[Fact]
public void Compile_AllNodeAndEdgeIds_AreGuidStrings()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
Approval(A1Id, "审批", "user:u1")),
};
var graph = TreeToGraphCompiler.Compile(tree);
foreach (var node in graph.Nodes)
{
Guid.TryParse(node.Id, out _).Should().BeTrue($"节点 id 必须是合法 Guid:{node.Id}");
}
foreach (var edge in graph.Edges)
{
Guid.TryParse(edge.Id, out _).Should().BeTrue($"边 id 必须是合法 Guid:{edge.Id}");
Guid.TryParse(edge.Source.Cell, out _).Should().BeTrue();
Guid.TryParse(edge.Target.Cell, out _).Should().BeTrue();
}
}
// ====== 7. 只有发起人(空链)也能跑 ======
[Fact]
public void Compile_OnlyInitiator_StartConnectsToEndDirectly()
{
var tree = new WorkflowTree
{
Root = Node(RootId, TreeNodeType.Initiator, "发起人"),
};
var graph = TreeToGraphCompiler.Compile(tree);
var start = graph.Nodes.Single(n => n.Shape == WorkflowShapeMap.Shapes.Start);
var end = graph.Nodes.Single(n => n.Shape == WorkflowShapeMap.Shapes.End);
// Initiator 的 id 成为 Start 的 id
start.Id.Should().Be(RootId);
graph.Edges.Should().ContainSingle(e => e.Source.Cell == start.Id && e.Target.Cell == end.Id);
var act = () => WorkflowGraphValidator.Validate(graph);
act.Should().NotThrow();
}
// ====== 8. 审批节点配置透传到 node.data ======
[Fact]
public void Compile_ApprovalConfig_PreservedInNodeData()
{
var tree = new WorkflowTree
{
Root = Chain(
Node(RootId, TreeNodeType.Initiator, "发起人"),
Approval(A1Id, "主管审批", "user:abc")),
};
var graph = TreeToGraphCompiler.Compile(tree);
var approval = graph.Nodes.Single(n => n.Id == A1Id);
approval.Data.Should().NotBeNull();
approval.Data!.Value.GetProperty("title").GetString().Should().Be("主管审批");
// assigneeRules 应原样透传
approval.Data.Value.GetProperty("assigneeRules")
.EnumerateArray().First().GetString().Should().Be("user:abc");
}
}