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:
master
2026-03-20 19:14:44 +02:00
parent e56f9a114a
commit f5b5f24d95
422 changed files with 85428 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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 == &quot;approve&quot;");
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");
}
}

View File

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

View File

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