elksharp: stabilize document-processing terminal routing
This commit is contained in:
@@ -5,6 +5,8 @@ using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
@@ -13,14 +15,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
[Test]
|
||||
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
|
||||
{
|
||||
var workflowRenderingsDirectory = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults",
|
||||
"workflow-renderings");
|
||||
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
|
||||
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
|
||||
.First(Directory.Exists);
|
||||
var outputDir = RenderLatestElkSharpArtifactForInspection();
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
Assert.That(File.Exists(jsonPath), Is.True);
|
||||
|
||||
@@ -259,6 +254,7 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
Assert.That(gatewaySourceCount, Is.EqualTo(0), "Latest artifact should not report residual gateway-source violations.");
|
||||
|
||||
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Distinct()
|
||||
@@ -514,4 +510,51 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderLatestElkSharpArtifactForInspection()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults",
|
||||
"workflow-renderings",
|
||||
DateTime.Today.ToString("yyyyMMdd"),
|
||||
"DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
if (File.Exists(diagnosticsPath))
|
||||
{
|
||||
File.Delete(diagnosticsPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||
|
||||
var layout = engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
File.WriteAllText(Path.Combine(outputDir, "elksharp.svg"), svgDoc.Svg);
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputDir, "elksharp.json"),
|
||||
JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
File.WriteAllText(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,13 +198,14 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(sharedLaneOffenders, Is.Empty, "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.GatewaySourceExitViolations, Is.EqualTo(0), "Selected layout must not leave soft gateway-source false positives in the final diagnostic score.");
|
||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
@@ -309,4 +310,4 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,31 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
path.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(routedEdge => new ElkRoutedEdge
|
||||
{
|
||||
Id = routedEdge.Id,
|
||||
SourceNodeId = routedEdge.SourceNodeId,
|
||||
TargetNodeId = routedEdge.TargetNodeId,
|
||||
Kind = routedEdge.Kind,
|
||||
Label = routedEdge.Label,
|
||||
Sections = routedEdge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var repeatReturnEdges = elkEdges
|
||||
.Where(routedEdge => routedEdge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& (routedEdge.Id == "edge/14" || routedEdge.Id == "edge/15" || routedEdge.Id == "edge/35"))
|
||||
.ToArray();
|
||||
|
||||
Assert.That(
|
||||
ElkEdgeRoutingScoring.CountUnderNodeViolations(repeatReturnEdges, elkNodes),
|
||||
Is.EqualTo(0),
|
||||
"Repeat returns into Process Batch should stay above the Parallel Execution join field instead of routing under it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -283,4 +308,431 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
||||
Is.EqualTo(0),
|
||||
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldDockForkBranchIntoProcessBatchHeaderBand()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/3");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(3));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("top"),
|
||||
"Fork branch lanes into Process Batch should dock into the repeat header band instead of the left-face midpoint.");
|
||||
Assert.That(
|
||||
path[^1].Y,
|
||||
Is.EqualTo(targetNode.Y).Within(0.5d),
|
||||
"The Process Batch branch endpoint should land on the top boundary.");
|
||||
Assert.That(
|
||||
path[^2].X,
|
||||
Is.EqualTo(path[^1].X).Within(0.5d),
|
||||
"The final Process Batch branch segment should be a direct vertical drop into the header band.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldShareOneAboveGraphTerminalHighwayIntoEnd()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
var graphMinY = layout.Nodes.Min(node => node.Y);
|
||||
|
||||
static double FindAboveGraphLaneY(WorkflowRenderRoutedEdge edge, double graphMinY)
|
||||
{
|
||||
var path = FlattenPath(edge);
|
||||
var bestLength = double.NegativeInfinity;
|
||||
var bestY = double.NaN;
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||
|| path[i].Y >= graphMinY - 8d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||
if (length <= bestLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestLength = length;
|
||||
bestY = path[i].Y;
|
||||
}
|
||||
|
||||
Assert.That(double.IsNaN(bestY), Is.False, $"Expected an above-graph corridor lane for {edge.Id}.");
|
||||
return bestY;
|
||||
}
|
||||
|
||||
var failureLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/20"), graphMinY);
|
||||
var defaultLaneY = FindAboveGraphLaneY(layout.Edges.Single(edge => edge.Id == "edge/23"), graphMinY);
|
||||
|
||||
Assert.That(
|
||||
Math.Abs(failureLaneY - defaultLaneY),
|
||||
Is.LessThanOrEqualTo(1d),
|
||||
"Long terminal sweeps into End should converge onto one shared terminal highway instead of splitting into color-only roof lanes.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepEmailDispatchTerminalBundleDistinctAtEnd()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var endNode = layout.Nodes.Single(node => node.Id == "end");
|
||||
var terminalEdges = new[]
|
||||
{
|
||||
layout.Edges.Single(edge => edge.Id == "edge/30"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/32"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/33"),
|
||||
};
|
||||
var terminalEndpointYs = terminalEdges
|
||||
.Select(edge =>
|
||||
{
|
||||
var path = FlattenPath(edge);
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], endNode),
|
||||
Is.EqualTo("left"),
|
||||
$"Terminal end arrivals should converge on a coherent left-face bundle for {edge.Id}.");
|
||||
return path[^1].Y;
|
||||
})
|
||||
.OrderBy(value => value)
|
||||
.ToArray();
|
||||
var terminalGap = terminalEndpointYs
|
||||
.Zip(terminalEndpointYs.Skip(1), (upper, lower) => lower - upper)
|
||||
.DefaultIfEmpty(double.MaxValue)
|
||||
.Min();
|
||||
|
||||
Assert.That(
|
||||
terminalGap,
|
||||
Is.GreaterThanOrEqualTo(24d),
|
||||
"The email-dispatch terminal bundle should keep distinct end-face slots.");
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var terminalIds = new HashSet<string>(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal);
|
||||
var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId))
|
||||
.ToArray();
|
||||
|
||||
Assert.That(
|
||||
sharedTerminalConflicts,
|
||||
Is.Empty,
|
||||
"Email dispatch terminal edges must not collapse into a shared lane near End.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldPreferTheWorkBranchOnTheForkPrimaryAxis()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var splitNode = layout.Nodes.Single(node => node.Id == "start/2/split");
|
||||
var workBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/3"));
|
||||
var bypassBranch = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/4"));
|
||||
var splitCenterY = splitNode.Y + (splitNode.Height / 2d);
|
||||
var workOffset = Math.Abs(workBranch[0].Y - splitCenterY);
|
||||
var bypassOffset = Math.Abs(bypassBranch[0].Y - splitCenterY);
|
||||
|
||||
Assert.That(
|
||||
workOffset,
|
||||
Is.LessThan(bypassOffset),
|
||||
"The direct Parallel Execution -> Join bypass must not own the fork primary axis over the work branch into Process Batch.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotCountCleanForkBypassAsGatewaySourceViolation()
|
||||
{
|
||||
var elkNodes = new[]
|
||||
{
|
||||
new ElkPositionedNode
|
||||
{
|
||||
Id = "start/2/split",
|
||||
Label = "Parallel Execution",
|
||||
Kind = "Fork",
|
||||
X = 652d,
|
||||
Y = 127.1552734375d,
|
||||
Width = 176d,
|
||||
Height = 124d,
|
||||
},
|
||||
new ElkPositionedNode
|
||||
{
|
||||
Id = "start/2/branch-1/1",
|
||||
Label = "Process Batch",
|
||||
Kind = "Repeat",
|
||||
X = 992d,
|
||||
Y = 268.310546875d,
|
||||
Width = 208d,
|
||||
Height = 88d,
|
||||
},
|
||||
new ElkPositionedNode
|
||||
{
|
||||
Id = "start/2/join",
|
||||
Label = "Parallel Execution Join",
|
||||
Kind = "Join",
|
||||
X = 1290d,
|
||||
Y = 188.73291015625d,
|
||||
Width = 176d,
|
||||
Height = 124d,
|
||||
},
|
||||
};
|
||||
var elkEdges = new[]
|
||||
{
|
||||
new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/3",
|
||||
SourceNodeId = "start/2/split",
|
||||
TargetNodeId = "start/2/branch-1/1",
|
||||
Label = "branch 1",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 828d, Y = 189.1552734375d },
|
||||
EndPoint = new ElkPoint { X = 1016d, Y = 268.310546875d },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 1016d, Y = 189.1552734375d },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/4",
|
||||
SourceNodeId = "start/2/split",
|
||||
TargetNodeId = "start/2/join",
|
||||
Label = "branch 2",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 740d, Y = 135.1552734375d },
|
||||
EndPoint = new ElkPoint { X = 1378d, Y = 196.73291015625d },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 740d, Y = 55.1552734375d },
|
||||
new ElkPoint { X = 1378d, Y = 55.1552734375d },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
|
||||
elkEdges,
|
||||
elkNodes,
|
||||
severityByEdgeId,
|
||||
1);
|
||||
|
||||
Assert.That(gatewaySourceCount, Is.EqualTo(0));
|
||||
Assert.That(severityByEdgeId.Keys, Does.Not.Contain("edge/4"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepRetryDefaultWithinTheLocalSetterBand()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var retryDefault = FlattenPath(layout.Edges.Single(edge => edge.Id == "edge/9"));
|
||||
var cooldownTimer = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/1/true/1");
|
||||
var batchFailed = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/4/failure/2");
|
||||
var maxAllowedY = Math.Max(
|
||||
cooldownTimer.Y + cooldownTimer.Height,
|
||||
batchFailed.Y + batchFailed.Height) + 24d;
|
||||
|
||||
Assert.That(
|
||||
retryDefault.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Retry Decision default should stay in the local setter family instead of dropping into a lower detour band under Cooldown Timer.");
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var brokenSetterHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes)
|
||||
.Where(diagnostic => string.Equals(
|
||||
diagnostic.TargetNodeId,
|
||||
"start/2/branch-1/1/body/4/failure/2",
|
||||
StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
Assert.That(
|
||||
brokenSetterHighways,
|
||||
Is.Empty,
|
||||
"Retry Decision default and Cooldown Timer continuation should not collapse into a broken short-highway at Set batchGenerateFailed.");
|
||||
|
||||
var retryDefaultEdge = elkEdges.Single(edge => edge.Id == "edge/9");
|
||||
var cooldownEdge = elkEdges.Single(edge => edge.Id == "edge/8");
|
||||
Assert.That(
|
||||
ElkEdgeRoutingScoring.CountUnderNodeViolations([retryDefaultEdge], elkNodes),
|
||||
Is.EqualTo(0),
|
||||
"Retry Decision default should stay clear of Cooldown Timer instead of tucking underneath it.");
|
||||
Assert.That(
|
||||
ElkEdgeRoutingScoring.DetectSharedLaneConflicts([cooldownEdge, retryDefaultEdge], elkNodes),
|
||||
Is.Empty,
|
||||
"Retry Decision default should keep a distinct departure lane instead of sharing Cooldown Timer's rail.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepAllTerminalArrivalsOnTheEndLeftFaceFamily()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var endNode = layout.Nodes.Single(node => node.Id == "end");
|
||||
var terminalEdges = new[]
|
||||
{
|
||||
layout.Edges.Single(edge => edge.Id == "edge/20"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/23"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/30"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/32"),
|
||||
layout.Edges.Single(edge => edge.Id == "edge/33"),
|
||||
};
|
||||
|
||||
foreach (var edge in terminalEdges)
|
||||
{
|
||||
var path = FlattenPath(edge);
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], endNode),
|
||||
Is.EqualTo("left"),
|
||||
$"Terminal arrival {edge.Id} should join the same left-face End family as the shorter email-dispatch arrivals.");
|
||||
Assert.That(
|
||||
ResolveTargetApproachJoinSide(path, endNode),
|
||||
Is.EqualTo("left"),
|
||||
$"Terminal arrival {edge.Id} should approach End from the left-side family instead of curling in from the top/right.");
|
||||
}
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var terminalIds = new HashSet<string>(terminalEdges.Select(edge => edge.Id), StringComparer.Ordinal);
|
||||
var sharedTerminalConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Where(conflict => terminalIds.Contains(conflict.LeftEdgeId) && terminalIds.Contains(conflict.RightEdgeId))
|
||||
.ToArray();
|
||||
var brokenTerminalHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes)
|
||||
.Where(diagnostic => string.Equals(diagnostic.TargetNodeId, "end", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
var terminalJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes)
|
||||
.Where(offender => terminalIds.Any(edgeId => offender.Contains(edgeId, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
|
||||
Assert.That(
|
||||
sharedTerminalConflicts,
|
||||
Is.Empty,
|
||||
"All End arrivals should resolve into one coherent terminal family instead of a split corridor-versus-side-face strategy.");
|
||||
var topTerminalHighwayEdges = elkEdges
|
||||
.Where(edge => edge.Id is "edge/20" or "edge/23")
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
ElkEdgeRoutingScoring.CountUnderNodeViolations(topTerminalHighwayEdges, elkNodes),
|
||||
Is.EqualTo(0),
|
||||
"The top-family End arrivals should stay on the above-graph terminal highway instead of dropping into an under-node horizontal before End.");
|
||||
Assert.That(
|
||||
terminalJoinOffenders,
|
||||
Is.Empty,
|
||||
"All End arrivals should keep distinct left-face feeder bands instead of collapsing into target-side joins near End.");
|
||||
Assert.That(
|
||||
brokenTerminalHighways,
|
||||
Is.Empty,
|
||||
"All End arrivals should share a coherent terminal highway instead of fragmenting into short, broken target-side bundles.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepProcessBatchExitOrthogonalIntoParallelJoin()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
NodeSpacing = 50,
|
||||
});
|
||||
|
||||
var processBatchExit = layout.Edges.Single(edge => edge.Id == "edge/17");
|
||||
var boundaryAngleOffenders = GetBoundaryAngleViolations(processBatchExit, layout.Nodes).ToArray();
|
||||
|
||||
Assert.That(
|
||||
boundaryAngleOffenders,
|
||||
Is.Empty,
|
||||
"Process Batch -> Parallel Execution Join must keep an orthogonal gateway approach instead of collapsing into a diagonal join entry.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1294,4 +1294,313 @@ public partial class ElkSharpEdgeRefinementTests
|
||||
repairedLowerPath.Should().NotContain(point => Math.Abs(point.X - 4840d) <= 0.5d && point.Y < 697.3528d);
|
||||
repairedLowerPath.Should().Contain(point => point.X < 4840d && point.Y < 697.3528d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Property("Intent", "Operational")]
|
||||
public void TopCorridorOwnership_WhenRepeatAndEndFamiliesOverlap_ShouldKeepRepeatClosestAndShareEndRoofLane()
|
||||
{
|
||||
var repeatTarget = new ElkPositionedNode
|
||||
{
|
||||
Id = "repeat",
|
||||
Label = "Process Batch",
|
||||
Kind = "Repeat",
|
||||
X = 992,
|
||||
Y = 247.181640625,
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
};
|
||||
|
||||
var repeatSource = new ElkPositionedNode
|
||||
{
|
||||
Id = "check",
|
||||
Label = "Check Result",
|
||||
Kind = "Decision",
|
||||
X = 4240,
|
||||
Y = 297.4360656738281,
|
||||
Width = 188,
|
||||
Height = 132,
|
||||
};
|
||||
|
||||
var sourceFailure = new ElkPositionedNode
|
||||
{
|
||||
Id = "start/3",
|
||||
Label = "Load Configuration",
|
||||
Kind = "TransportCall",
|
||||
X = 3200,
|
||||
Y = 120,
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
};
|
||||
|
||||
var sourceDefault = new ElkPositionedNode
|
||||
{
|
||||
Id = "start/9/true/1/true/1/handled/1",
|
||||
Label = "Set internalNotificationFailed",
|
||||
Kind = "SetState",
|
||||
X = 3560,
|
||||
Y = 356,
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
};
|
||||
|
||||
var end = new ElkPositionedNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
X = 5000,
|
||||
Y = 404,
|
||||
Width = 264,
|
||||
Height = 132,
|
||||
};
|
||||
|
||||
var repeatReturn = new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/14",
|
||||
SourceNodeId = repeatSource.Id,
|
||||
TargetNodeId = repeatTarget.Id,
|
||||
Label = "repeat while state.printInsisAttempt eq 0",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 4412.082354488216, Y = 358.4633486927404 },
|
||||
EndPoint = new ElkPoint { X = 1096, Y = 247.181640625 },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 4398, Y = 358.4633486927404 },
|
||||
new ElkPoint { X = 4398, Y = 80 },
|
||||
new ElkPoint { X = 1096, Y = 80 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var failureArrival = new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/20",
|
||||
SourceNodeId = sourceFailure.Id,
|
||||
TargetNodeId = end.Id,
|
||||
Label = "on failure / timeout",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 3408, Y = 164 },
|
||||
EndPoint = new ElkPoint { X = 5000, Y = 438 },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 3408, Y = 64 },
|
||||
new ElkPoint { X = 4620, Y = 64 },
|
||||
new ElkPoint { X = 4620, Y = 438 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var defaultArrival = new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/23",
|
||||
SourceNodeId = sourceDefault.Id,
|
||||
TargetNodeId = end.Id,
|
||||
Label = "default",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 3768, Y = 400 },
|
||||
EndPoint = new ElkPoint { X = 5000, Y = 472 },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 3768, Y = 100 },
|
||||
new ElkPoint { X = 4548, Y = 100 },
|
||||
new ElkPoint { X = 4548, Y = 472 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY)
|
||||
{
|
||||
var path = ExtractPath(edge);
|
||||
var bestLength = double.NegativeInfinity;
|
||||
var bestY = double.NaN;
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||
|| path[i].Y >= graphMinY - 8d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||
if (length <= bestLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestLength = length;
|
||||
bestY = path[i].Y;
|
||||
}
|
||||
|
||||
double.IsNaN(bestY).Should().BeFalse();
|
||||
return bestY;
|
||||
}
|
||||
|
||||
var nodes = new[] { repeatTarget, repeatSource, sourceFailure, sourceDefault, end };
|
||||
var edges = new[] { repeatReturn, failureArrival, defaultArrival };
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
FindAboveGraphLaneY(repeatReturn, graphMinY).Should().BeGreaterThan(FindAboveGraphLaneY(failureArrival, graphMinY));
|
||||
Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY))
|
||||
.Should()
|
||||
.BeGreaterThan(1d);
|
||||
|
||||
var repaired = ElkTopCorridorOwnership.SpreadAboveGraphCorridorLanes(edges, nodes, 53d);
|
||||
var repairedRepeat = repaired.Single(edge => edge.Id == "edge/14");
|
||||
var repairedFailure = repaired.Single(edge => edge.Id == "edge/20");
|
||||
var repairedDefault = repaired.Single(edge => edge.Id == "edge/23");
|
||||
|
||||
var repairedRepeatY = FindAboveGraphLaneY(repairedRepeat, graphMinY);
|
||||
var repairedFailureY = FindAboveGraphLaneY(repairedFailure, graphMinY);
|
||||
var repairedDefaultY = FindAboveGraphLaneY(repairedDefault, graphMinY);
|
||||
|
||||
repairedRepeatY.Should().BeGreaterThan(repairedFailureY);
|
||||
repairedRepeatY.Should().BeGreaterThan(repairedDefaultY);
|
||||
Math.Abs(repairedFailureY - repairedDefaultY).Should().BeLessThanOrEqualTo(1d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Property("Intent", "Operational")]
|
||||
public void EndTerminalFamilyHelpers_WhenTopFamilyIsSplitAcrossRoofLanes_ShouldShareOneAboveGraphHighway()
|
||||
{
|
||||
var sourceFailure = new ElkPositionedNode
|
||||
{
|
||||
Id = "start/3",
|
||||
Label = "Load Configuration",
|
||||
Kind = "TransportCall",
|
||||
X = 3200,
|
||||
Y = 120,
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
};
|
||||
|
||||
var sourceDefault = new ElkPositionedNode
|
||||
{
|
||||
Id = "start/9/true/1/true/1/handled/1",
|
||||
Label = "Set internalNotificationFailed",
|
||||
Kind = "SetState",
|
||||
X = 3560,
|
||||
Y = 356,
|
||||
Width = 208,
|
||||
Height = 88,
|
||||
};
|
||||
|
||||
var target = new ElkPositionedNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
X = 5000,
|
||||
Y = 404,
|
||||
Width = 264,
|
||||
Height = 132,
|
||||
};
|
||||
|
||||
var failureArrival = new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/20",
|
||||
SourceNodeId = sourceFailure.Id,
|
||||
TargetNodeId = target.Id,
|
||||
Label = "on failure / timeout",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 3408, Y = 164 },
|
||||
EndPoint = new ElkPoint { X = 5000, Y = 438 },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 3408, Y = 64 },
|
||||
new ElkPoint { X = 4620, Y = 64 },
|
||||
new ElkPoint { X = 4620, Y = 438 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var defaultArrival = new ElkRoutedEdge
|
||||
{
|
||||
Id = "edge/23",
|
||||
SourceNodeId = sourceDefault.Id,
|
||||
TargetNodeId = target.Id,
|
||||
Label = "default",
|
||||
Sections =
|
||||
[
|
||||
new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = 3768, Y = 400 },
|
||||
EndPoint = new ElkPoint { X = 5000, Y = 472 },
|
||||
BendPoints =
|
||||
[
|
||||
new ElkPoint { X = 3768, Y = 100 },
|
||||
new ElkPoint { X = 4548, Y = 100 },
|
||||
new ElkPoint { X = 4548, Y = 472 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
static double FindAboveGraphLaneY(ElkRoutedEdge edge, double graphMinY)
|
||||
{
|
||||
var path = ExtractPath(edge);
|
||||
var bestLength = double.NegativeInfinity;
|
||||
var bestY = double.NaN;
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
if (Math.Abs(path[i].Y - path[i + 1].Y) > 0.5d
|
||||
|| path[i].Y >= graphMinY - 8d)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = Math.Abs(path[i + 1].X - path[i].X);
|
||||
if (length <= bestLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestLength = length;
|
||||
bestY = path[i].Y;
|
||||
}
|
||||
|
||||
double.IsNaN(bestY).Should().BeFalse();
|
||||
return bestY;
|
||||
}
|
||||
|
||||
var nodes = new[] { sourceFailure, sourceDefault, target };
|
||||
var edges = new[] { failureArrival, defaultArrival };
|
||||
var graphMinY = nodes.Min(node => node.Y);
|
||||
Math.Abs(FindAboveGraphLaneY(failureArrival, graphMinY) - FindAboveGraphLaneY(defaultArrival, graphMinY))
|
||||
.Should()
|
||||
.BeGreaterThan(1d);
|
||||
|
||||
var repaired = ElkEdgePostProcessor.DistributeEndTerminalLeftFaceTrunks(edges, nodes, 53d);
|
||||
|
||||
var repairedFailure = repaired.Single(edge => edge.Id == "edge/20");
|
||||
var repairedDefault = repaired.Single(edge => edge.Id == "edge/23");
|
||||
var repairedFailurePath = ExtractPath(repairedFailure);
|
||||
var repairedDefaultPath = ExtractPath(repairedDefault);
|
||||
|
||||
Math.Abs(FindAboveGraphLaneY(repairedFailure, graphMinY) - FindAboveGraphLaneY(repairedDefault, graphMinY))
|
||||
.Should()
|
||||
.BeLessThanOrEqualTo(1d);
|
||||
ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedFailurePath[^1], target).Should().Be("left");
|
||||
ElkEdgeRoutingGeometry.ResolveBoundarySide(repairedDefaultPath[^1], target).Should().Be("left");
|
||||
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedFailurePath[^1], repairedFailurePath[^2], target).Should().Be("left");
|
||||
ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(repairedDefaultPath[^1], repairedDefaultPath[^2], target).Should().Be("left");
|
||||
ElkEdgeRouterHighway.DetectRemainingBrokenHighways(repaired, nodes)
|
||||
.Any(diagnostic => string.Equals(diagnostic.TargetNodeId, target.Id, StringComparison.Ordinal) && diagnostic.WasBroken)
|
||||
.Should()
|
||||
.BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
@@ -9,6 +12,124 @@ namespace StellaOps.Workflow.Renderer.Tests;
|
||||
[TestFixture]
|
||||
public class WorkflowRenderSvgRendererTests
|
||||
{
|
||||
[Test]
|
||||
public void Render_WhenLegendContentIsSparse_ShouldSizeLegendToContent()
|
||||
{
|
||||
var renderer = new WorkflowRenderSvgRenderer();
|
||||
var layout = new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = "legend-sparse",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "start",
|
||||
Label = "Start",
|
||||
Kind = "Start",
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = 128,
|
||||
Height = 64,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
X = 220,
|
||||
Y = 0,
|
||||
Width = 128,
|
||||
Height = 64,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "start",
|
||||
TargetNodeId = "end",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 128, Y = 32 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 220, Y = 32 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var document = renderer.Render(layout, "LegendCompact");
|
||||
var match = Regex.Match(
|
||||
document.Svg,
|
||||
"<rect x=\"24\" y=\"34\" rx=\"14\" ry=\"14\" width=\"[^\"]+\" height=\"([^\"]+)\"");
|
||||
|
||||
match.Success.Should().BeTrue();
|
||||
var legendHeight = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
legendHeight.Should().BeLessThan(160d);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_WhenEdgeLabelIsLong_ShouldWrapIntoMultipleBadgeLines()
|
||||
{
|
||||
var renderer = new WorkflowRenderSvgRenderer();
|
||||
var layout = new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = "label-wrap",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "left",
|
||||
Label = "Left",
|
||||
Kind = "TransportCall",
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = 196,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderPositionedNode
|
||||
{
|
||||
Id = "right",
|
||||
Label = "Right",
|
||||
Kind = "Decision",
|
||||
X = 320,
|
||||
Y = 0,
|
||||
Width = 144,
|
||||
Height = 96,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = "e1",
|
||||
SourceNodeId = "left",
|
||||
TargetNodeId = "right",
|
||||
Label = "when payload.amount exceeds approval threshold",
|
||||
Sections =
|
||||
[
|
||||
new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = 196, Y = 42 },
|
||||
EndPoint = new WorkflowRenderPoint { X = 320, Y = 48 },
|
||||
BendPoints = [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var document = renderer.Render(layout, "EdgeLabelWrap");
|
||||
|
||||
document.Svg.Should().Contain(">when payload.amount exceeds<");
|
||||
document.Svg.Should().Contain(">approval threshold<");
|
||||
document.Svg.Should().NotContain(">when payload.amount exceeds approval threshold<");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Render_WhenTaskGatewayAndConditionsExist_ShouldEmitBoxesDiamondsLegendAndStyledBranches()
|
||||
{
|
||||
@@ -154,7 +275,8 @@ public class WorkflowRenderSvgRendererTests
|
||||
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(">when payload.answer ==<");
|
||||
document.Svg.Should().Contain(">"approve"<");
|
||||
document.Svg.Should().Contain("stroke=\"#15803d\"");
|
||||
document.Svg.Should().Contain("Call Pricing");
|
||||
document.Svg.Should().Contain(">Wait For Timeout<");
|
||||
@@ -255,7 +377,30 @@ public class WorkflowRenderSvgRendererTests
|
||||
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");
|
||||
Regex.IsMatch(document.Svg, "M 21[0-9](?:\\.\\d+)?,318 L 22[0-9](?:\\.\\d+)?,318")
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildRoundedEdgePath_WhenShortHorizontalJogPrecedesAShallowContinuation_ShouldPreserveTheHorizontalSpan()
|
||||
{
|
||||
var buildRoundedEdgePath = typeof(WorkflowRenderSvgRenderer)
|
||||
.GetMethod("BuildRoundedEdgePath", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
buildRoundedEdgePath.Should().NotBeNull();
|
||||
|
||||
WorkflowRenderPoint[] points =
|
||||
[
|
||||
new WorkflowRenderPoint { X = 0, Y = 0 },
|
||||
new WorkflowRenderPoint { X = 24, Y = 0 },
|
||||
new WorkflowRenderPoint { X = 164, Y = 2.8d },
|
||||
];
|
||||
|
||||
var pathData = (string?)buildRoundedEdgePath!.Invoke(null, [points, 0d, 0d, 40d]);
|
||||
|
||||
pathData.Should().NotBeNullOrWhiteSpace();
|
||||
pathData.Should().Contain("164,");
|
||||
pathData.Should().NotContain("M 0,0 L 0,");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user