elksharp: stabilize document-processing terminal routing

This commit is contained in:
master
2026-04-05 15:02:12 +03:00
parent 3a0cfcbc89
commit 1151c30e3a
11 changed files with 2946 additions and 71 deletions

View File

@@ -11,8 +11,11 @@ public sealed class WorkflowRenderSvgRenderer
private const double Margin = 32;
private const double HeaderHeight = 220;
private const double LegendTop = 34;
private const double LegendHeight = 160;
private const double LabelMinTop = LegendTop + LegendHeight + 18;
private const double LegendLeft = 24;
private const double LegendInnerLeft = 48;
private const double LegendInnerRight = 24;
private const double LegendBottomPadding = 16;
private const double LegendSectionGap = 18;
private const double LabelInsetX = 56;
private const double LabelInsetRight = 16;
@@ -25,9 +28,11 @@ public sealed class WorkflowRenderSvgRenderer
var bounds = CalculateBounds(layout);
var width = Math.Max(1328, bounds.Width + (Margin * 2));
var height = Math.Max(320, bounds.Height + HeaderHeight + Margin + 96);
var legendLayout = BuildLegendLayout(width, layout);
var headerHeight = Math.Max(HeaderHeight, legendLayout.Bottom + 28d);
var height = Math.Max(320, bounds.Height + headerHeight + Margin + 96);
var offsetX = Margin - bounds.MinX;
var offsetY = HeaderHeight - bounds.MinY;
var offsetY = headerHeight - bounds.MinY;
var builder = new StringBuilder();
var edgeLabels = new List<WorkflowRenderEdgeLabelPlacement>();
@@ -80,7 +85,7 @@ public sealed class WorkflowRenderSvgRenderer
<rect x="0" y="0" width="{Format(width)}" height="{Format(height)}" fill="#f4f7fb" />
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">{Encode(title)}</text>
""");
RenderLegend(builder, width, layout);
RenderLegend(builder, legendLayout);
var highways = DetectHighwayGroups(layout);
var highwayByEdgeId = highways.Values
@@ -228,6 +233,7 @@ public sealed class WorkflowRenderSvgRenderer
offsetY,
width,
height,
legendLayout.Bottom + 18d,
labelObstacles,
edgeLabels));
}
@@ -510,29 +516,38 @@ public sealed class WorkflowRenderSvgRenderer
stroke-dasharray="2.2 4.8" />
<rect x="{Format(placement.Left)}" y="{Format(placement.Top)}" rx="9" ry="9" width="{Format(placement.Width)}" height="{Format(placement.Height)}"
fill="{placement.Style.LabelFill}" fill-opacity="0.54" stroke="{placement.Style.Stroke}" stroke-opacity="0.5" stroke-width="0.9" />
<text x="{Format(placement.CenterX)}" y="{Format(placement.Top + 14)}"
""");
var fontSize = placement.Lines.Count > 1 ? 10.8d : 11.5d;
var lineHeight = placement.Lines.Count > 1 ? 12d : 0d;
var firstBaseline = placement.Lines.Count > 1
? placement.Top + ((placement.Height - ((placement.Lines.Count - 1) * lineHeight)) / 2d) + 4d
: placement.Top + 14d;
for (var index = 0; index < placement.Lines.Count; index++)
{
builder.AppendLine($"""
<text x="{Format(placement.CenterX)}" y="{Format(firstBaseline + (index * lineHeight))}"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="11.5"
font-size="{Format(fontSize)}"
font-weight="700"
fill="{placement.Style.LabelText}">{Encode(placement.Label)}</text>
fill="{placement.Style.LabelText}">{Encode(placement.Lines[index])}</text>
""");
}
}
private static void RenderLegend(StringBuilder builder, double canvasWidth, WorkflowRenderLayoutResult layout)
private static LegendLayout BuildLegendLayout(double canvasWidth, WorkflowRenderLayoutResult layout)
{
var nodeKinds = new HashSet<string>(layout.Nodes.Select(n => n.Kind), StringComparer.OrdinalIgnoreCase);
var edgeLabels = new HashSet<string>(
layout.Edges.Where(e => !string.IsNullOrWhiteSpace(e.Label)).Select(e => e.Label!),
StringComparer.OrdinalIgnoreCase);
var legendWidth = Math.Min(canvasWidth - 48d, 1260d);
builder.AppendLine($"""
<g>
<rect x="24" y="{Format(LegendTop)}" rx="14" ry="14" width="{Format(legendWidth)}" height="{Format(LegendHeight)}" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
<text x="40" y="78" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
</g>
var legendWidth = Math.Min(canvasWidth - 48d, 1040d);
var maxRight = LegendLeft + legendWidth - LegendInnerRight;
var contentBuilder = new StringBuilder();
contentBuilder.AppendLine("""
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
""");
var nodeChips = new List<(string Kind, string Label)>();
@@ -547,11 +562,18 @@ public sealed class WorkflowRenderSvgRenderer
if (nodeKinds.Contains("Repeat")) nodeChips.Add(("Repeat", "Repeat / Loop"));
if (nodeKinds.Contains("Signal")) nodeChips.Add(("Signal", "Signal"));
var nodeChipX = 118d;
foreach (var (kind, label) in nodeChips)
var cursorY = 78d;
var maxBottom = 56d;
if (nodeChips.Count > 0)
{
RenderLegendNodeChip(builder, nodeChipX, 62, kind, label);
nodeChipX += (label.Length * 7.5d) + 56d;
contentBuilder.AppendLine($"""
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
""");
maxBottom = Math.Max(maxBottom, cursorY);
var nodeBottom = RenderWrappedLegendNodeChips(contentBuilder, nodeChips, cursorY + 10d, maxRight);
maxBottom = Math.Max(maxBottom, nodeBottom);
cursorY = nodeBottom + LegendSectionGap;
}
var badgeChips = new List<(string Kind, string Label)>();
@@ -565,15 +587,13 @@ public sealed class WorkflowRenderSvgRenderer
if (badgeChips.Count > 0)
{
builder.AppendLine("""
<text x="40" y="132" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
contentBuilder.AppendLine($"""
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
""");
var badgeChipX = 102d;
foreach (var (kind, label) in badgeChips)
{
RenderLegendBadgeChip(builder, badgeChipX, 128, kind, label);
badgeChipX += (label.Length * 7d) + 50d;
}
maxBottom = Math.Max(maxBottom, cursorY);
var badgeBottom = RenderWrappedLegendBadgeChips(contentBuilder, badgeChips, cursorY + 8d, maxRight);
maxBottom = Math.Max(maxBottom, badgeBottom);
cursorY = badgeBottom + LegendSectionGap;
}
var hasWhenCondition = edgeLabels.Any(l => l.StartsWith("when ", StringComparison.OrdinalIgnoreCase));
@@ -595,16 +615,116 @@ public sealed class WorkflowRenderSvgRenderer
if (branchChips.Count > 0)
{
builder.AppendLine("""
<text x="40" y="164" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Branch Callouts:</text>
contentBuilder.AppendLine($"""
<text x="40" y="{Format(cursorY)}" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Branch Callouts:</text>
""");
var branchChipX = 152d;
foreach (var (color, label) in branchChips)
{
RenderLegendBranchChip(builder, branchChipX, 160, color, label);
branchChipX += (label.Length * 7d) + 46d;
}
maxBottom = Math.Max(maxBottom, cursorY);
var branchBottom = RenderWrappedLegendBranchChips(contentBuilder, branchChips, cursorY + 8d, maxRight);
maxBottom = Math.Max(maxBottom, branchBottom);
}
var legendHeight = Math.Max(74d, (maxBottom - LegendTop) + LegendBottomPadding);
var legendBuilder = new StringBuilder();
legendBuilder.AppendLine($"""
<g>
<rect x="{Format(LegendLeft)}" y="{Format(LegendTop)}" rx="14" ry="14" width="{Format(legendWidth)}" height="{Format(legendHeight)}" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
""");
legendBuilder.Append(contentBuilder.ToString());
legendBuilder.AppendLine(" </g>");
return new LegendLayout(legendWidth, legendHeight, legendBuilder.ToString());
}
private static void RenderLegend(StringBuilder builder, LegendLayout legendLayout)
{
builder.Append(legendLayout.Svg);
}
private static double RenderWrappedLegendNodeChips(
StringBuilder builder,
IReadOnlyList<(string Kind, string Label)> chips,
double startY,
double maxRight)
{
var x = LegendInnerLeft;
var y = startY;
foreach (var (kind, label) in chips)
{
var width = MeasureLegendNodeChipWidth(label);
if (x + width > maxRight && x > LegendInnerLeft)
{
x = LegendInnerLeft;
y += 32d;
}
RenderLegendNodeChip(builder, x, y, kind, label);
x += width + 16d;
}
return y + 24d;
}
private static double RenderWrappedLegendBadgeChips(
StringBuilder builder,
IReadOnlyList<(string Kind, string Label)> chips,
double startY,
double maxRight)
{
var x = LegendInnerLeft;
var y = startY;
foreach (var (kind, label) in chips)
{
var width = MeasureLegendBadgeChipWidth(label);
if (x + width > maxRight && x > LegendInnerLeft)
{
x = LegendInnerLeft;
y += 28d;
}
RenderLegendBadgeChip(builder, x + 10.5d, y + 10.5d, kind, label);
x += width + 16d;
}
return y + 22d;
}
private static double RenderWrappedLegendBranchChips(
StringBuilder builder,
IReadOnlyList<(string Color, string Label)> chips,
double startY,
double maxRight)
{
var x = LegendInnerLeft;
var y = startY;
foreach (var (color, label) in chips)
{
var width = MeasureLegendBranchChipWidth(label);
if (x + width > maxRight && x > LegendInnerLeft)
{
x = LegendInnerLeft;
y += 24d;
}
RenderLegendBranchChip(builder, x, y + 10d, color, label);
x += width + 14d;
}
return y + 18d;
}
private static double MeasureLegendNodeChipWidth(string label)
{
return 56d + (label.Length * 7.2d);
}
private static double MeasureLegendBadgeChipWidth(string label)
{
return 34d + (label.Length * 6.8d);
}
private static double MeasureLegendBranchChipWidth(string label)
{
return 54d + (label.Length * 6.8d);
}
private static (double MinX, double MinY, double Width, double Height) CalculateBounds(WorkflowRenderLayoutResult layout)
@@ -860,6 +980,24 @@ public sealed class WorkflowRenderSvgRenderer
return $"{value[..(maxCharsPerLine - 3)]}...";
}
private static string[] WrapEdgeLabelLines(string label)
{
var normalized = label.Replace('\r', ' ').Replace('\n', ' ');
var wrapped = WrapSingleLine(normalized, 28)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (wrapped.Count <= 2)
{
return wrapped.ToArray();
}
return
[
wrapped[0],
TruncateSingleLine(string.Join(" ", wrapped.Skip(1)), 28),
];
}
private static string Encode(string value)
{
return WebUtility.HtmlEncode(value);
@@ -878,12 +1016,14 @@ public sealed class WorkflowRenderSvgRenderer
double offsetY,
double canvasWidth,
double canvasHeight,
double labelMinTop,
IReadOnlyCollection<WorkflowRenderRect> nodeObstacles,
IReadOnlyCollection<WorkflowRenderEdgeLabelPlacement> placedLabels)
{
var renderedLabel = TruncateSingleLine(label, 50);
var width = Math.Min(368d, Math.Max(92d, (renderedLabel.Length * 6.35d) + 10d));
var height = 22d;
var renderedLines = WrapEdgeLabelLines(label);
var longestLineLength = renderedLines.Max(static line => line.Length);
var width = Math.Min(252d, Math.Max(108d, (longestLineLength * 6.35d) + 18d));
var height = renderedLines.Length == 1 ? 22d : 36d;
var isErrorLabel = label.Contains("failure", StringComparison.OrdinalIgnoreCase)
|| label.Contains("timeout", StringComparison.OrdinalIgnoreCase);
var segment = ResolveLabelAnchorSegment(points);
@@ -912,10 +1052,10 @@ public sealed class WorkflowRenderSvgRenderer
var bestOverlapArea = double.MaxValue;
var bestDistance = double.MaxValue;
foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight))
foreach (var candidate in EnumerateEdgeLabelCandidates(anchorX, anchorY, width, height, horizontal, canvasWidth, canvasHeight, labelMinTop))
{
var placement = new WorkflowRenderEdgeLabelPlacement(
renderedLabel,
renderedLines,
edgeStyle,
anchorX,
anchorY,
@@ -946,12 +1086,12 @@ public sealed class WorkflowRenderSvgRenderer
return bestPlacement
?? new WorkflowRenderEdgeLabelPlacement(
renderedLabel,
renderedLines,
edgeStyle,
anchorX,
anchorY,
Clamp(anchorX - (width / 2d), 24d, Math.Max(24d, canvasWidth - 24d - width)),
Clamp(anchorY - height - 54d, LabelMinTop, Math.Max(LabelMinTop, canvasHeight - 24d - height)),
Clamp(anchorY - height - 54d, labelMinTop, Math.Max(labelMinTop, canvasHeight - 24d - height)),
width,
height);
}
@@ -963,20 +1103,21 @@ public sealed class WorkflowRenderSvgRenderer
double height,
bool horizontal,
double canvasWidth,
double canvasHeight)
double canvasHeight,
double labelMinTop)
{
static double ResolveTopBound(double canvasHeightLocal, double heightLocal)
static double ResolveTopBound(double canvasHeightLocal, double heightLocal, double labelMinTopLocal)
{
return Math.Max(LabelMinTop, canvasHeightLocal - 24d - heightLocal);
return Math.Max(labelMinTopLocal, canvasHeightLocal - 24d - heightLocal);
}
(double Left, double Top) Normalize(double left, double top)
{
var maxLeft = Math.Max(24d, canvasWidth - 24d - width);
var maxTop = ResolveTopBound(canvasHeight, height);
var maxTop = ResolveTopBound(canvasHeight, height, labelMinTop);
return (
Clamp(left, 24d, maxLeft),
Clamp(top, LabelMinTop, maxTop));
Clamp(top, labelMinTop, maxTop));
}
for (var level = 0; level < 5; level++)
@@ -2239,7 +2380,10 @@ public sealed class WorkflowRenderSvgRenderer
if (segLen < 30d && i < mutablePoints.Count - 1)
{
var next = mutablePoints[i + 1];
if (dxIn < dyIn)
var dxOut = Math.Abs(next.X - curr.X);
var dyOut = Math.Abs(next.Y - curr.Y);
var preserveHorizontalContinuation = dxOut >= dyOut;
if (preserveHorizontalContinuation)
{
mutablePoints[i + 1] = new WorkflowRenderPoint { X = next.X, Y = prev.Y };
}
@@ -2321,7 +2465,7 @@ public sealed class WorkflowRenderSvgRenderer
string LabelText);
private sealed record WorkflowRenderEdgeLabelPlacement(
string Label,
IReadOnlyList<string> Lines,
WorkflowRenderEdgeStyle Style,
double AnchorX,
double AnchorY,
@@ -2334,6 +2478,14 @@ public sealed class WorkflowRenderSvgRenderer
public double CenterY => Top + (Height / 2d);
}
private sealed record LegendLayout(
double Width,
double Height,
string Svg)
{
public double Bottom => LegendTop + Height;
}
private sealed record WorkflowRenderRect(
double Left,
double Top,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 == &quot;approve&quot;");
document.Svg.Should().Contain(">when payload.answer ==<");
document.Svg.Should().Contain(">&quot;approve&quot;<");
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,");
}
}