Refactor ElkSharp routing sources into partial modules

This commit is contained in:
master
2026-03-28 11:56:35 +02:00
parent 7be4e855d6
commit 7057819f4d
34 changed files with 33377 additions and 21402 deletions

View File

@@ -0,0 +1,929 @@
using System.Text.Json;
using System.Reflection;
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;
public partial class DocumentProcessingWorkflowRenderingTests
{
[Test]
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
{
var graph = BuildDocumentProcessingWorkflowGraph();
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);
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var distinctLayerXs = visibleNodes
.Select(node => node.X)
.Distinct()
.OrderBy(x => x)
.ToArray();
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
var minInLayerGap = visibleNodes
.GroupBy(node => node.X)
.Select(group =>
{
var ordered = group.OrderBy(node => node.Y).ToArray();
if (ordered.Length < 2)
{
return double.MaxValue;
}
return ordered
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
.Min();
})
.DefaultIfEmpty(double.MaxValue)
.Min();
Assert.That(
minLayerGap,
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
Assert.That(
minInLayerGap,
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[0], sourceNode),
Is.EqualTo("right"),
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
var shortestDirectLength = 0d;
for (var i = 1; i < path.Count; i++)
{
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
}
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
Assert.That(
path.Min(point => point.Y),
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
Assert.That(
shortestDirectLength,
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge =>
routedEdge.SourceNodeId == "start/3"
&& routedEdge.TargetNodeId == "start/4/batched");
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeTopEntryIntoHasRecipients()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge =>
routedEdge.SourceNodeId == "start/9/true/1"
&& routedEdge.TargetNodeId == "start/9/true/2");
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Internal Notification -> Has Recipients must not stay horizontal to the gateway and then drop a tiny vertical stub into the target face.");
Assert.That(path.Count, Is.GreaterThanOrEqualTo(3));
Assert.That(
HasShortGatewayTargetOrthogonalHook(path, targetNode),
Is.False,
"Internal Notification -> Has Recipients must not change into the gateway boundary direction within less than one node depth of the target.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
Assert.That(
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.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepDecisionSourceExitsOnDiscreteBoundarySlots()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
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 severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
Assert.That(
count,
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]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
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 = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
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 }));
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, 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}");
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
// Render every iteration of every strategy as SVG only
var variantsDir = Path.Combine(outputDir, "strategy-variants");
Directory.CreateDirectory(variantsDir);
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
{
foreach (var attemptDiag in stratDiag.AttemptDetails)
{
if (attemptDiag.Edges is null)
{
continue;
}
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
var sc = attemptDiag.Score;
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"sl={sc.SharedLaneViolations} bs={sc.BoundarySlotViolations} " +
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
attemptSvg.Svg);
}
if (stratDiag.BestEdges is not null)
{
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
var bestSc = stratDiag.BestScore;
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"sl={bestSc?.SharedLaneViolations} bs={bestSc?.BoundarySlotViolations} " +
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
bestSvg.Svg);
}
}
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
.SelectMany(strategy => strategy.AttemptDetails)
.Where(attempt => attempt.Attempt > 1
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
.ToArray();
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
Assert.That(
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
Is.True,
"Local repair attempts must reroute only the penalized subset of edges.");
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
var boundaryAngleOffenders = layout.Edges
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
.ToArray();
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).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 sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
.Distinct(StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
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(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.");
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));
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoops = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
.ToArray();
Assert.That(
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
Is.True,
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
{
return new WorkflowRenderLayoutResult
{
GraphId = baseLayout.GraphId,
Nodes = baseLayout.Nodes,
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
{
Id = e.Id,
SourceNodeId = e.SourceNodeId,
TargetNodeId = e.TargetNodeId,
Kind = e.Kind,
Label = e.Label,
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
{
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
}).ToArray(),
}).ToArray(),
};
}
// 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");
}
[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 jsonPath = Path.Combine(outputDir, "elksharp.json");
Assert.That(File.Exists(jsonPath), Is.True);
var layout = JsonSerializer.Deserialize<WorkflowRenderLayoutResult>(
File.ReadAllText(jsonPath),
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
Assert.That(layout, Is.Not.Null);
var elkNodes = layout!.Nodes.Select(node => new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).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 severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot artifact count: {count}");
foreach (var offender in severityByEdgeId.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var serviceNodes = elkNodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var minLineClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
: 50d;
var nodesById = elkNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var graphMinY = elkNodes.Min(node => node.Y);
var graphMaxY = elkNodes.Max(node => node.Y + node.Height);
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
elkEdges,
nodesById,
graphMinY,
graphMaxY,
restrictedEdgeIds: null,
enforceAllNodeEndpoints: true);
if (sourceSlots.TryGetValue("edge/25", out var sourceSlot))
{
TestContext.Out.WriteLine(
$"edge/25 source-slot: side={sourceSlot.Side} boundary=({sourceSlot.Boundary.X:F3},{sourceSlot.Boundary.Y:F3})");
}
if (targetSlots.TryGetValue("edge/25", out var targetSlot))
{
TestContext.Out.WriteLine(
$"edge/25 target-slot: side={targetSlot.Side} boundary=({targetSlot.Boundary.X:F3},{targetSlot.Boundary.Y:F3})");
var edge25 = elkEdges.Single(edge => edge.Id == "edge/25");
var edge25Path = ExtractElkPath(edge25);
var buildTargetApproachCandidatePath = typeof(ElkEdgePostProcessor).GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[edge25Path, elkNodes.Single(node => node.Id == edge25.TargetNodeId), targetSlot.Side, targetSlot.Boundary, edge25Path[2].Y])!;
TestContext.Out.WriteLine(
$"edge/25 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var snapped = ElkEdgePostProcessor.SnapBoundarySlotAssignments(elkEdges, elkNodes, minLineClearance);
var snappedSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var snappedCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(snapped, elkNodes, snappedSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"boundary-slot snapped count: {snappedCount}");
if (snappedSeverityByEdgeId.TryGetValue("edge/25", out _))
{
var snappedEdge = snapped.Single(candidate => candidate.Id == "edge/25");
TestContext.Out.WriteLine(
$"edge/25 snapped: {string.Join(" -> ", ExtractElkPath(snappedEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var detourSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var detourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(elkEdges, elkNodes, detourSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"detour artifact count: {detourCount}");
foreach (var offender in detourSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} detour={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
var shortcutCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes)[0];
if (!ExtractElkPath(shortcutCandidate).SequenceEqual(ExtractElkPath(edge), ElkPointComparer.Instance))
{
var shortcutDetourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutGatewaySeverity = new Dictionary<string, int>(StringComparer.Ordinal);
var shortcutDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(
[shortcutCandidate],
elkNodes,
shortcutDetourSeverity,
1);
var shortcutGatewayCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
[shortcutCandidate],
elkNodes,
shortcutGatewaySeverity,
1);
TestContext.Out.WriteLine(
$" shortcut -> detour={shortcutDetourCount} gateway-source={shortcutGatewayCount}: {string.Join(" -> ", ExtractElkPath(shortcutCandidate).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
else
{
TestContext.Out.WriteLine(" shortcut -> unchanged");
}
}
var gatewaySourceSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(elkEdges, elkNodes, gatewaySourceSeverityByEdgeId, 1);
TestContext.Out.WriteLine($"gateway-source artifact count: {gatewaySourceCount}");
foreach (var offender in gatewaySourceSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
{
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
TestContext.Out.WriteLine(
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Distinct()
.OrderBy(conflict => conflict.LeftEdgeId, StringComparer.Ordinal)
.ThenBy(conflict => conflict.RightEdgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"shared-lane artifact count: {sharedLaneConflicts.Length}");
foreach (var conflict in sharedLaneConflicts)
{
TestContext.Out.WriteLine($"shared-lane artifact pair: {conflict.LeftEdgeId}+{conflict.RightEdgeId}");
}
var layoutNodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var gatewayCornerDiagonalOffenders = layout.Edges
.Where(edge =>
HasGatewayCornerDiagonal(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayCornerDiagonal(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-corner artifact count: {gatewayCornerDiagonalOffenders.Length}");
foreach (var edgeId in gatewayCornerDiagonalOffenders)
{
TestContext.Out.WriteLine($"gateway-corner artifact edge: {edgeId}");
}
var gatewayInteriorAdjacentOffenders = layout.Edges
.Where(edge =>
HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact count: {gatewayInteriorAdjacentOffenders.Length}");
foreach (var edgeId in gatewayInteriorAdjacentOffenders)
{
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact edge: {edgeId}");
}
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-curl artifact count: {gatewaySourceCurlOffenders.Length}");
foreach (var edgeId in gatewaySourceCurlOffenders)
{
TestContext.Out.WriteLine($"gateway-source-curl artifact edge: {edgeId}");
}
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact count: {gatewaySourceFaceMismatchOffenders.Length}");
foreach (var edgeId in gatewaySourceFaceMismatchOffenders)
{
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact edge: {edgeId}");
}
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-detour artifact count: {gatewaySourceDetourOffenders.Length}");
foreach (var edgeId in gatewaySourceDetourOffenders)
{
TestContext.Out.WriteLine($"gateway-source-detour artifact edge: {edgeId}");
}
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layoutNodesById[edge.SourceNodeId]))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact count: {gatewaySourceVertexExitOffenders.Length}");
foreach (var edgeId in gatewaySourceVertexExitOffenders)
{
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact edge: {edgeId}");
}
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"gateway-source-scoring artifact count: {gatewaySourceScoringOffenders.Length}");
foreach (var edgeId in gatewaySourceScoringOffenders)
{
TestContext.Out.WriteLine($"gateway-source-scoring artifact edge: {edgeId}");
}
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoopOffenders = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal)
&& HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d))
.Select(edge => edge.Id)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"process-batch-clearance artifact count: {processBatchLoopOffenders.Length}");
foreach (var edgeId in processBatchLoopOffenders)
{
TestContext.Out.WriteLine($"process-batch-clearance artifact edge: {edgeId}");
}
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 points = new List<WorkflowRenderPoint> { section.StartPoint };
points.AddRange(section.BendPoints);
points.Add(section.EndPoint);
for (var i = 0; i < points.Count - 1; i++)
{
var start = points[i];
var end = points[i + 1];
if (Math.Abs(start.Y - end.Y) < 2d && start.Y > node.Y && start.Y < node.Y + node.Height)
{
if (Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width)
{
crossings++;
}
}
else if (Math.Abs(start.X - end.X) < 2d && start.X > node.X && start.X < node.X + node.Width)
{
if (Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height)
{
crossings++;
}
}
}
}
}
}
TestContext.Out.WriteLine($"edge-node-crossing artifact count: {crossings}");
var focusedSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(
elkEdges,
elkNodes,
minLineClearance,
["edge/15", "edge/17", "edge/35"]);
TestContext.Out.WriteLine(
$"focused shared-lane candidate counts: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedSharedLaneCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedSharedLaneCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedSharedLaneCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedSharedLaneCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedSharedLaneCandidate, elkNodes)}");
foreach (var edgeId in new[] { "edge/15", "edge/17", "edge/35" })
{
var currentEdge = elkEdges.Single(edge => edge.Id == edgeId);
var candidateEdge = focusedSharedLaneCandidate.Single(edge => edge.Id == edgeId);
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} current: {string.Join(" -> ", ExtractElkPath(currentEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"focused shared-lane {edgeId} candidate: {string.Join(" -> ", ExtractElkPath(candidateEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
}
var combinedFocus = detourSeverityByEdgeId.Keys
.Concat(gatewaySourceSeverityByEdgeId.Keys)
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
.ToArray();
if (combinedFocus.Length > 0)
{
var batchCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(batchCandidate, elkNodes, combinedFocus);
batchCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(batchCandidate, elkNodes, minLineClearance, combinedFocus);
batchCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
batchCandidate,
elkNodes,
minLineClearance,
combinedFocus,
enforceAllNodeEndpoints: true);
var batchDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(batchCandidate, elkNodes);
var batchGatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(batchCandidate, elkNodes);
var batchBoundarySlotCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(batchCandidate, elkNodes);
var batchEntryCount = ElkEdgeRoutingScoring.CountBadEntryAngles(batchCandidate, elkNodes);
var batchSharedLaneCount = ElkEdgeRoutingScoring.CountSharedLaneViolations(batchCandidate, elkNodes);
TestContext.Out.WriteLine(
$"batch-candidate counts: detour={batchDetourCount} gateway-source={batchGatewaySourceCount} boundary-slots={batchBoundarySlotCount} entry={batchEntryCount} shared-lanes={batchSharedLaneCount}");
foreach (var focusEdgeId in combinedFocus)
{
var focusedCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(focusedCandidate, elkNodes, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedCandidate,
elkNodes,
minLineClearance,
[focusEdgeId],
enforceAllNodeEndpoints: true);
TestContext.Out.WriteLine(
$"focused {focusEdgeId}: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedCandidate, elkNodes)}");
}
var subsetResults = new List<(string[] Focus, int Detour, int GatewaySource, int BoundarySlots, int Entry, int SharedLanes)>();
for (var mask = 1; mask < (1 << combinedFocus.Length); mask++)
{
var subset = combinedFocus
.Where((_, index) => (mask & (1 << index)) != 0)
.ToArray();
var subsetCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(subsetCandidate, elkNodes, subset);
subsetCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(subsetCandidate, elkNodes, minLineClearance, subset);
subsetCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
subsetCandidate,
elkNodes,
minLineClearance,
subset,
enforceAllNodeEndpoints: true);
subsetResults.Add((
subset,
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBoundarySlotViolations(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountBadEntryAngles(subsetCandidate, elkNodes),
ElkEdgeRoutingScoring.CountSharedLaneViolations(subsetCandidate, elkNodes)));
}
foreach (var result in subsetResults
.OrderBy(item => item.BoundarySlots)
.ThenBy(item => item.GatewaySource)
.ThenBy(item => item.Detour)
.ThenBy(item => item.Entry)
.ThenBy(item => item.SharedLanes)
.ThenBy(item => item.Focus.Length)
.ThenBy(item => string.Join(",", item.Focus), StringComparer.Ordinal)
.Take(12))
{
TestContext.Out.WriteLine(
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
}
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Reflection;
using NUnit.Framework;
using StellaOps.ElkSharp;
@@ -9,7 +10,7 @@ using StellaOps.Workflow.Renderer.Svg;
namespace StellaOps.Workflow.Renderer.Tests;
[TestFixture]
public class DocumentProcessingWorkflowRenderingTests
public partial class DocumentProcessingWorkflowRenderingTests
{
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
{
@@ -85,451 +86,46 @@ public class DocumentProcessingWorkflowRenderingTests
};
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
private sealed class ElkPointComparer : IEqualityComparer<ElkPoint>
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
internal static readonly ElkPointComparer Instance = new();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
public bool Equals(ElkPoint? x, ElkPoint? y)
{
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);
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
var distinctLayerXs = visibleNodes
.Select(node => node.X)
.Distinct()
.OrderBy(x => x)
.ToArray();
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
var minInLayerGap = visibleNodes
.GroupBy(node => node.X)
.Select(group =>
if (ReferenceEquals(x, y))
{
var ordered = group.OrderBy(node => node.Y).ToArray();
if (ordered.Length < 2)
{
return double.MaxValue;
}
return ordered
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
.Min();
})
.DefaultIfEmpty(double.MaxValue)
.Min();
Assert.That(
minLayerGap,
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
Assert.That(
minInLayerGap,
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[0], sourceNode),
Is.EqualTo("right"),
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
var shortestDirectLength = 0d;
for (var i = 1; i < path.Count; i++)
{
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
}
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
Assert.That(
path.Min(point => point.Y),
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
Assert.That(
shortestDirectLength,
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge =>
routedEdge.SourceNodeId == "start/3"
&& routedEdge.TargetNodeId == "start/4/batched");
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
Assert.That(
HasTargetApproachBacktracking(edge, targetNode),
Is.False,
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
Assert.That(
ResolveBoundarySide(path[^1], targetNode),
Is.EqualTo("left"),
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
}
[Test]
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
var path = FlattenPath(edge);
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
Assert.That(
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.");
}
[Test]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
{
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 = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
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 }));
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, 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}");
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
// Render every iteration of every strategy as SVG only
var variantsDir = Path.Combine(outputDir, "strategy-variants");
Directory.CreateDirectory(variantsDir);
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
{
foreach (var attemptDiag in stratDiag.AttemptDetails)
{
if (attemptDiag.Edges is null)
{
continue;
}
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
var sc = attemptDiag.Score;
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"sl={sc.SharedLaneViolations} " +
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
attemptSvg.Svg);
return true;
}
if (stratDiag.BestEdges is not null)
if (x is null || y is null)
{
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
var bestSc = stratDiag.BestScore;
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"sl={bestSc?.SharedLaneViolations} " +
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
await File.WriteAllTextAsync(
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
bestSvg.Svg);
return false;
}
return Math.Abs(x.X - y.X) <= 0.5d && Math.Abs(x.Y - y.Y) <= 0.5d;
}
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
.SelectMany(strategy => strategy.AttemptDetails)
.Where(attempt => attempt.Attempt > 1
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
.ToArray();
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
Assert.That(
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
Is.True,
"Local repair attempts must reroute only the penalized subset of edges.");
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
var boundaryAngleOffenders = layout.Edges
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
.ToArray();
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
public int GetHashCode(ElkPoint obj)
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
}).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 sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
.Distinct(StringComparer.Ordinal)
.ToArray();
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
var gatewaySourceScoringOffenders = layout.Edges
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
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(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.");
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));
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceCurlOffenders = layout.Edges
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceFaceMismatchOffenders = layout.Edges
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
var gatewaySourceDetourOffenders = layout.Edges
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
var gatewaySourceVertexExitOffenders = layout.Edges
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
.Select(edge => edge.Id)
.ToArray();
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
var processBatchLoops = layout.Edges
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
.ToArray();
Assert.That(
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
Is.True,
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
{
return new WorkflowRenderLayoutResult
{
GraphId = baseLayout.GraphId,
Nodes = baseLayout.Nodes,
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
{
Id = e.Id,
SourceNodeId = e.SourceNodeId,
TargetNodeId = e.TargetNodeId,
Kind = e.Kind,
Label = e.Label,
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
{
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
}).ToArray(),
}).ToArray(),
};
return HashCode.Combine(Math.Round(obj.X, 3), Math.Round(obj.Y, 3));
}
}
// Verify zero edge-node crossings
var crossings = 0;
foreach (var node in layout.Nodes)
private static List<ElkPoint> ExtractElkPath(ElkRoutedEdge edge)
{
var path = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
foreach (var edge in layout.Edges)
if (path.Count == 0)
{
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++;
}
}
}
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
return path;
}
private static List<WorkflowRenderPoint> FlattenPath(WorkflowRenderRoutedEdge edge)
@@ -560,7 +156,7 @@ public class DocumentProcessingWorkflowRenderingTests
if (targetNode.Kind is "Decision" or "Fork" or "Join")
{
return HasGatewayTargetApproachBacktracking(path);
return HasGatewayTargetApproachBacktracking(path, targetNode);
}
var side = ResolveBoundarySide(path[^1], targetNode);
@@ -689,13 +285,20 @@ public class DocumentProcessingWorkflowRenderingTests
};
}
private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList<WorkflowRenderPoint> path)
private static bool HasGatewayTargetApproachBacktracking(
IReadOnlyList<WorkflowRenderPoint> path,
WorkflowRenderPositionedNode targetNode)
{
if (path.Count < 4)
{
return false;
}
if (HasShortGatewayTargetOrthogonalHook(path, targetNode))
{
return true;
}
const double tolerance = 0.5d;
var startIndex = Math.Max(0, path.Count - 4);
var nearEnd = path.Skip(startIndex).ToArray();
@@ -757,6 +360,39 @@ public class DocumentProcessingWorkflowRenderingTests
return false;
}
private static bool HasShortGatewayTargetOrthogonalHook(
IReadOnlyList<WorkflowRenderPoint> path,
WorkflowRenderPositionedNode targetNode)
{
if (path.Count < 3)
{
return false;
}
const double tolerance = 0.5d;
var boundaryPoint = path[^1];
var exteriorPoint = path[^2];
var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X);
var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y);
var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance;
var finalIsVertical = finalDy > tolerance && finalDx <= tolerance;
if (!finalIsHorizontal && !finalIsVertical)
{
return false;
}
var finalStubLength = finalIsHorizontal ? finalDx : finalDy;
var requiredDepth = Math.Min(targetNode.Width, targetNode.Height);
var predecessor = path[^3];
var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X);
var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y);
const double minimumApproachSpan = 24d;
return finalStubLength + tolerance < requiredDepth
&& (finalIsHorizontal
? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d
: predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d);
}
private static IEnumerable<string> GetTargetApproachJoinOffenders(
IReadOnlyCollection<WorkflowRenderRoutedEdge> edges,
IReadOnlyCollection<WorkflowRenderPositionedNode> nodes)
@@ -775,36 +411,51 @@ public class DocumentProcessingWorkflowRenderingTests
continue;
}
var targetEdges = group.ToArray();
for (var i = 0; i < targetEdges.Length; i++)
var elkTargetNode = new ElkPositionedNode
{
var leftPath = ExtractPath(targetEdges[i]);
if (leftPath.Count < 2)
Id = targetNode.Id,
Label = targetNode.Label,
Kind = targetNode.Kind,
X = targetNode.X,
Y = targetNode.Y,
Width = targetNode.Width,
Height = targetNode.Height,
};
var sideGroups = group
.Select(edge => new
{
continue;
}
var leftSide = ResolveTargetApproachJoinSide(leftPath, targetNode);
for (var j = i + 1; j < targetEdges.Length; j++)
Edge = edge,
Path = ExtractPath(edge),
})
.Where(entry => entry.Path.Count >= 2)
.Select(entry => new
{
var rightPath = ExtractPath(targetEdges[j]);
if (rightPath.Count < 2)
{
continue;
}
entry.Edge,
entry.Path,
Side = ResolveTargetApproachJoinSide(entry.Path, targetNode),
})
.GroupBy(entry => entry.Side, StringComparer.Ordinal);
var rightSide = ResolveTargetApproachJoinSide(rightPath, targetNode);
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
{
continue;
}
foreach (var sideGroup in sideGroups)
{
var sideEntries = sideGroup.ToArray();
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
elkTargetNode,
sideGroup.Key,
sideEntries.Length,
minLineClearance);
if (!HasTargetApproachJoin(leftPath, rightPath, minLineClearance, 3))
for (var i = 0; i < sideEntries.Length; i++)
{
for (var j = i + 1; j < sideEntries.Length; j++)
{
continue;
}
if (!HasTargetApproachJoin(sideEntries[i].Path, sideEntries[j].Path, requiredGap, 3))
{
continue;
}
yield return $"{targetEdges[i].Id}+{targetEdges[j].Id}@{targetNode.Id}/{leftSide}";
yield return $"{sideEntries[i].Edge.Id}+{sideEntries[j].Edge.Id}@{targetNode.Id}/{sideGroup.Key}";
}
}
}
}
@@ -833,6 +484,7 @@ public class DocumentProcessingWorkflowRenderingTests
double minLineClearance,
int maxSegmentsFromEnd)
{
var effectiveClearance = Math.Max(0d, minLineClearance - 0.5d);
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
@@ -840,7 +492,7 @@ public class DocumentProcessingWorkflowRenderingTests
{
foreach (var rightSegment in rightSegments)
{
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, minLineClearance))
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, effectiveClearance))
{
continue;
}
@@ -1555,7 +1207,7 @@ public class DocumentProcessingWorkflowRenderingTests
}
var averageShapeSize = (serviceNodes.Average(node => node.Width) + serviceNodes.Average(node => node.Height)) / 2d;
var maxDiagonalLength = Math.Max(96d, averageShapeSize * 2d);
var maxDiagonalLength = averageShapeSize;
foreach (var edge in edges)
{
@@ -1659,4 +1311,4 @@ public class DocumentProcessingWorkflowRenderingTests
return ResolveBoundarySide(boundaryPoint, node);
}
}
}

View File

@@ -1,6 +1,7 @@
using FluentAssertions;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Renderer.ElkSharp;
@@ -149,6 +150,174 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY);
}
[Test]
public async Task LayoutAsync_WhenDecisionSourceExitsTowardLowerBranch_ShouldUseDiagonalGatewayExit()
{
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var graph = new WorkflowRenderGraph
{
Id = "decision-exit-diagonal",
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 lowerSection = result.Edges.Single(x => x.Id == "e-lower").Sections.Single();
var lowerPath = BuildPath(lowerSection);
var exitPoint = lowerPath[0];
var nextPoint = lowerPath[1];
Math.Abs(nextPoint.X - exitPoint.X).Should().BeGreaterThan(3d);
Math.Abs(nextPoint.Y - exitPoint.Y).Should().BeGreaterThan(3d);
AssertDiamondBoundaryPoint(exitPoint, source);
ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(ToElkNode(source), new ElkPoint { X = nextPoint.X, Y = nextPoint.Y })
.Should()
.BeFalse();
}
[Test]
public async Task LayoutAsync_WhenDecisionTargetIsReachedOffAxis_ShouldUseDiagonalGatewayEntry()
{
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var graph = new WorkflowRenderGraph
{
Id = "decision-entry-diagonal",
Nodes =
[
new WorkflowRenderNode
{
Id = "upper",
Label = "Upper",
Kind = "SetState",
Width = 180,
Height = 84,
},
new WorkflowRenderNode
{
Id = "lower",
Label = "Lower",
Kind = "SetState",
Width = 180,
Height = 84,
},
new WorkflowRenderNode
{
Id = "gate",
Label = "Gate",
Kind = "Decision",
Width = 156,
Height = 132,
},
new WorkflowRenderNode
{
Id = "end",
Label = "End",
Kind = "End",
Width = 80,
Height = 40,
},
],
Edges =
[
new WorkflowRenderEdge
{
Id = "upper-gate",
SourceNodeId = "upper",
TargetNodeId = "gate",
},
new WorkflowRenderEdge
{
Id = "lower-gate",
SourceNodeId = "lower",
TargetNodeId = "gate",
},
new WorkflowRenderEdge
{
Id = "gate-end",
SourceNodeId = "gate",
TargetNodeId = "end",
},
],
};
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
{
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var gate = result.Nodes.Single(x => x.Id == "gate");
var incomingSections = result.Edges
.Where(x => x.TargetNodeId == "gate")
.Select(x => x.Sections.Single())
.ToArray();
var incomingPaths = incomingSections
.Select(BuildPath)
.ToArray();
incomingPaths.All(path => IsPointOnDiamondBoundary(path[^1], gate)).Should().BeTrue();
incomingPaths.Any(path =>
{
var prev = path[^2];
var end = path[^1];
return Math.Abs(end.X - prev.X) > 3d
&& Math.Abs(end.Y - prev.Y) > 3d;
}).Should().BeTrue();
incomingPaths.All(path =>
!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(
ToElkNode(gate),
new ElkPoint { X = path[^2].X, Y = path[^2].Y }))
.Should()
.BeTrue();
}
[Test]
public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders()
{
@@ -940,22 +1109,22 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
.ToArray();
var outerLoopEdges = loopEdges
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
.Where(section => BuildPath(section).Any(point => point.Y < section.EndPoint.Y - 1d))
.ToArray();
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
outerLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 4);
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
directLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 2);
var collectorX = outerLoopEdges
.Select(section => section.BendPoints.Max(point => point.X))
.Select(section => BuildPath(section).Max(point => point.X))
.DistinctBy(x => Math.Round(x, 2))
.ToArray();
collectorX.Should().HaveCount(1);
collectorX.Should().HaveCountLessOrEqualTo(2);
var outerLaneYs = outerLoopEdges
.Select(section => section.BendPoints.Min(point => point.Y))
.Select(section => BuildPath(section).Min(point => point.Y))
.OrderBy(y => y)
.ToArray();
outerLaneYs.Should().OnlyHaveUniqueItems();
@@ -977,4 +1146,40 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
}
private static IReadOnlyList<WorkflowRenderPoint> BuildPath(WorkflowRenderEdgeSection section)
{
var path = new List<WorkflowRenderPoint> { section.StartPoint };
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
return path;
}
private static void AssertDiamondBoundaryPoint(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
{
IsPointOnDiamondBoundary(point, node).Should().BeTrue();
}
private static bool IsPointOnDiamondBoundary(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
{
var centerX = node.X + (node.Width / 2d);
var centerY = node.Y + (node.Height / 2d);
var normalized = (Math.Abs(point.X - centerX) / Math.Max(node.Width / 2d, 0.001d))
+ (Math.Abs(point.Y - centerY) / Math.Max(node.Height / 2d, 0.001d));
return Math.Abs(normalized - 1d) <= 0.08d;
}
private static ElkPositionedNode ToElkNode(WorkflowRenderPositionedNode node)
{
return new ElkPositionedNode
{
Id = node.Id,
Label = node.Label,
Kind = node.Kind,
X = node.X,
Y = node.Y,
Width = node.Width,
Height = node.Height,
};
}
}