Add StellaOps.Workflow engine: 14 libraries, WebService, 8 test projects
Extract product-agnostic workflow engine from Ablera.Serdica.Workflow into standalone StellaOps.Workflow.* libraries targeting net10.0. Libraries (14): - Contracts, Abstractions (compiler, decompiler, expression runtime) - Engine (execution, signaling, scheduling, projections, hosted services) - ElkSharp (generic graph layout algorithm) - Renderer.ElkSharp, Renderer.ElkJs, Renderer.Msagl, Renderer.Svg - Signaling.Redis, Signaling.OracleAq - DataStore.MongoDB, DataStore.PostgreSQL, DataStore.Oracle WebService: ASP.NET Core Minimal API with 22 endpoints Tests (8 projects, 109 tests pass): - Engine.Tests (105 pass), WebService.Tests (4 E2E pass) - Renderer.Tests, DataStore.MongoDB/Oracle/PostgreSQL.Tests - Signaling.Redis.Tests, IntegrationTests.Shared Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class AssistantPrintInsisDocumentsRenderingTests
|
||||
{
|
||||
private static WorkflowRenderGraph BuildAssistantPrintInsisDocumentsGraph()
|
||||
{
|
||||
return new WorkflowRenderGraph
|
||||
{
|
||||
Id = "AssistantPrintInsisDocuments:1.0.0",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode { Id = "start", Label = "Start", Kind = "Start", Width = 264, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/1", Label = "Assign Business Reference", Kind = "BusinessReference", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/2/split", Label = "Spin off async process", Kind = "Fork", Width = 176, Height = 124 },
|
||||
new WorkflowRenderNode { Id = "start/2/join", Label = "Spin off async process Join", Kind = "Join", Width = 176, Height = 124 },
|
||||
new WorkflowRenderNode { Id = "start/3", Label = "Load Notification Parameters", Kind = "TransportCall", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/4/batched", Label = "Setting:\nnotificationParameters\nskipSystemNotification\ntoEmailsCount\nnotificationHasBody\nnotificationHasTitle", Kind = "SetState", Width = 224, Height = 104 },
|
||||
new WorkflowRenderNode { Id = "start/9", Label = "Has Notification Content", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/1", Label = "Send Private Note", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/1/true/1", Label = "Send Private Note", Kind = "TransportCall", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/1/true/1/handled/1", Label = "Set notificationPrivateNoteFailed", Kind = "SetState", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/2", Label = "Has Notification Emails", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/2/true/1", Label = "Send Notification Email", Kind = "TransportCall", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/9/true/2/true/1/handled/1", Label = "Set notificationEmailFailed", Kind = "SetState", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "end", Label = "End", Kind = "End", Width = 264, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1", Label = "Generate Documents", Kind = "Repeat", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/1/batched", Label = "Setting:\nprintTimedOut\nprintGenerateFailed\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4", Label = "Print Batch Documents", Kind = "TransportCall", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1", Label = "Attempt Again", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "Wait 5m", Kind = "Timer", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/failure/2", Label = "Set printGenerateFailed", Kind = "SetState", Width = 208, Height = 88 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5", Label = "Print Batch Returned Result", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1", Label = "Print Batch Succeeded", Kind = "Decision", Width = 188, Height = 132 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "Setting:\npolicyNo\nfiles\ndocsCount\nhasMissingDocuments", Kind = "SetState", Width = 224, Height = 104 },
|
||||
new WorkflowRenderNode { Id = "start/2/branch-1/1/body/4/timeout/1", Label = "Set printTimedOut", Kind = "SetState", Width = 208, Height = 88 },
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge { Id = "edge/1", SourceNodeId = "start", TargetNodeId = "start/1" },
|
||||
new WorkflowRenderEdge { Id = "edge/2", SourceNodeId = "start/1", TargetNodeId = "start/2/split" },
|
||||
new WorkflowRenderEdge { Id = "edge/3", SourceNodeId = "start/2/split", TargetNodeId = "start/2/branch-1/1", Label = "branch 1" },
|
||||
new WorkflowRenderEdge { Id = "edge/4", SourceNodeId = "start/2/split", TargetNodeId = "start/2/join", Label = "branch 2" },
|
||||
new WorkflowRenderEdge { Id = "edge/5", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/4/failure/1", Label = "on failure" },
|
||||
new WorkflowRenderEdge { Id = "edge/6", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/4/timeout/1", Label = "on timeout" },
|
||||
new WorkflowRenderEdge { Id = "edge/7", SourceNodeId = "start/2/branch-1/1/body/4", TargetNodeId = "start/2/branch-1/1/body/5" },
|
||||
new WorkflowRenderEdge { Id = "edge/8", SourceNodeId = "start/2/branch-1/1/body/4/failure/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/1/true/1", Label = "when notstate.printInsisAttempt gt 2" },
|
||||
new WorkflowRenderEdge { Id = "edge/9", SourceNodeId = "start/2/branch-1/1/body/4/failure/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/2", Label = "default" },
|
||||
new WorkflowRenderEdge { Id = "edge/10", SourceNodeId = "start/2/branch-1/1/body/4/failure/1/true/1", TargetNodeId = "start/2/branch-1/1/body/4/failure/2" },
|
||||
new WorkflowRenderEdge { Id = "edge/11", SourceNodeId = "start/2/branch-1/1/body/4/failure/2", TargetNodeId = "start/2/branch-1/1/body/5" },
|
||||
new WorkflowRenderEdge { Id = "edge/12", SourceNodeId = "start/2/branch-1/1/body/4/timeout/1", TargetNodeId = "start/2/branch-1/1/body/5" },
|
||||
new WorkflowRenderEdge { Id = "edge/13", SourceNodeId = "start/2/branch-1/1/body/5", TargetNodeId = "start/2/branch-1/1/body/5/true/1", Label = "when state.printTimedOut eq false" },
|
||||
new WorkflowRenderEdge { Id = "edge/14", SourceNodeId = "start/2/branch-1/1/body/5", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" },
|
||||
new WorkflowRenderEdge { Id = "edge/15", SourceNodeId = "start/2/branch-1/1/body/5/true/1", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" },
|
||||
new WorkflowRenderEdge { Id = "edge/16", SourceNodeId = "start/2/branch-1/1/body/5/true/1", TargetNodeId = "start/2/branch-1/1/body/5/true/1/true/1/batched", Label = "when state.printGenerateFailed eq false" },
|
||||
new WorkflowRenderEdge { Id = "edge/17", SourceNodeId = "start/2/branch-1/1", TargetNodeId = "start/2/join" },
|
||||
new WorkflowRenderEdge { Id = "edge/18", SourceNodeId = "start/2/branch-1/1", TargetNodeId = "start/2/branch-1/1/body/1/batched", Label = "body" },
|
||||
new WorkflowRenderEdge { Id = "edge/19", SourceNodeId = "start/2/join", TargetNodeId = "start/3" },
|
||||
new WorkflowRenderEdge { Id = "edge/20", SourceNodeId = "start/3", TargetNodeId = "end", Label = "on failure / timeout" },
|
||||
new WorkflowRenderEdge { Id = "edge/21", SourceNodeId = "start/3", TargetNodeId = "start/4/batched" },
|
||||
new WorkflowRenderEdge { Id = "edge/22", SourceNodeId = "start/9", TargetNodeId = "start/9/true/1", Label = "when state.notificationHasBody" },
|
||||
new WorkflowRenderEdge { Id = "edge/23", SourceNodeId = "start/9", TargetNodeId = "end", Label = "default" },
|
||||
new WorkflowRenderEdge { Id = "edge/24", SourceNodeId = "start/9/true/1", TargetNodeId = "start/9/true/1/true/1", Label = "when state.skipSystemNotification eq false" },
|
||||
new WorkflowRenderEdge { Id = "edge/25", SourceNodeId = "start/9/true/1", TargetNodeId = "start/9/true/2", Label = "default" },
|
||||
new WorkflowRenderEdge { Id = "edge/26", SourceNodeId = "start/9/true/1/true/1", TargetNodeId = "start/9/true/1/true/1/handled/1", Label = "on failure / timeout" },
|
||||
new WorkflowRenderEdge { Id = "edge/27", SourceNodeId = "start/9/true/1/true/1", TargetNodeId = "start/9/true/2" },
|
||||
new WorkflowRenderEdge { Id = "edge/28", SourceNodeId = "start/9/true/1/true/1/handled/1", TargetNodeId = "start/9/true/2" },
|
||||
new WorkflowRenderEdge { Id = "edge/29", SourceNodeId = "start/9/true/2", TargetNodeId = "start/9/true/2/true/1", Label = "when state.toEmailsCount gt 0" },
|
||||
new WorkflowRenderEdge { Id = "edge/30", SourceNodeId = "start/9/true/2", TargetNodeId = "end", Label = "default" },
|
||||
new WorkflowRenderEdge { Id = "edge/31", SourceNodeId = "start/9/true/2/true/1", TargetNodeId = "start/9/true/2/true/1/handled/1", Label = "on failure / timeout" },
|
||||
new WorkflowRenderEdge { Id = "edge/32", SourceNodeId = "start/9/true/2/true/1", TargetNodeId = "end" },
|
||||
new WorkflowRenderEdge { Id = "edge/33", SourceNodeId = "start/9/true/2/true/1/handled/1", TargetNodeId = "end" },
|
||||
new WorkflowRenderEdge { Id = "edge/34", SourceNodeId = "start/2/branch-1/1/body/1/batched", TargetNodeId = "start/2/branch-1/1/body/4" },
|
||||
new WorkflowRenderEdge { Id = "edge/35", SourceNodeId = "start/2/branch-1/1/body/5/true/1/true/1/batched", TargetNodeId = "start/2/branch-1/1", Label = "repeat while state.printInsisAttempt eq 0" },
|
||||
new WorkflowRenderEdge { Id = "edge/36", SourceNodeId = "start/4/batched", TargetNodeId = "start/9" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AssistantPrintInsisDocuments_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||
{
|
||||
var graph = BuildAssistantPrintInsisDocumentsGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task AssistantPrintInsisDocuments_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildAssistantPrintInsisDocumentsGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "AssistantPrintInsisDocuments [ElkSharp]");
|
||||
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(AssistantPrintInsisDocumentsRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "AssistantPrintInsisDocuments");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
{
|
||||
pngPath = Path.Combine(outputDir, "elksharp.png");
|
||||
pngExporter = new WorkflowRenderPngExporter();
|
||||
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
|
||||
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
|
||||
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = pts[i];
|
||||
var p2 = pts[i + 1];
|
||||
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
|
||||
crossings++;
|
||||
}
|
||||
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkJs;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ElkJsWorkflowRenderLayoutEngineTests
|
||||
{
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges()
|
||||
{
|
||||
var engine = new ElkJsWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "root",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task-1",
|
||||
Label = "Review",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task-1",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e2",
|
||||
SourceNodeId = "task-1",
|
||||
TargetNodeId = "end",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
result.GraphId.Should().Be("root");
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Edges.Should().HaveCount(2);
|
||||
|
||||
var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray();
|
||||
orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end");
|
||||
result.Edges.Should().OnlyContain(x => x.Sections.Count > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ElkSharpSourceAnalyzerTests
|
||||
{
|
||||
[Test]
|
||||
public void Analyze_WhenGivenGwtLikeSource_ShouldReturnStructuralCounts()
|
||||
{
|
||||
const string Source = """
|
||||
function defineClass(typeId, superTypeIdOrPrototype, castableTypeMap){
|
||||
}
|
||||
|
||||
function layout_10(graphObj, layoutOptionsObj, optionsObj){
|
||||
}
|
||||
|
||||
function createSomething(){
|
||||
defineClass(1, 0, {});
|
||||
createForClass('pkg', 'Clazz', 1);
|
||||
registerLayoutAlgorithms(data_0.algorithms);
|
||||
}
|
||||
|
||||
var $intern_0 = 1;
|
||||
var $intern_1 = 2;
|
||||
""";
|
||||
|
||||
var profile = ElkSharpSourceAnalyzer.Analyze("elk-worker.js", Source);
|
||||
|
||||
profile.SourceName.Should().Be("elk-worker.js");
|
||||
profile.LineCount.Should().BeGreaterThan(0);
|
||||
profile.CharacterCount.Should().Be(Source.Length);
|
||||
profile.FunctionCount.Should().Be(3);
|
||||
profile.DefineClassCount.Should().Be(2);
|
||||
profile.CreateForClassCount.Should().Be(1);
|
||||
profile.InternConstantCount.Should().Be(2);
|
||||
profile.LayoutCommandCount.Should().Be(1);
|
||||
profile.RegisterAlgorithmCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
{
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "root",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task-1",
|
||||
Label = "Review",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task-1",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e2",
|
||||
SourceNodeId = "task-1",
|
||||
TargetNodeId = "end",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
result.GraphId.Should().Be("root");
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Edges.Should().HaveCount(2);
|
||||
|
||||
var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray();
|
||||
orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end");
|
||||
result.Edges.Should().OnlyContain(x => x.Sections.Count > 0);
|
||||
var startNode = result.Nodes.Single(x => x.Id == "start");
|
||||
var taskNode = result.Nodes.Single(x => x.Id == "task-1");
|
||||
var endNode = result.Nodes.Single(x => x.Id == "end");
|
||||
(startNode.Y + (startNode.Height / 2d)).Should().BeApproximately(taskNode.Y + (taskNode.Height / 2d), 0.01d);
|
||||
(endNode.Y + (endNode.Height / 2d)).Should().BeApproximately(taskNode.Y + (taskNode.Height / 2d), 0.01d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenBranchTargetIsBelowSource_ShouldUseLowerAnchorPoint()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "branch",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "source",
|
||||
Label = "Source",
|
||||
Kind = "Decision",
|
||||
Width = 144,
|
||||
Height = 120,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "upper",
|
||||
Label = "Upper",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "lower",
|
||||
Label = "Lower",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-upper",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "upper",
|
||||
Label = "when true",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-lower",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "lower",
|
||||
Label = "otherwise",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var source = result.Nodes.Single(x => x.Id == "source");
|
||||
var upper = result.Nodes.Single(x => x.Id == "upper");
|
||||
var lower = result.Nodes.Single(x => x.Id == "lower");
|
||||
var lowerEdge = result.Edges.Single(x => x.Id == "e-lower").Sections.Single();
|
||||
var sourceCenterY = source.Y + (source.Height / 2d);
|
||||
var upperCenterY = upper.Y + (upper.Height / 2d);
|
||||
var lowerCenterY = lower.Y + (lower.Height / 2d);
|
||||
|
||||
upperCenterY.Should().BeLessThan(lowerCenterY);
|
||||
lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "same-lane",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "left",
|
||||
Label = "Set printGenerateFailed",
|
||||
Kind = "SetState",
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "right",
|
||||
Label = "Set hasMissingDocuments",
|
||||
Kind = "SetState",
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "left",
|
||||
TargetNodeId = "right",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var left = result.Nodes.Single(x => x.Id == "left");
|
||||
var right = result.Nodes.Single(x => x.Id == "right");
|
||||
var edge = result.Edges.Single().Sections.Single();
|
||||
|
||||
edge.BendPoints.Should().BeEmpty();
|
||||
edge.StartPoint.X.Should().BeApproximately(left.X + left.Width, 0.01d);
|
||||
edge.EndPoint.X.Should().BeApproximately(right.X, 0.01d);
|
||||
edge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(left.Y);
|
||||
edge.StartPoint.Y.Should().BeLessThanOrEqualTo(left.Y + left.Height);
|
||||
edge.EndPoint.Y.Should().BeGreaterThanOrEqualTo(right.Y);
|
||||
edge.EndPoint.Y.Should().BeLessThanOrEqualTo(right.Y + right.Height);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenRetryEdgePointsBackwards_ShouldKeepPrimaryFlowForwardAndRouteBackEdgeOutside()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "retry-loop",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "service",
|
||||
Label = "Call service",
|
||||
Kind = "TransportCall",
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "retry",
|
||||
Label = "Retry",
|
||||
Kind = "HumanTask",
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-start",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "service",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-failure",
|
||||
SourceNodeId = "service",
|
||||
TargetNodeId = "retry",
|
||||
Label = "on failure",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-retry",
|
||||
SourceNodeId = "retry",
|
||||
TargetNodeId = "service",
|
||||
Label = "retry",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-end",
|
||||
SourceNodeId = "service",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var start = result.Nodes.Single(x => x.Id == "start");
|
||||
var service = result.Nodes.Single(x => x.Id == "service");
|
||||
var retry = result.Nodes.Single(x => x.Id == "retry");
|
||||
var end = result.Nodes.Single(x => x.Id == "end");
|
||||
var retryEdge = result.Edges.Single(x => x.Id == "e-retry").Sections.Single();
|
||||
|
||||
start.X.Should().BeLessThan(service.X);
|
||||
service.X.Should().BeLessThan(retry.X);
|
||||
end.X.Should().BeGreaterThan(service.X);
|
||||
retryEdge.StartPoint.Y.Should().BeApproximately(retry.Y, 0.01d);
|
||||
retryEdge.EndPoint.Y.Should().BeApproximately(service.Y, 0.01d);
|
||||
retryEdge.BendPoints.Should().HaveCountGreaterThan(0);
|
||||
retryEdge.BendPoints.Should().OnlyContain(point => point.Y < service.Y);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenEndNodeIsDeclaredBeforeItsPredecessors_ShouldStillPlaceEndAsASink()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "end-order",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task-a",
|
||||
Label = "Task A",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task-b",
|
||||
Label = "Task B",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "start-a",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task-a",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "a-b",
|
||||
SourceNodeId = "task-a",
|
||||
TargetNodeId = "task-b",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "a-end",
|
||||
SourceNodeId = "task-a",
|
||||
TargetNodeId = "end",
|
||||
Label = "on failure",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "b-end",
|
||||
SourceNodeId = "task-b",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
Effort = WorkflowRenderLayoutEffort.Best,
|
||||
});
|
||||
|
||||
var taskA = result.Nodes.Single(node => node.Id == "task-a");
|
||||
var taskB = result.Nodes.Single(node => node.Id == "task-b");
|
||||
var end = result.Nodes.Single(node => node.Id == "end");
|
||||
var edgeToEnd = result.Edges.Single(edge => edge.Id == "b-end").Sections.Single();
|
||||
|
||||
end.X.Should().BeGreaterThan(taskA.X);
|
||||
end.X.Should().BeGreaterThan(taskB.X);
|
||||
edgeToEnd.EndPoint.X.Should().BeApproximately(end.X, 0.01d);
|
||||
edgeToEnd.BendPoints.Should().OnlyContain(point => point.X >= taskA.X);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenLinearChainFeedsFork_ShouldKeepNodeCentersAligned()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "chain-alignment",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task",
|
||||
Label = "Task",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "fork",
|
||||
Label = "Fork",
|
||||
Kind = "Fork",
|
||||
Width = 120,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "branch-a",
|
||||
Label = "Branch A",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "branch-b",
|
||||
Label = "Branch B",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "start-task",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "task-fork",
|
||||
SourceNodeId = "task",
|
||||
TargetNodeId = "fork",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "fork-a",
|
||||
SourceNodeId = "fork",
|
||||
TargetNodeId = "branch-a",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "fork-b",
|
||||
SourceNodeId = "fork",
|
||||
TargetNodeId = "branch-b",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
Effort = WorkflowRenderLayoutEffort.Best,
|
||||
});
|
||||
|
||||
var start = result.Nodes.Single(node => node.Id == "start");
|
||||
var task = result.Nodes.Single(node => node.Id == "task");
|
||||
var fork = result.Nodes.Single(node => node.Id == "fork");
|
||||
|
||||
var startCenterY = start.Y + (start.Height / 2d);
|
||||
var taskCenterY = task.Y + (task.Height / 2d);
|
||||
var forkCenterY = fork.Y + (fork.Height / 2d);
|
||||
|
||||
taskCenterY.Should().BeApproximately(startCenterY, 0.01d);
|
||||
forkCenterY.Should().BeApproximately(taskCenterY, 0.01d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenLongEdgeUsesPorts_ShouldPreservePortAnchors()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "ports",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "a",
|
||||
Label = "A",
|
||||
Kind = "Task",
|
||||
Width = 120,
|
||||
Height = 72,
|
||||
Ports =
|
||||
[
|
||||
new WorkflowRenderPort { Id = "a-out", Side = "EAST" },
|
||||
],
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "b",
|
||||
Label = "B",
|
||||
Kind = "Task",
|
||||
Width = 120,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "c",
|
||||
Label = "C",
|
||||
Kind = "Task",
|
||||
Width = 120,
|
||||
Height = 72,
|
||||
Ports =
|
||||
[
|
||||
new WorkflowRenderPort { Id = "c-in", Side = "WEST" },
|
||||
],
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "ab",
|
||||
SourceNodeId = "a",
|
||||
TargetNodeId = "b",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "bc",
|
||||
SourceNodeId = "b",
|
||||
TargetNodeId = "c",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "ac",
|
||||
SourceNodeId = "a",
|
||||
TargetNodeId = "c",
|
||||
SourcePortId = "a-out",
|
||||
TargetPortId = "c-in",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = result.Edges.Single(x => x.Id == "ac").Sections.Single();
|
||||
var source = result.Nodes.Single(x => x.Id == "a");
|
||||
var target = result.Nodes.Single(x => x.Id == "c");
|
||||
var sourcePort = source.Ports.Single(x => x.Id == "a-out");
|
||||
var targetPort = target.Ports.Single(x => x.Id == "c-in");
|
||||
|
||||
edge.StartPoint.X.Should().BeApproximately(sourcePort.X + (sourcePort.Width / 2d), 0.01d);
|
||||
edge.StartPoint.Y.Should().BeApproximately(sourcePort.Y + (sourcePort.Height / 2d), 0.01d);
|
||||
edge.EndPoint.X.Should().BeApproximately(targetPort.X + (targetPort.Width / 2d), 0.01d);
|
||||
edge.EndPoint.Y.Should().BeApproximately(targetPort.Y + (targetPort.Height / 2d), 0.01d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenTopToBottomEdgesConverge_ShouldSpreadTargetAnchorsAcrossTopSide()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "top-to-bottom",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "left",
|
||||
Label = "Left",
|
||||
Kind = "Task",
|
||||
Width = 120,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "right",
|
||||
Label = "Right",
|
||||
Kind = "Task",
|
||||
Width = 120,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "target",
|
||||
Label = "Target",
|
||||
Kind = "Task",
|
||||
Width = 144,
|
||||
Height = 80,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "lt",
|
||||
SourceNodeId = "left",
|
||||
TargetNodeId = "target",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "rt",
|
||||
SourceNodeId = "right",
|
||||
TargetNodeId = "target",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.TopToBottom,
|
||||
});
|
||||
|
||||
var target = result.Nodes.Single(x => x.Id == "target");
|
||||
var leftEdge = result.Edges.Single(x => x.Id == "lt").Sections.Single();
|
||||
var rightEdge = result.Edges.Single(x => x.Id == "rt").Sections.Single();
|
||||
|
||||
leftEdge.EndPoint.Y.Should().BeApproximately(target.Y, 0.01d);
|
||||
rightEdge.EndPoint.Y.Should().BeApproximately(target.Y, 0.01d);
|
||||
leftEdge.EndPoint.X.Should().NotBeApproximately(rightEdge.EndPoint.X, 0.01d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenMultipleLaneFamiliesConvergeIntoEnd_ShouldReserveDistinctBundleBands()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "end-bundles",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "success-a",
|
||||
Label = "Success A",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "success-b",
|
||||
Label = "Success B",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "failure",
|
||||
Label = "Failure",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "success-1",
|
||||
SourceNodeId = "success-a",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "success-2",
|
||||
SourceNodeId = "success-b",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "failure-1",
|
||||
SourceNodeId = "failure",
|
||||
TargetNodeId = "end",
|
||||
Label = "on failure",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
Effort = WorkflowRenderLayoutEffort.Best,
|
||||
OrderingIterations = 18,
|
||||
PlacementIterations = 10,
|
||||
});
|
||||
|
||||
var successOne = result.Edges.Single(edge => edge.Id == "success-1").Sections.Single();
|
||||
var successTwo = result.Edges.Single(edge => edge.Id == "success-2").Sections.Single();
|
||||
var failure = result.Edges.Single(edge => edge.Id == "failure-1").Sections.Single();
|
||||
|
||||
var successBundleYOne = ResolvePreTargetBundleY(successOne);
|
||||
var successBundleYTwo = ResolvePreTargetBundleY(successTwo);
|
||||
var failureBundleY = ResolvePreTargetBundleY(failure);
|
||||
|
||||
successBundleYOne.Should().BeApproximately(successBundleYTwo, 0.01d);
|
||||
failureBundleY.Should().NotBeApproximately(successBundleYOne, 0.01d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenLongEndFanInExists_ShouldUseExternalSinkCorridorInsteadOfInteriorDummyCenters()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "end-highway",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "prepare",
|
||||
Label = "Prepare",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "review",
|
||||
Label = "Review",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "notify",
|
||||
Label = "Notify",
|
||||
Kind = "Task",
|
||||
Width = 140,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 88,
|
||||
Height = 48,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "start-prepare",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "prepare",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "prepare-review",
|
||||
SourceNodeId = "prepare",
|
||||
TargetNodeId = "review",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "review-notify",
|
||||
SourceNodeId = "review",
|
||||
TargetNodeId = "notify",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "prepare-end",
|
||||
SourceNodeId = "prepare",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "review-end",
|
||||
SourceNodeId = "review",
|
||||
TargetNodeId = "end",
|
||||
Label = "default",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "notify-end",
|
||||
SourceNodeId = "notify",
|
||||
TargetNodeId = "end",
|
||||
Label = "on failure",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
Effort = WorkflowRenderLayoutEffort.Best,
|
||||
OrderingIterations = 18,
|
||||
PlacementIterations = 10,
|
||||
});
|
||||
|
||||
var prepare = result.Nodes.Single(node => node.Id == "prepare");
|
||||
var end = result.Nodes.Single(node => node.Id == "end");
|
||||
var maxInteriorBottom = result.Nodes
|
||||
.Where(node => !string.Equals(node.Id, "end", StringComparison.Ordinal))
|
||||
.Max(node => node.Y + node.Height);
|
||||
var longEdge = result.Edges.Single(edge => edge.Id == "prepare-end").Sections.Single();
|
||||
var defaultPeer = result.Edges.Single(edge => edge.Id == "review-end").Sections.Single();
|
||||
|
||||
longEdge.BendPoints.Should().Contain(point => point.Y > maxInteriorBottom + 1d);
|
||||
defaultPeer.BendPoints.Should().Contain(point => point.Y > maxInteriorBottom + 1d);
|
||||
longEdge.BendPoints.Should().NotContain(point =>
|
||||
point.Y <= maxInteriorBottom + 1d
|
||||
&& point.X > prepare.X + prepare.Width + 96d
|
||||
&& point.X < end.X - 96d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldUseSharedSourceCollectorColumn()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "backward-family-collector",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "target",
|
||||
Label = "Target",
|
||||
Kind = "Repeat",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "a",
|
||||
Label = "A",
|
||||
Kind = "Decision",
|
||||
Width = 144,
|
||||
Height = 96,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "b",
|
||||
Label = "B",
|
||||
Kind = "Decision",
|
||||
Width = 144,
|
||||
Height = 96,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "c",
|
||||
Label = "C",
|
||||
Kind = "SetState",
|
||||
Width = 176,
|
||||
Height = 84,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "target-a",
|
||||
SourceNodeId = "target",
|
||||
TargetNodeId = "a",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "a-b",
|
||||
SourceNodeId = "a",
|
||||
TargetNodeId = "b",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "b-c",
|
||||
SourceNodeId = "b",
|
||||
TargetNodeId = "c",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "loop-a",
|
||||
SourceNodeId = "a",
|
||||
TargetNodeId = "target",
|
||||
Label = "repeat while retry",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "loop-b",
|
||||
SourceNodeId = "b",
|
||||
TargetNodeId = "target",
|
||||
Label = "repeat while retry",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "loop-c",
|
||||
SourceNodeId = "c",
|
||||
TargetNodeId = "target",
|
||||
Label = "repeat while retry",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
Effort = WorkflowRenderLayoutEffort.Best,
|
||||
});
|
||||
|
||||
var loopEdges = result.Edges
|
||||
.Where(edge => edge.Id is "loop-a" or "loop-b" or "loop-c")
|
||||
.Select(edge => edge.Sections.Single())
|
||||
.ToArray();
|
||||
|
||||
loopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
|
||||
var sharedCollectorX = loopEdges[0].BendPoints.ElementAt(0).X;
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(0).X - sharedCollectorX) <= 0.01d);
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).X - sharedCollectorX) <= 0.01d);
|
||||
var sharedCorridorY = loopEdges[0].BendPoints.ElementAt(1).Y;
|
||||
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).Y - sharedCorridorY) <= 0.01d);
|
||||
}
|
||||
|
||||
private static double ResolvePreTargetBundleY(WorkflowRenderEdgeSection section)
|
||||
{
|
||||
var preTargetX = section.BendPoints.Max(point => point.X);
|
||||
var bundlePoint = section.BendPoints
|
||||
.Where(point => Math.Abs(point.X - preTargetX) <= 0.01d && Math.Abs(point.Y - section.EndPoint.Y) > 0.01d)
|
||||
.OrderBy(point => point.Y)
|
||||
.First();
|
||||
|
||||
return bundlePoint.Y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.Msagl;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class MsaglWorkflowRenderLayoutEngineTests
|
||||
{
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSimpleLinearGraphProvided_ShouldReturnPositionedNodesAndEdges()
|
||||
{
|
||||
var engine = new MsaglWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "root",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "task-1",
|
||||
Label = "Review",
|
||||
Kind = "Task",
|
||||
Width = 160,
|
||||
Height = 72,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task-1",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e2",
|
||||
SourceNodeId = "task-1",
|
||||
TargetNodeId = "end",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
result.GraphId.Should().Be("root");
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Edges.Should().HaveCount(2);
|
||||
|
||||
var orderedNodes = result.Nodes.OrderBy(x => x.X).ToArray();
|
||||
orderedNodes.Select(x => x.Id).Should().ContainInOrder("start", "task-1", "end");
|
||||
result.Edges.Should().OnlyContain(x => x.Sections.Count > 0);
|
||||
result.Edges.SelectMany(x => x.Sections).Should().OnlyContain(section =>
|
||||
Math.Abs(section.StartPoint.X) > 0.001d
|
||||
&& Math.Abs(section.EndPoint.X) > 0.001d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseXunitV3>false</UseXunitV3>
|
||||
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
|
||||
<NoWarn>CS8601;CS8602;CS8604;NU1015</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.3.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Abstractions\StellaOps.Workflow.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Contracts\StellaOps.Workflow.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Engine\StellaOps.Workflow.Engine.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.ElkSharp\StellaOps.Workflow.Renderer.ElkSharp.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.ElkJs\StellaOps.Workflow.Renderer.ElkJs.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.Msagl\StellaOps.Workflow.Renderer.Msagl.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Workflow.Renderer.Svg\StellaOps.Workflow.Renderer.Svg.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.ElkSharp\StellaOps.ElkSharp.csproj" />
|
||||
<!-- <ProjectReference Include="..\StellaOps.Workflow.Engine.Tests\StellaOps.Workflow.Engine.Tests.csproj" /> -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="WorkflowRenderingBenchmarkTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,261 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class WorkflowRenderSvgRendererTests
|
||||
{
|
||||
[Test]
|
||||
public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches()
|
||||
{
|
||||
var renderer = new WorkflowRenderSvgRenderer();
|
||||
var layout = new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = "svg",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
IconKey = "start",
|
||||
X = 0,
|
||||
Y = 12,
|
||||
Width = 128,
|
||||
Height = 64,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "task",
|
||||
Label = "Call Pricing",
|
||||
Kind = "TransportCall",
|
||||
IconKey = "transport",
|
||||
X = 220,
|
||||
Y = 0,
|
||||
Width = 196,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "timer",
|
||||
Label = "Wait For Timeout",
|
||||
Kind = "Timer",
|
||||
IconKey = "timer",
|
||||
X = 220,
|
||||
Y = 126,
|
||||
Width = 196,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "decision",
|
||||
Label = "Approved?",
|
||||
Kind = "Decision",
|
||||
IconKey = "decision",
|
||||
X = 500,
|
||||
Y = 0,
|
||||
Width = 144,
|
||||
Height = 96,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "human",
|
||||
Label = "Review Documents",
|
||||
Kind = "HumanTask",
|
||||
IconKey = "human",
|
||||
X = 500,
|
||||
Y = 126,
|
||||
Width = 196,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "fork",
|
||||
Label = "Fan Out",
|
||||
Kind = "Fork",
|
||||
IconKey = "fork",
|
||||
X = 720,
|
||||
Y = 0,
|
||||
Width = 144,
|
||||
Height = 96,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "task",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 128, Y = 44 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 220, Y = 42 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "e2",
|
||||
SourceNodeId = "task",
|
||||
TargetNodeId = "decision",
|
||||
Label = "when payload.answer == \"approve\"",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 416, Y = 42 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 500, Y = 48 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "e3",
|
||||
SourceNodeId = "task",
|
||||
TargetNodeId = "timer",
|
||||
Label = "on timeout",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 416, Y = 42 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 220, Y = 168 },
|
||||
BendPoints =
|
||||
[
|
||||
new WorkflowRenderPoint { X = 460, Y = 42 },
|
||||
new WorkflowRenderPoint { X = 460, Y = 168 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var document = renderer.Render(layout, "SvgSmoke");
|
||||
|
||||
document.Svg.Should().Contain("<rect");
|
||||
document.Svg.Should().Contain("<polygon");
|
||||
document.Svg.Should().Contain("Legend");
|
||||
document.Svg.Should().Contain("Node Shapes:");
|
||||
document.Svg.Should().Contain("Decision / Branch");
|
||||
document.Svg.Should().Contain("Fork / Join");
|
||||
document.Svg.Should().Contain("Human Task");
|
||||
document.Svg.Should().Contain("service call");
|
||||
document.Svg.Should().Contain("markerWidth=\"5\"");
|
||||
document.Svg.Should().Contain("stroke-dasharray=\"2.2 4.8\"");
|
||||
document.Svg.Should().Contain("fill-opacity=\"0.54\"");
|
||||
document.Svg.Should().Contain("when payload.answer == "approve"");
|
||||
document.Svg.Should().Contain("stroke=\"#15803d\"");
|
||||
document.Svg.Should().Contain("Call Pricing");
|
||||
document.Svg.Should().Contain(">Wait For Timeout<");
|
||||
document.Svg.Should().Contain(">Review Documents<");
|
||||
document.Svg.Should().Contain("fill=\"#f4fdfb\" stroke=\"#0f766e\" stroke-width=\"3.5\"");
|
||||
document.Svg.Should().Contain("Approved?");
|
||||
document.Svg.Should().NotContain("data-badge-kind=\"Timer\"");
|
||||
document.Svg.Should().NotContain(">F<");
|
||||
document.Svg.Should().NotContain(">?<");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_WhenOrthogonalEdgesCross_ShouldEmitBridgeGap()
|
||||
{
|
||||
var renderer = new WorkflowRenderSvgRenderer();
|
||||
var layout = new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = "crossings",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "left",
|
||||
Label = "Left",
|
||||
Kind = "Task",
|
||||
X = 0,
|
||||
Y = 70,
|
||||
Width = 96,
|
||||
Height = 56,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "right",
|
||||
Label = "Right",
|
||||
Kind = "Task",
|
||||
X = 280,
|
||||
Y = 70,
|
||||
Width = 96,
|
||||
Height = 56,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "top",
|
||||
Label = "Top",
|
||||
Kind = "Task",
|
||||
X = 140,
|
||||
Y = 0,
|
||||
Width = 96,
|
||||
Height = 56,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "bottom",
|
||||
Label = "Bottom",
|
||||
Kind = "Task",
|
||||
X = 140,
|
||||
Y = 180,
|
||||
Width = 96,
|
||||
Height = 56,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "horizontal",
|
||||
SourceNodeId = "left",
|
||||
TargetNodeId = "right",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 96, Y = 98 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 280, Y = 98 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "vertical",
|
||||
SourceNodeId = "top",
|
||||
TargetNodeId = "bottom",
|
||||
Label = "on failure",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 188, Y = 56 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 188, Y = 180 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var document = renderer.Render(layout, "BridgeGap");
|
||||
|
||||
document.Svg.Should().Contain("data-bridge-gap=\"true\"");
|
||||
document.Svg.Should().Contain("M 214.93,318");
|
||||
document.Svg.Should().Contain("L 225.07,318");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
internal sealed record WorkflowRenderLayoutMetrics
|
||||
{
|
||||
public required int NodeOverlapCount { get; init; }
|
||||
public required int EdgeNodeIntersectionCount { get; init; }
|
||||
public required int EdgeCrossingCount { get; init; }
|
||||
public required int BendCount { get; init; }
|
||||
public required int PortViolationCount { get; init; }
|
||||
public required int FeedbackEdgeCount { get; init; }
|
||||
public required int FeedbackBandViolationCount { get; init; }
|
||||
public required double ForwardEdgeRatio { get; init; }
|
||||
public required double Area { get; init; }
|
||||
public required double QualityScore { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record WorkflowRenderBenchmarkEntry
|
||||
{
|
||||
public required string WorkflowName { get; init; }
|
||||
public required string ProviderName { get; init; }
|
||||
public required WorkflowRenderLayoutMetrics Metrics { get; init; }
|
||||
}
|
||||
|
||||
internal static class WorkflowRenderingBenchmark
|
||||
{
|
||||
internal static async Task<IReadOnlyCollection<WorkflowRenderBenchmarkEntry>> MeasureAsync(
|
||||
ServiceProvider provider,
|
||||
IReadOnlyCollection<string> workflowNames,
|
||||
IReadOnlyCollection<string> providerNames,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
|
||||
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
|
||||
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
|
||||
var entries = new List<WorkflowRenderBenchmarkEntry>(workflowNames.Count * providerNames.Count);
|
||||
|
||||
foreach (var workflowName in workflowNames)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var definition = store.GetRequiredDefinition(workflowName);
|
||||
var graph = compiler.Compile(definition);
|
||||
foreach (var providerName in providerNames)
|
||||
{
|
||||
var layout = await resolver.Resolve(providerName).LayoutAsync(graph, cancellationToken: cancellationToken);
|
||||
entries.Add(new WorkflowRenderBenchmarkEntry
|
||||
{
|
||||
WorkflowName = workflowName,
|
||||
ProviderName = providerName,
|
||||
Metrics = CalculateMetrics(layout),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
internal static async Task WriteSummaryAsync(
|
||||
IReadOnlyCollection<WorkflowRenderBenchmarkEntry> entries,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
outputPath,
|
||||
JsonSerializer.Serialize(entries, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
}),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal static WorkflowRenderLayoutMetrics CalculateMetrics(WorkflowRenderLayoutResult layout)
|
||||
{
|
||||
var nodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var nodeRects = layout.Nodes
|
||||
.ToDictionary(node => node.Id, node => ToRect(node), StringComparer.Ordinal);
|
||||
var segments = layout.Edges
|
||||
.SelectMany(edge => EnumerateSegments(edge).Select(segment => new EdgeSegment(edge, segment.Start, segment.End)))
|
||||
.ToArray();
|
||||
|
||||
var nodeOverlapCount = 0;
|
||||
for (var leftIndex = 0; leftIndex < layout.Nodes.Count; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < layout.Nodes.Count; rightIndex++)
|
||||
{
|
||||
var left = layout.Nodes.ElementAt(leftIndex);
|
||||
var right = layout.Nodes.ElementAt(rightIndex);
|
||||
if (RectanglesOverlap(nodeRects[left.Id], nodeRects[right.Id], 0.25d))
|
||||
{
|
||||
nodeOverlapCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var edgeNodeIntersectionCount = 0;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
if (string.Equals(node.Id, segment.Edge.SourceNodeId, StringComparison.Ordinal)
|
||||
|| string.Equals(node.Id, segment.Edge.TargetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SegmentIntersectsRect(segment.Start, segment.End, nodeRects[node.Id]))
|
||||
{
|
||||
edgeNodeIntersectionCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var edgeCrossingCount = 0;
|
||||
for (var leftIndex = 0; leftIndex < segments.Length; leftIndex++)
|
||||
{
|
||||
for (var rightIndex = leftIndex + 1; rightIndex < segments.Length; rightIndex++)
|
||||
{
|
||||
var left = segments[leftIndex];
|
||||
var right = segments[rightIndex];
|
||||
if (string.Equals(left.Edge.Id, right.Edge.Id, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(left.Edge.SourceNodeId, right.Edge.SourceNodeId, StringComparison.Ordinal)
|
||||
|| string.Equals(left.Edge.SourceNodeId, right.Edge.TargetNodeId, StringComparison.Ordinal)
|
||||
|| string.Equals(left.Edge.TargetNodeId, right.Edge.SourceNodeId, StringComparison.Ordinal)
|
||||
|| string.Equals(left.Edge.TargetNodeId, right.Edge.TargetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SegmentsCross(left.Start, left.End, right.Start, right.End))
|
||||
{
|
||||
edgeCrossingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bendCount = layout.Edges.Sum(edge => edge.Sections.Sum(section => section.BendPoints.Count));
|
||||
var portViolationCount = layout.Edges.Sum(edge => CountPortViolations(edge, nodesById));
|
||||
var feedbackEdges = layout.Edges.Where(edge => IsFeedbackEdge(edge, nodesById)).ToArray();
|
||||
var feedbackBandViolationCount = feedbackEdges.Count(edge => !IsFeedbackEdgeRoutedAboveMainFlow(edge, nodesById));
|
||||
var forwardEdgeRatio = CalculateForwardEdgeRatio(layout, nodesById);
|
||||
var area = CalculateArea(layout.Nodes);
|
||||
var qualityScore = (forwardEdgeRatio * 100d)
|
||||
- (nodeOverlapCount * 1000d)
|
||||
- (edgeNodeIntersectionCount * 150d)
|
||||
- (edgeCrossingCount * 12d)
|
||||
- (bendCount * 1.5d)
|
||||
- (portViolationCount * 80d)
|
||||
- (feedbackBandViolationCount * 35d)
|
||||
- (area / 50000d);
|
||||
|
||||
return new WorkflowRenderLayoutMetrics
|
||||
{
|
||||
NodeOverlapCount = nodeOverlapCount,
|
||||
EdgeNodeIntersectionCount = edgeNodeIntersectionCount,
|
||||
EdgeCrossingCount = edgeCrossingCount,
|
||||
BendCount = bendCount,
|
||||
PortViolationCount = portViolationCount,
|
||||
FeedbackEdgeCount = feedbackEdges.Length,
|
||||
FeedbackBandViolationCount = feedbackBandViolationCount,
|
||||
ForwardEdgeRatio = forwardEdgeRatio,
|
||||
Area = area,
|
||||
QualityScore = qualityScore,
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountPortViolations(
|
||||
WorkflowRenderRoutedEdge edge,
|
||||
IReadOnlyDictionary<string, WorkflowRenderPositionedNode> nodesById)
|
||||
{
|
||||
if (edge.Sections.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var section = edge.Sections.ElementAt(0);
|
||||
var violations = 0;
|
||||
if (!string.IsNullOrWhiteSpace(edge.SourcePortId))
|
||||
{
|
||||
var port = nodesById[edge.SourceNodeId].Ports.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Id, edge.SourcePortId, StringComparison.Ordinal));
|
||||
if (port is not null && !PointsEqual(section.StartPoint, port.X + (port.Width / 2d), port.Y + (port.Height / 2d)))
|
||||
{
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.TargetPortId))
|
||||
{
|
||||
var port = nodesById[edge.TargetNodeId].Ports.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Id, edge.TargetPortId, StringComparison.Ordinal));
|
||||
if (port is not null && !PointsEqual(section.EndPoint, port.X + (port.Width / 2d), port.Y + (port.Height / 2d)))
|
||||
{
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static bool PointsEqual(WorkflowRenderPoint point, double x, double y)
|
||||
{
|
||||
return Math.Abs(point.X - x) <= 0.1d && Math.Abs(point.Y - y) <= 0.1d;
|
||||
}
|
||||
|
||||
private static bool IsFeedbackEdge(
|
||||
WorkflowRenderRoutedEdge edge,
|
||||
IReadOnlyDictionary<string, WorkflowRenderPositionedNode> nodesById)
|
||||
{
|
||||
var source = nodesById[edge.SourceNodeId];
|
||||
var target = nodesById[edge.TargetNodeId];
|
||||
return (target.X + (target.Width / 2d)) < (source.X + (source.Width / 2d)) - 1d;
|
||||
}
|
||||
|
||||
private static bool IsFeedbackEdgeRoutedAboveMainFlow(
|
||||
WorkflowRenderRoutedEdge edge,
|
||||
IReadOnlyDictionary<string, WorkflowRenderPositionedNode> nodesById)
|
||||
{
|
||||
if (edge.Sections.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var source = nodesById[edge.SourceNodeId];
|
||||
var target = nodesById[edge.TargetNodeId];
|
||||
var threshold = Math.Min(source.Y, target.Y) + 1d;
|
||||
return edge.Sections
|
||||
.SelectMany(section => section.BendPoints)
|
||||
.All(point => point.Y < threshold);
|
||||
}
|
||||
|
||||
private static double CalculateForwardEdgeRatio(
|
||||
WorkflowRenderLayoutResult layout,
|
||||
IReadOnlyDictionary<string, WorkflowRenderPositionedNode> nodesById)
|
||||
{
|
||||
if (layout.Edges.Count == 0)
|
||||
{
|
||||
return 1d;
|
||||
}
|
||||
|
||||
var forwardEdges = layout.Edges.Count(edge =>
|
||||
{
|
||||
var source = nodesById[edge.SourceNodeId];
|
||||
var target = nodesById[edge.TargetNodeId];
|
||||
return (target.X + (target.Width / 2d)) >= (source.X + (source.Width / 2d)) - 1d;
|
||||
});
|
||||
|
||||
return forwardEdges / (double)layout.Edges.Count;
|
||||
}
|
||||
|
||||
private static double CalculateArea(IReadOnlyCollection<WorkflowRenderPositionedNode> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var minX = nodes.Min(node => node.X);
|
||||
var minY = nodes.Min(node => node.Y);
|
||||
var maxX = nodes.Max(node => node.X + node.Width);
|
||||
var maxY = nodes.Max(node => node.Y + node.Height);
|
||||
return Math.Max(1d, maxX - minX) * Math.Max(1d, maxY - minY);
|
||||
}
|
||||
|
||||
private static IEnumerable<(WorkflowRenderPoint Start, WorkflowRenderPoint End)> EnumerateSegments(WorkflowRenderRoutedEdge edge)
|
||||
{
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var points = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
points.AddRange(section.BendPoints);
|
||||
points.Add(section.EndPoint);
|
||||
for (var index = 0; index < points.Count - 1; index++)
|
||||
{
|
||||
yield return (points[index], points[index + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkflowRenderRect ToRect(WorkflowRenderPositionedNode node)
|
||||
{
|
||||
return new WorkflowRenderRect(node.X, node.Y, node.Width, node.Height);
|
||||
}
|
||||
|
||||
private static bool RectanglesOverlap(WorkflowRenderRect first, WorkflowRenderRect second, double epsilon)
|
||||
{
|
||||
return first.Left < second.Right - epsilon
|
||||
&& first.Right > second.Left + epsilon
|
||||
&& first.Top < second.Bottom - epsilon
|
||||
&& first.Bottom > second.Top + epsilon;
|
||||
}
|
||||
|
||||
private static bool SegmentIntersectsRect(
|
||||
WorkflowRenderPoint start,
|
||||
WorkflowRenderPoint end,
|
||||
WorkflowRenderRect rect)
|
||||
{
|
||||
const double epsilon = 0.25d;
|
||||
if (Math.Abs(start.Y - end.Y) <= 0.01d)
|
||||
{
|
||||
var y = start.Y;
|
||||
if (y <= rect.Top + epsilon || y >= rect.Bottom - epsilon)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minX = Math.Min(start.X, end.X);
|
||||
var maxX = Math.Max(start.X, end.X);
|
||||
return minX < rect.Right - epsilon && maxX > rect.Left + epsilon;
|
||||
}
|
||||
|
||||
if (Math.Abs(start.X - end.X) <= 0.01d)
|
||||
{
|
||||
var x = start.X;
|
||||
if (x <= rect.Left + epsilon || x >= rect.Right - epsilon)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minY = Math.Min(start.Y, end.Y);
|
||||
var maxY = Math.Max(start.Y, end.Y);
|
||||
return minY < rect.Bottom - epsilon && maxY > rect.Top + epsilon;
|
||||
}
|
||||
|
||||
var segmentRect = new WorkflowRenderRect(
|
||||
Math.Min(start.X, end.X),
|
||||
Math.Min(start.Y, end.Y),
|
||||
Math.Abs(end.X - start.X),
|
||||
Math.Abs(end.Y - start.Y));
|
||||
return RectanglesOverlap(segmentRect, rect, epsilon);
|
||||
}
|
||||
|
||||
private static bool SegmentsCross(
|
||||
WorkflowRenderPoint firstStart,
|
||||
WorkflowRenderPoint firstEnd,
|
||||
WorkflowRenderPoint secondStart,
|
||||
WorkflowRenderPoint secondEnd)
|
||||
{
|
||||
const double epsilon = 0.01d;
|
||||
var firstHorizontal = Math.Abs(firstStart.Y - firstEnd.Y) <= epsilon;
|
||||
var firstVertical = Math.Abs(firstStart.X - firstEnd.X) <= epsilon;
|
||||
var secondHorizontal = Math.Abs(secondStart.Y - secondEnd.Y) <= epsilon;
|
||||
var secondVertical = Math.Abs(secondStart.X - secondEnd.X) <= epsilon;
|
||||
|
||||
if (firstHorizontal && secondVertical)
|
||||
{
|
||||
return IsInteriorCrossing(firstStart, firstEnd, secondStart, secondEnd);
|
||||
}
|
||||
|
||||
if (firstVertical && secondHorizontal)
|
||||
{
|
||||
return IsInteriorCrossing(secondStart, secondEnd, firstStart, firstEnd);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsInteriorCrossing(
|
||||
WorkflowRenderPoint horizontalStart,
|
||||
WorkflowRenderPoint horizontalEnd,
|
||||
WorkflowRenderPoint verticalStart,
|
||||
WorkflowRenderPoint verticalEnd)
|
||||
{
|
||||
var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X);
|
||||
var maxHorizontalX = Math.Max(horizontalStart.X, horizontalEnd.X);
|
||||
var minVerticalY = Math.Min(verticalStart.Y, verticalEnd.Y);
|
||||
var maxVerticalY = Math.Max(verticalStart.Y, verticalEnd.Y);
|
||||
var intersectionX = verticalStart.X;
|
||||
var intersectionY = horizontalStart.Y;
|
||||
|
||||
return intersectionX > minHorizontalX + 0.1d
|
||||
&& intersectionX < maxHorizontalX - 0.1d
|
||||
&& intersectionY > minVerticalY + 0.1d
|
||||
&& intersectionY < maxVerticalY - 0.1d;
|
||||
}
|
||||
|
||||
private sealed record EdgeSegment(
|
||||
WorkflowRenderRoutedEdge Edge,
|
||||
WorkflowRenderPoint Start,
|
||||
WorkflowRenderPoint End);
|
||||
|
||||
private readonly record struct WorkflowRenderRect(double Left, double Top, double Width, double Height)
|
||||
{
|
||||
public double Right => Left + Width;
|
||||
public double Bottom => Top + Height;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Engine.Tests;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class WorkflowRenderingBenchmarkTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ElkSharp_WhenRenderingAssistantPrintInsisDocuments_ShouldAvoidTopologyViolationsAndKeepFeedbackAboveMainFlow()
|
||||
{
|
||||
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
|
||||
var store = provider.GetRequiredService<IWorkflowRuntimeDefinitionStore>();
|
||||
var compiler = provider.GetRequiredService<IWorkflowRenderGraphCompiler>();
|
||||
var resolver = provider.GetRequiredService<IWorkflowRenderLayoutEngineResolver>();
|
||||
var graph = compiler.Compile(store.GetRequiredDefinition("AssistantPrintInsisDocuments"));
|
||||
|
||||
var layout = await resolver.Resolve(WorkflowRenderLayoutProviderNames.ElkSharp).LayoutAsync(graph);
|
||||
var metrics = WorkflowRenderingBenchmark.CalculateMetrics(layout);
|
||||
TestContext.Out.WriteLine(
|
||||
$"AssistantPrintInsisDocuments metrics: overlaps={metrics.NodeOverlapCount}, edgeNode={metrics.EdgeNodeIntersectionCount}, crossings={metrics.EdgeCrossingCount}, bends={metrics.BendCount}, feedback={metrics.FeedbackEdgeCount}, feedbackBand={metrics.FeedbackBandViolationCount}, forwardRatio={metrics.ForwardEdgeRatio:0.###}, score={metrics.QualityScore:0.##}");
|
||||
|
||||
metrics.NodeOverlapCount.Should().Be(0);
|
||||
metrics.PortViolationCount.Should().Be(0);
|
||||
metrics.FeedbackBandViolationCount.Should().BeLessThanOrEqualTo(1);
|
||||
metrics.ForwardEdgeRatio.Should().BeGreaterThan(0.55d);
|
||||
metrics.FeedbackEdgeCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingBenchmark")]
|
||||
public async Task ElkSharp_WhenBenchmarkingCanonicalCorpus_ShouldProduceMetricsSummaryWithoutNodeOverlaps()
|
||||
{
|
||||
using var provider = WorkflowRenderingTestHelpers.CreateRenderingServiceProvider();
|
||||
var workflowNames = WorkflowRenderingTestHelpers.GetAllCanonicalWorkflowNames(provider);
|
||||
var entries = await WorkflowRenderingBenchmark.MeasureAsync(
|
||||
provider,
|
||||
workflowNames,
|
||||
[WorkflowRenderLayoutProviderNames.ElkSharp]);
|
||||
var outputPath = Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"workflow-render-benchmarks",
|
||||
"elksharp-corpus-summary.json");
|
||||
|
||||
await WorkflowRenderingBenchmark.WriteSummaryAsync(entries, outputPath);
|
||||
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
entries.Should().HaveCount(workflowNames.Length);
|
||||
entries.Should().OnlyContain(entry => entry.Metrics.NodeOverlapCount == 0);
|
||||
entries.Should().OnlyContain(entry => entry.Metrics.PortViolationCount == 0);
|
||||
entries.Should().OnlyContain(entry => !double.IsNaN(entry.Metrics.QualityScore) && !double.IsInfinity(entry.Metrics.QualityScore));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user