ElkSharp: gateway face overflow redirect, under-node push-first routing, boundary-slot snap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 10:35:23 +03:00
parent 5af14cf212
commit f275b8a267
30 changed files with 5632 additions and 2647 deletions

View File

@@ -0,0 +1,517 @@
using System.Reflection;
using System.Text.Json;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Renderer.Tests;
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 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})"))}");
}
if (targetSlots.TryGetValue("edge/5", out var edge5TargetSlot))
{
TestContext.Out.WriteLine(
$"edge/5 target-slot: side={edge5TargetSlot.Side} boundary=({edge5TargetSlot.Boundary.X:F3},{edge5TargetSlot.Boundary.Y:F3})");
var edge5 = elkEdges.Single(edge => edge.Id == "edge/5");
var edge5Path = ExtractElkPath(edge5);
var targetNode = elkNodes.Single(node => node.Id == edge5.TargetNodeId);
var postProcessorType = typeof(ElkEdgePostProcessor);
var buildTargetApproachCandidatePath = postProcessorType.GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachAxisValue = postProcessorType.GetMethod(
"ResolveTargetApproachAxisValue",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveDefaultTargetApproachAxis = postProcessorType.GetMethod(
"ResolveDefaultTargetApproachAxis",
BindingFlags.NonPublic | BindingFlags.Static);
var normalizeEntryPath = postProcessorType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Static)
.Single(method =>
method.Name == "NormalizeEntryPath"
&& method.GetParameters().Length == 4);
var canAcceptGatewayTargetRepair = postProcessorType.GetMethod(
"CanAcceptGatewayTargetRepair",
BindingFlags.NonPublic | BindingFlags.Static);
var hasAcceptableGatewayBoundaryPath = postProcessorType.GetMethod(
"HasAcceptableGatewayBoundaryPath",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachSide = postProcessorType.GetMethod(
"ResolveTargetApproachSide",
BindingFlags.NonPublic | BindingFlags.Static);
var isAcceptableStrictBoundarySlotCandidate = postProcessorType.GetMethod(
"IsAcceptableStrictBoundarySlotCandidate",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
Assert.That(resolveTargetApproachAxisValue, Is.Not.Null);
Assert.That(resolveDefaultTargetApproachAxis, Is.Not.Null);
Assert.That(canAcceptGatewayTargetRepair, Is.Not.Null);
Assert.That(hasAcceptableGatewayBoundaryPath, Is.Not.Null);
Assert.That(resolveTargetApproachSide, Is.Not.Null);
Assert.That(isAcceptableStrictBoundarySlotCandidate, Is.Not.Null);
var desiredTargetAxis = (double)resolveTargetApproachAxisValue!.Invoke(
null,
[edge5Path, edge5TargetSlot.Side])!;
if (double.IsNaN(desiredTargetAxis))
{
desiredTargetAxis = (double)resolveDefaultTargetApproachAxis!.Invoke(
null,
[targetNode, edge5TargetSlot.Side])!;
}
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[edge5Path, targetNode, edge5TargetSlot.Side, edge5TargetSlot.Boundary, desiredTargetAxis])!;
var strictTargetCandidate = (List<ElkPoint>)normalizeEntryPath.Invoke(
null,
[edge5Path, targetNode, edge5TargetSlot.Side, edge5TargetSlot.Boundary])!;
var reflectedTargetCandidateEdge = new ElkRoutedEdge[]
{
edge5 with
{
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = reflectedTargetCandidate[0].X, Y = reflectedTargetCandidate[0].Y },
EndPoint = new ElkPoint { X = reflectedTargetCandidate[^1].X, Y = reflectedTargetCandidate[^1].Y },
BendPoints = reflectedTargetCandidate.Skip(1).SkipLast(1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray(),
},
],
},
};
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate accepts-gateway={canAcceptGatewayTargetRepair!.Invoke(null, [reflectedTargetCandidate, targetNode])} boundary={hasAcceptableGatewayBoundaryPath!.Invoke(null, [reflectedTargetCandidate, elkNodes, edge5.SourceNodeId, edge5.TargetNodeId, targetNode, false])} strict={isAcceptableStrictBoundarySlotCandidate!.Invoke(null, [edge5, edge5Path, reflectedTargetCandidate, targetNode, false, elkNodes, graphMinY, graphMaxY])} side={resolveTargetApproachSide!.Invoke(null, [reflectedTargetCandidate, targetNode])}");
TestContext.Out.WriteLine(
$"edge/5 reflected-target-candidate score: boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(reflectedTargetCandidateEdge, elkNodes)} long-diagonal={ElkEdgeRoutingScoring.CountLongDiagonalViolations(reflectedTargetCandidateEdge, elkNodes)}");
TestContext.Out.WriteLine(
$"edge/5 strict-target-candidate: {string.Join(" -> ", strictTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 strict-target-candidate accepts-gateway={canAcceptGatewayTargetRepair.Invoke(null, [strictTargetCandidate, targetNode])} boundary={hasAcceptableGatewayBoundaryPath.Invoke(null, [strictTargetCandidate, elkNodes, edge5.SourceNodeId, edge5.TargetNodeId, targetNode, false])} strict={isAcceptableStrictBoundarySlotCandidate.Invoke(null, [edge5, edge5Path, strictTargetCandidate, targetNode, false, elkNodes, graphMinY, graphMaxY])} side={resolveTargetApproachSide.Invoke(null, [strictTargetCandidate, targetNode])}");
}
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 edge5FocusedSnap = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
elkEdges,
elkNodes,
minLineClearance,
["edge/5"],
enforceAllNodeEndpoints: true);
var edge5FocusedSnapPath = ExtractElkPath(edge5FocusedSnap.Single(edge => edge.Id == "edge/5"));
TestContext.Out.WriteLine(
$"edge/5 focused-snapped: {string.Join(" -> ", edge5FocusedSnapPath.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
TestContext.Out.WriteLine(
$"edge/5 focused-snapped score: boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(edge5FocusedSnap, elkNodes)} long-diagonal={ElkEdgeRoutingScoring.CountLongDiagonalViolations(edge5FocusedSnap, elkNodes)}");
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

@@ -0,0 +1,294 @@
using System.Text.Json;
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]
[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");
}
}

View File

@@ -283,690 +283,4 @@ 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]
[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

@@ -185,6 +185,164 @@ public partial class ElkSharpEdgeRefinementTests
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [join, target]).Should().Be(1);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenDecisionSourceLeavesFromTipOnStraightExit_ShouldRepairOffVertex()
{
var source = new ElkPositionedNode
{
Id = "decision",
Label = "Check Result",
Kind = "Decision",
X = 3406,
Y = 225.182,
Width = 188,
Height = 132,
};
var target = new ElkPositionedNode
{
Id = "task",
Label = "Set printBatched",
Kind = "SetState",
X = 3778,
Y = 239.182,
Width = 224,
Height = 104,
};
var tip = new ElkPoint
{
X = source.X + source.Width,
Y = source.Y + (source.Height / 2d),
};
var edge = new ElkRoutedEdge
{
Id = "edge/16",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = tip,
EndPoint = new ElkPoint { X = target.X, Y = tip.Y },
BendPoints = [],
},
],
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [source, target]).Should().Be(1);
var helper = typeof(ElkEdgePostProcessor).GetMethod(
"ForceDecisionSourceExitOffVertex",
BindingFlags.Static | BindingFlags.NonPublic)!;
var repairedPath = ((IReadOnlyList<ElkPoint>)helper.Invoke(
null,
[new List<ElkPoint> { tip, new ElkPoint { X = target.X, Y = tip.Y } }, source, new[] { source, target }, source.Id, target.Id])!)
.ToList();
var repaired = new[]
{
new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Sections =
[
new ElkEdgeSection
{
StartPoint = repairedPath[0],
EndPoint = repairedPath[^1],
BendPoints = repairedPath.Skip(1).Take(Math.Max(0, repairedPath.Count - 2)).ToArray(),
},
],
},
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations(repaired, [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, [source, target]).Should().Be(0);
ElkShapeBoundaries.IsNearGatewayVertex(source, repairedPath[0]).Should().BeFalse();
repairedPath[1].X.Should().BeGreaterThan(repairedPath[0].X + 3d);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenJoinSourceLeavesFromTipOnStraightExit_ShouldRepairOffVertex()
{
var source = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.591,
Width = 176,
Height = 124,
};
var target = new ElkPositionedNode
{
Id = "task",
Label = "Load Configuration",
Kind = "TransportCall",
X = 1604,
Y = 134.591,
Width = 208,
Height = 88,
};
var tip = new ElkPoint
{
X = source.X + source.Width,
Y = source.Y + (source.Height / 2d),
};
var edge = new ElkRoutedEdge
{
Id = "edge/19",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = tip,
EndPoint = new ElkPoint { X = target.X, Y = tip.Y },
BendPoints = [],
},
],
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations([edge], [source, target]).Should().Be(1);
var helper = typeof(ElkEdgePostProcessor).GetMethod(
"ForceDecisionSourceExitOffVertex",
BindingFlags.Static | BindingFlags.NonPublic)!;
var repairedPath = ((IReadOnlyList<ElkPoint>)helper.Invoke(
null,
[new List<ElkPoint> { tip, new ElkPoint { X = target.X, Y = tip.Y } }, source, new[] { source, target }, source.Id, target.Id])!)
.ToList();
var repaired = new[]
{
new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Sections =
[
new ElkEdgeSection
{
StartPoint = repairedPath[0],
EndPoint = repairedPath[^1],
BendPoints = repairedPath.Skip(1).Take(Math.Max(0, repairedPath.Count - 2)).ToArray(),
},
],
},
};
ElkEdgeRoutingScoring.CountGatewaySourceVertexExitViolations(repaired, [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, [source, target]).Should().Be(0);
ElkShapeBoundaries.IsNearGatewayVertex(source, repairedPath[0]).Should().BeFalse();
repairedPath[1].X.Should().BeGreaterThan(repairedPath[0].X + 3d);
}
[Test]
[Property("Intent", "Operational")]
public void GatewayBoundaryHelpers_WhenTwoEdgesArriveNearParallelIntoJoin_ShouldCountTargetJoinViolation()
@@ -1186,6 +1344,24 @@ public partial class ElkSharpEdgeRefinementTests
var composeTransactionalFinalDetourCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"ComposeTransactionalFinalDetourCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var expandWinningSolutionFocus = typeof(ElkEdgeRouterIterative).GetMethod(
"ExpandWinningSolutionFocus",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildFinalBoundarySlotCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildFinalBoundarySlotCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildFinalRestabilizedCandidate = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildFinalRestabilizedCandidate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var buildRetryState = typeof(ElkEdgeRouterIterative).GetMethod(
"BuildRetryState",
BindingFlags.Static | BindingFlags.NonPublic)!;
var compareRetryStates = typeof(ElkEdgeRouterIterative).GetMethod(
"CompareRetryStates",
BindingFlags.Static | BindingFlags.NonPublic)!;
var hasHardRuleRegression = typeof(ElkEdgeRouterIterative).GetMethod(
"HasHardRuleRegression",
BindingFlags.Static | BindingFlags.NonPublic)!;
var offenders = elkEdges
.Where(edge =>
@@ -1223,22 +1399,97 @@ public partial class ElkSharpEdgeRefinementTests
var finalizedBoundary = ElkEdgeRoutingScoring.CountBadBoundaryAngles(finalized, elkNodes);
var finalizedGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(finalized, elkNodes);
var edgeSpecificDebug = string.Empty;
if (edge.Id is "edge/7" or "edge/27")
if (edge.Id is "edge/7" or "edge/9" or "edge/20" or "edge/27")
{
var targetNode = elkNodes.Single(node => node.Id == edge.TargetNodeId);
var expandedFocusEdgeIds = ((IEnumerable<string>)expandWinningSolutionFocus.Invoke(
null,
new object?[] { elkEdges, new[] { edge.Id } })!)
.ToArray();
var focusedLayoutRepair = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [edge.Id]);
var transactionalLayoutRepair = (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, new[] { edge.Id } })!;
var focusedExpandedSnapLayoutRepair = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
focusedLayoutRepair,
elkNodes,
52.7d,
expandedFocusEdgeIds,
enforceAllNodeEndpoints: true);
var focusedExpandedSnapBacktrackingRepairLayout = focusedExpandedSnapLayoutRepair;
var expandedTransactionalLayoutRepair = (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, expandedFocusEdgeIds })!;
var focusedExpandedRestabilizedLayoutRepair = (ElkRoutedEdge[])buildFinalRestabilizedCandidate.Invoke(
null,
new object?[]
{
focusedLayoutRepair,
elkNodes,
ElkLayoutDirection.LeftToRight,
52.7d,
expandedFocusEdgeIds,
})!;
var pairedTransactionalLayoutRepair = edge.Id == "edge/27"
? (ElkRoutedEdge[])composeTransactionalFinalDetourCandidate.Invoke(
null,
new object?[] { elkEdges, elkNodes, 52.7d, new[] { "edge/27", "edge/28" } })!
: transactionalLayoutRepair;
var focusedExpandedSnapNewOffenderRevertLayout = focusedExpandedSnapLayoutRepair;
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(elkEdges, elkNodes);
var focusedLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedLayoutRepair, elkNodes);
var transactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(transactionalLayoutRepair, elkNodes);
var focusedExpandedSnapLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedExpandedSnapLayoutRepair, elkNodes);
var focusedExpandedSnapBacktrackingRepairLayoutScore = focusedExpandedSnapLayoutScore;
var focusedExpandedSnapNewOffenderRevertLayoutScore = focusedExpandedSnapLayoutScore;
var expandedTransactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(expandedTransactionalLayoutRepair, elkNodes);
var focusedExpandedRestabilizedLayoutScore = ElkEdgeRoutingScoring.ComputeScore(focusedExpandedRestabilizedLayoutRepair, elkNodes);
var pairedTransactionalLayoutScore = ElkEdgeRoutingScoring.ComputeScore(pairedTransactionalLayoutRepair, elkNodes);
var focusedBoundarySlotCandidate = (ElkRoutedEdge[])buildFinalBoundarySlotCandidate.Invoke(
null,
new object?[]
{
focusedLayoutRepair,
elkNodes,
ElkLayoutDirection.LeftToRight,
52.7d,
new[] { edge.Id },
false,
})!;
var focusedBoundarySlotScore = ElkEdgeRoutingScoring.ComputeScore(focusedBoundarySlotCandidate, elkNodes);
var baselineBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(elkEdges, elkNodes).Count;
var focusedBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedLayoutRepair, elkNodes).Count;
var transactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(transactionalLayoutRepair, elkNodes).Count;
var focusedExpandedSnapBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedExpandedSnapLayoutRepair, elkNodes).Count;
var focusedExpandedSnapBacktrackingRepairBrokenHighways = focusedExpandedSnapBrokenHighways;
var focusedExpandedSnapNewOffenderRevertBrokenHighways = focusedExpandedSnapBrokenHighways;
var expandedTransactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(expandedTransactionalLayoutRepair, elkNodes).Count;
var focusedExpandedRestabilizedBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedExpandedRestabilizedLayoutRepair, elkNodes).Count;
var pairedTransactionalBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(pairedTransactionalLayoutRepair, elkNodes).Count;
var focusedBoundarySlotBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(focusedBoundarySlotCandidate, elkNodes).Count;
var baselineRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { baselineScore, baselineBrokenHighways })!;
var focusedLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedLayoutScore, focusedBrokenHighways })!;
var transactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { transactionalLayoutScore, transactionalBrokenHighways })!;
var focusedExpandedSnapLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedExpandedSnapLayoutScore, focusedExpandedSnapBrokenHighways })!;
var focusedExpandedSnapBacktrackingRepairLayoutRetryState = focusedExpandedSnapLayoutRetryState;
var focusedExpandedSnapNewOffenderRevertLayoutRetryState = focusedExpandedSnapLayoutRetryState;
var expandedTransactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { expandedTransactionalLayoutScore, expandedTransactionalBrokenHighways })!;
var focusedExpandedRestabilizedLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { focusedExpandedRestabilizedLayoutScore, focusedExpandedRestabilizedBrokenHighways })!;
var pairedTransactionalLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[] { pairedTransactionalLayoutScore, pairedTransactionalBrokenHighways })!;
var peerConflictLayoutRepair = edge.Id == "edge/27"
? focusedLayoutRepair.ToArray()
: focusedLayoutRepair;
@@ -1265,8 +1516,110 @@ public partial class ElkSharpEdgeRefinementTests
}
}
var peerConflictLayoutScore = ElkEdgeRoutingScoring.ComputeScore(peerConflictLayoutRepair, elkNodes);
var baselineBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
elkEdges,
elkNodes,
baselineBacktrackingSeverity,
1);
var focusedJoinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedLayoutRepair, elkNodes, focusedJoinSeverity, 1);
var expandedTransactionalBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
expandedTransactionalLayoutRepair,
elkNodes,
expandedTransactionalBacktrackingSeverity,
1);
var focusedExpandedSnapBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapLayoutRepair,
elkNodes,
focusedExpandedSnapBacktrackingSeverity,
1);
if (focusedExpandedSnapBacktrackingSeverity.Count > 0)
{
focusedExpandedSnapBacktrackingRepairLayout = ElkEdgePostProcessor.NormalizeBoundaryAngles(
ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
focusedExpandedSnapLayoutRepair,
elkNodes,
52.7d,
focusedExpandedSnapBacktrackingSeverity.Keys
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray()),
elkNodes);
focusedExpandedSnapBacktrackingRepairLayoutScore = ElkEdgeRoutingScoring.ComputeScore(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes);
focusedExpandedSnapBacktrackingRepairBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes).Count;
focusedExpandedSnapBacktrackingRepairLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[]
{
focusedExpandedSnapBacktrackingRepairLayoutScore,
focusedExpandedSnapBacktrackingRepairBrokenHighways,
})!;
}
var focusedExpandedSnapBacktrackingRepairSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapBacktrackingRepairLayout,
elkNodes,
focusedExpandedSnapBacktrackingRepairSeverity,
1);
var focusedExpandedSnapNewOffenderEdgeIds = focusedExpandedSnapBacktrackingSeverity.Keys
.Except(baselineBacktrackingSeverity.Keys, StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
if (focusedExpandedSnapNewOffenderEdgeIds.Length > 0)
{
focusedExpandedSnapNewOffenderRevertLayout = focusedExpandedSnapLayoutRepair.ToArray();
foreach (var revertEdgeId in focusedExpandedSnapNewOffenderEdgeIds)
{
var revertIndex = Array.FindIndex(
focusedExpandedSnapNewOffenderRevertLayout,
candidate => string.Equals(candidate.Id, revertEdgeId, StringComparison.Ordinal));
if (revertIndex < 0)
{
continue;
}
focusedExpandedSnapNewOffenderRevertLayout[revertIndex] = focusedLayoutRepair.Single(candidate =>
string.Equals(candidate.Id, revertEdgeId, StringComparison.Ordinal));
}
focusedExpandedSnapNewOffenderRevertLayoutScore = ElkEdgeRoutingScoring.ComputeScore(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes);
focusedExpandedSnapNewOffenderRevertBrokenHighways = ElkEdgeRouterHighway.DetectRemainingBrokenHighways(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes).Count;
focusedExpandedSnapNewOffenderRevertLayoutRetryState = (RoutingRetryState)buildRetryState.Invoke(
null,
new object?[]
{
focusedExpandedSnapNewOffenderRevertLayoutScore,
focusedExpandedSnapNewOffenderRevertBrokenHighways,
})!;
}
var focusedExpandedSnapNewOffenderRevertSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedSnapNewOffenderRevertLayout,
elkNodes,
focusedExpandedSnapNewOffenderRevertSeverity,
1);
var focusedExpandedRestabilizedBacktrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(
focusedExpandedRestabilizedLayoutRepair,
elkNodes,
focusedExpandedRestabilizedBacktrackingSeverity,
1);
var focusedExpandedRestabilizedJoinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(
focusedExpandedRestabilizedLayoutRepair,
elkNodes,
focusedExpandedRestabilizedJoinSeverity,
1);
var focusedLayoutSection = focusedLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedLayoutPath = focusedLayoutSection.BendPoints
.Prepend(focusedLayoutSection.StartPoint)
@@ -1279,6 +1632,36 @@ public partial class ElkSharpEdgeRefinementTests
.Append(transactionalLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapLayoutSection = focusedExpandedSnapLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapLayoutPath = focusedExpandedSnapLayoutSection.BendPoints
.Prepend(focusedExpandedSnapLayoutSection.StartPoint)
.Append(focusedExpandedSnapLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapBacktrackingRepairLayoutSection = focusedExpandedSnapBacktrackingRepairLayout.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapBacktrackingRepairLayoutPath = focusedExpandedSnapBacktrackingRepairLayoutSection.BendPoints
.Prepend(focusedExpandedSnapBacktrackingRepairLayoutSection.StartPoint)
.Append(focusedExpandedSnapBacktrackingRepairLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedSnapNewOffenderRevertLayoutSection = focusedExpandedSnapNewOffenderRevertLayout.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedSnapNewOffenderRevertLayoutPath = focusedExpandedSnapNewOffenderRevertLayoutSection.BendPoints
.Prepend(focusedExpandedSnapNewOffenderRevertLayoutSection.StartPoint)
.Append(focusedExpandedSnapNewOffenderRevertLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var expandedTransactionalLayoutSection = expandedTransactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var expandedTransactionalLayoutPath = expandedTransactionalLayoutSection.BendPoints
.Prepend(expandedTransactionalLayoutSection.StartPoint)
.Append(expandedTransactionalLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var focusedExpandedRestabilizedLayoutSection = focusedExpandedRestabilizedLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var focusedExpandedRestabilizedLayoutPath = focusedExpandedRestabilizedLayoutSection.BendPoints
.Prepend(focusedExpandedRestabilizedLayoutSection.StartPoint)
.Append(focusedExpandedRestabilizedLayoutSection.EndPoint)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray();
var peerConflictLayoutSection = peerConflictLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();
var peerConflictLayoutPath = peerConflictLayoutSection.BendPoints
.Prepend(peerConflictLayoutSection.StartPoint)
@@ -1291,6 +1674,21 @@ public partial class ElkSharpEdgeRefinementTests
var chosenTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, transactionalLayoutRepair, elkNodes })!;
var chosenFocusedExpandedSnapLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapLayoutRepair, elkNodes })!;
var chosenFocusedExpandedSnapBacktrackingRepairLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapBacktrackingRepairLayout, elkNodes })!;
var chosenFocusedExpandedSnapNewOffenderRevertLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedSnapNewOffenderRevertLayout, elkNodes })!;
var chosenExpandedTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, expandedTransactionalLayoutRepair, elkNodes })!;
var chosenFocusedExpandedRestabilizedLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, focusedExpandedRestabilizedLayoutRepair, elkNodes })!;
var chosenPairedTransactionalLayout = (ElkRoutedEdge[])choosePreferredHardRuleLayout.Invoke(
null,
new object?[] { elkEdges, pairedTransactionalLayoutRepair, elkNodes })!;
@@ -1326,8 +1724,13 @@ public partial class ElkSharpEdgeRefinementTests
};
edgeSpecificDebug += $" | chooser={(ReferenceEquals(chosenLayout, focusedLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | baseline-score entry={baselineScore.EntryAngleViolations},"
+ $" nc={baselineScore.NodeCrossings},"
+ $" short={baselineBrokenHighways},"
+ $" gateway-source={baselineScore.GatewaySourceExitViolations},"
+ $" collector-corridor={baselineScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={baselineScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={baselineScore.SharedLaneViolations},"
+ $" boundary={baselineScore.BoundarySlotViolations},"
+ $" joins={baselineScore.TargetApproachJoinViolations},"
+ $" backtracking={baselineScore.TargetApproachBacktrackingViolations},"
+ $" detour={baselineScore.ExcessiveDetourViolations},"
@@ -1336,10 +1739,16 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={baselineScore.LongDiagonalViolations},"
+ $" proximity={baselineScore.ProximityViolations},"
+ $" label={baselineScore.LabelProximityViolations},"
+ $" crossings={baselineScore.EdgeCrossings}";
+ $" crossings={baselineScore.EdgeCrossings},"
+ $" value={baselineScore.Value:F0}";
edgeSpecificDebug += $" | focused-score entry={focusedLayoutScore.EntryAngleViolations},"
+ $" nc={focusedLayoutScore.NodeCrossings},"
+ $" short={focusedBrokenHighways},"
+ $" gateway-source={focusedLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedLayoutScore.ExcessiveDetourViolations},"
@@ -1348,11 +1757,19 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={focusedLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedLayoutScore.ProximityViolations},"
+ $" label={focusedLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedLayoutScore.EdgeCrossings}";
+ $" crossings={focusedLayoutScore.EdgeCrossings},"
+ $" value={focusedLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | transactional-chooser={(ReferenceEquals(chosenTransactionalLayout, transactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | transactional-score entry={transactionalLayoutScore.EntryAngleViolations},"
+ $" nc={transactionalLayoutScore.NodeCrossings},"
+ $" short={transactionalBrokenHighways},"
+ $" gateway-source={transactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={transactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={transactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={transactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={transactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={transactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={transactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={transactionalLayoutScore.ExcessiveDetourViolations},"
@@ -1361,11 +1778,132 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={transactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={transactionalLayoutScore.ProximityViolations},"
+ $" label={transactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={transactionalLayoutScore.EdgeCrossings}";
+ $" crossings={transactionalLayoutScore.EdgeCrossings},"
+ $" value={transactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { transactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { transactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-chooser={(ReferenceEquals(chosenFocusedExpandedSnapLayout, focusedExpandedSnapLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-score entry={focusedExpandedSnapLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-edges=[{string.Join(", ", focusedExpandedSnapBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-chooser={(ReferenceEquals(chosenFocusedExpandedSnapBacktrackingRepairLayout, focusedExpandedSnapBacktrackingRepairLayout) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-score entry={focusedExpandedSnapBacktrackingRepairLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapBacktrackingRepairLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapBacktrackingRepairBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapBacktrackingRepairLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapBacktrackingRepairLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapBacktrackingRepairLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapBacktrackingRepairLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapBacktrackingRepairLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapBacktrackingRepairLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapBacktrackingRepairLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapBacktrackingRepairLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapBacktrackingRepairLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapBacktrackingRepairLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapBacktrackingRepairLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapBacktrackingRepairLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapBacktrackingRepairLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapBacktrackingRepairLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapBacktrackingRepairLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapBacktrackingRepairLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapBacktrackingRepairLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-edges=[{string.Join(", ", focusedExpandedSnapBacktrackingRepairSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-chooser={(ReferenceEquals(chosenFocusedExpandedSnapNewOffenderRevertLayout, focusedExpandedSnapNewOffenderRevertLayout) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-score entry={focusedExpandedSnapNewOffenderRevertLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedSnapNewOffenderRevertLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedSnapNewOffenderRevertBrokenHighways},"
+ $" gateway-source={focusedExpandedSnapNewOffenderRevertLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedSnapNewOffenderRevertLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedSnapNewOffenderRevertLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedSnapNewOffenderRevertLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedSnapNewOffenderRevertLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedSnapNewOffenderRevertLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedSnapNewOffenderRevertLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedSnapNewOffenderRevertLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedSnapNewOffenderRevertLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedSnapNewOffenderRevertLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedSnapNewOffenderRevertLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedSnapNewOffenderRevertLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedSnapNewOffenderRevertLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedSnapNewOffenderRevertLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedSnapNewOffenderRevertLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedSnapNewOffenderRevertLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedSnapNewOffenderRevertLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-edges=[{string.Join(", ", focusedExpandedSnapNewOffenderEdgeIds)}]";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-backtracking-edges=[{string.Join(", ", focusedExpandedSnapNewOffenderRevertSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | expanded-transactional-focus=[{string.Join(", ", expandedFocusEdgeIds)}]";
edgeSpecificDebug += $" | expanded-transactional-chooser={(ReferenceEquals(chosenExpandedTransactionalLayout, expandedTransactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | expanded-transactional-score entry={expandedTransactionalLayoutScore.EntryAngleViolations},"
+ $" nc={expandedTransactionalLayoutScore.NodeCrossings},"
+ $" short={expandedTransactionalBrokenHighways},"
+ $" gateway-source={expandedTransactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={expandedTransactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={expandedTransactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={expandedTransactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={expandedTransactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={expandedTransactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={expandedTransactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={expandedTransactionalLayoutScore.ExcessiveDetourViolations},"
+ $" below={expandedTransactionalLayoutScore.BelowGraphViolations},"
+ $" under={expandedTransactionalLayoutScore.UnderNodeViolations},"
+ $" longdiag={expandedTransactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={expandedTransactionalLayoutScore.ProximityViolations},"
+ $" label={expandedTransactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={expandedTransactionalLayoutScore.EdgeCrossings},"
+ $" value={expandedTransactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | expanded-transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { expandedTransactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { expandedTransactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | expanded-transactional-backtracking-edges=[{string.Join(", ", expandedTransactionalBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-restabilized-chooser={(ReferenceEquals(chosenFocusedExpandedRestabilizedLayout, focusedExpandedRestabilizedLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | focused-expanded-restabilized-score entry={focusedExpandedRestabilizedLayoutScore.EntryAngleViolations},"
+ $" nc={focusedExpandedRestabilizedLayoutScore.NodeCrossings},"
+ $" short={focusedExpandedRestabilizedBrokenHighways},"
+ $" gateway-source={focusedExpandedRestabilizedLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={focusedExpandedRestabilizedLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={focusedExpandedRestabilizedLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={focusedExpandedRestabilizedLayoutScore.SharedLaneViolations},"
+ $" boundary={focusedExpandedRestabilizedLayoutScore.BoundarySlotViolations},"
+ $" joins={focusedExpandedRestabilizedLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedExpandedRestabilizedLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedExpandedRestabilizedLayoutScore.ExcessiveDetourViolations},"
+ $" below={focusedExpandedRestabilizedLayoutScore.BelowGraphViolations},"
+ $" under={focusedExpandedRestabilizedLayoutScore.UnderNodeViolations},"
+ $" longdiag={focusedExpandedRestabilizedLayoutScore.LongDiagonalViolations},"
+ $" proximity={focusedExpandedRestabilizedLayoutScore.ProximityViolations},"
+ $" label={focusedExpandedRestabilizedLayoutScore.LabelProximityViolations},"
+ $" crossings={focusedExpandedRestabilizedLayoutScore.EdgeCrossings},"
+ $" value={focusedExpandedRestabilizedLayoutScore.Value:F0}";
edgeSpecificDebug += $" | focused-expanded-restabilized-compare retry={compareRetryStates.Invoke(null, new object?[] { focusedExpandedRestabilizedLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { focusedExpandedRestabilizedLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | focused-expanded-restabilized-backtracking-edges=[{string.Join(", ", focusedExpandedRestabilizedBacktrackingSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | focused-expanded-restabilized-join-edges=[{string.Join(", ", focusedExpandedRestabilizedJoinSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | paired-transactional-chooser={(ReferenceEquals(chosenPairedTransactionalLayout, pairedTransactionalLayoutRepair) ? "candidate" : "baseline")}";
edgeSpecificDebug += $" | paired-transactional-score entry={pairedTransactionalLayoutScore.EntryAngleViolations},"
+ $" nc={pairedTransactionalLayoutScore.NodeCrossings},"
+ $" short={pairedTransactionalBrokenHighways},"
+ $" gateway-source={pairedTransactionalLayoutScore.GatewaySourceExitViolations},"
+ $" collector-corridor={pairedTransactionalLayoutScore.RepeatCollectorCorridorViolations},"
+ $" collector-clearance={pairedTransactionalLayoutScore.RepeatCollectorNodeClearanceViolations},"
+ $" shared={pairedTransactionalLayoutScore.SharedLaneViolations},"
+ $" boundary={pairedTransactionalLayoutScore.BoundarySlotViolations},"
+ $" joins={pairedTransactionalLayoutScore.TargetApproachJoinViolations},"
+ $" backtracking={pairedTransactionalLayoutScore.TargetApproachBacktrackingViolations},"
+ $" detour={pairedTransactionalLayoutScore.ExcessiveDetourViolations},"
@@ -1374,7 +1912,10 @@ public partial class ElkSharpEdgeRefinementTests
+ $" longdiag={pairedTransactionalLayoutScore.LongDiagonalViolations},"
+ $" proximity={pairedTransactionalLayoutScore.ProximityViolations},"
+ $" label={pairedTransactionalLayoutScore.LabelProximityViolations},"
+ $" crossings={pairedTransactionalLayoutScore.EdgeCrossings}";
+ $" crossings={pairedTransactionalLayoutScore.EdgeCrossings},"
+ $" value={pairedTransactionalLayoutScore.Value:F0}";
edgeSpecificDebug += $" | paired-transactional-compare retry={compareRetryStates.Invoke(null, new object?[] { pairedTransactionalLayoutRetryState, baselineRetryState })},"
+ $" hard={(bool)hasHardRuleRegression.Invoke(null, new object?[] { pairedTransactionalLayoutRetryState, baselineRetryState })!}";
edgeSpecificDebug += $" | peer-conflict-score entry={peerConflictLayoutScore.EntryAngleViolations},"
+ $" gateway-source={peerConflictLayoutScore.GatewaySourceExitViolations},"
+ $" shared={peerConflictLayoutScore.SharedLaneViolations},"
@@ -1389,8 +1930,18 @@ public partial class ElkSharpEdgeRefinementTests
+ $" crossings={peerConflictLayoutScore.EdgeCrossings}";
edgeSpecificDebug += $" | focused-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedLayoutRepair, elkNodes)},"
+ $" boundary={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedLayoutPath)}";
edgeSpecificDebug += $" | focused-boundary-candidate short={focusedBoundarySlotBrokenHighways},"
+ $" boundary={focusedBoundarySlotScore.BoundarySlotViolations},"
+ $" joins={focusedBoundarySlotScore.TargetApproachJoinViolations},"
+ $" backtracking={focusedBoundarySlotScore.TargetApproachBacktrackingViolations},"
+ $" detour={focusedBoundarySlotScore.ExcessiveDetourViolations},"
+ $" under={focusedBoundarySlotScore.UnderNodeViolations},"
+ $" proximity={focusedBoundarySlotScore.ProximityViolations},"
+ $" label={focusedBoundarySlotScore.LabelProximityViolations},"
+ $" crossings={focusedBoundarySlotScore.EdgeCrossings}";
edgeSpecificDebug += $" | focused-join-edges=[{string.Join(", ", focusedJoinSeverity.Keys.OrderBy(id => id, StringComparer.Ordinal))}]";
edgeSpecificDebug += $" | transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(transactionalLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(transactionalLayoutRepair, elkNodes)},"
@@ -1398,6 +1949,36 @@ public partial class ElkSharpEdgeRefinementTests
+ $" under={ElkEdgeRoutingScoring.CountUnderNodeViolations(transactionalLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(transactionalLayoutRepair, elkNodes)}:"
+ $" {DescribePath(transactionalLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-backtracking-repair-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapBacktrackingRepairLayout, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapBacktrackingRepairLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-snap-new-offender-revert-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedSnapNewOffenderRevertLayout, elkNodes)}:"
+ $" {DescribePath(focusedExpandedSnapNewOffenderRevertLayoutPath)}";
edgeSpecificDebug += $" | expanded-transactional-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(expandedTransactionalLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(expandedTransactionalLayoutRepair, elkNodes)}:"
+ $" {DescribePath(expandedTransactionalLayoutPath)}";
edgeSpecificDebug += $" | focused-expanded-restabilized-layout detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" backtracking={ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" shared={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)},"
+ $" joins={ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(focusedExpandedRestabilizedLayoutRepair, elkNodes)}:"
+ $" {DescribePath(focusedExpandedRestabilizedLayoutPath)}";
if (edge.Id == "edge/27")
{
var pairedTransactionalLayoutSection = pairedTransactionalLayoutRepair.Single(candidate => candidate.Id == edge.Id).Sections.Single();

View File

@@ -0,0 +1,903 @@
using System.Reflection;
using System.Text.Json;
using FluentAssertions;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
namespace StellaOps.Workflow.Renderer.Tests;
public partial class ElkSharpEdgeRefinementTests
{
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonTopSlotNeedsSnap_ShouldPreserveOrthogonalFeeder()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Internal Notification",
Kind = "Decision",
X = 2662,
Y = 639.4360656738281,
Width = 188,
Height = 132,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/25",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2741.2685655585256, Y = 649.7794132603952 },
EndPoint = new ElkPoint { X = 3849.7988202419156, Y = 646.940845586461 },
BendPoints =
[
new ElkPoint { X = 2741.2685655585256, Y = 631.4360656738281 },
new ElkPoint { X = 3849.7988202419156, Y = 631.4360656738281 },
],
},
],
};
var nodes = new[] { source, target };
ElkEdgeRoutingScoring.CountBoundarySlotViolations([edge], nodes)
.Should().Be(1);
var repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
[edge],
nodes,
53d,
enforceAllNodeEndpoints: true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
var repairedPath = ExtractPath(repaired.Single());
repairedPath.Should().HaveCount(4);
repairedPath[1].Y.Should().BeApproximately(repairedPath[2].Y, 0.5d);
repairedPath[2].X.Should().BeApproximately(repairedPath[3].X, 0.5d);
var expectedEnd = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"top",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "top", 1).Single());
repairedPath[^1].X.Should().BeApproximately(expectedEnd.X, 0.5d);
repairedPath[^1].Y.Should().BeApproximately(expectedEnd.Y, 0.5d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonLeftSlotNeedsSnap_ShouldReplaceLongDiagonalWithOrthogonalFeeder()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Execute Batch",
Kind = "TransportCall",
X = 1604,
Y = 320.5908203125,
Width = 208,
Height = 88,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Retry Decision",
Kind = "Decision",
X = 1976,
Y = 420.5908203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/5",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "on failure",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1812, Y = 386.5908203125 },
EndPoint = new ElkPoint { X = 1986.4506603822254, Y = 479.25312259732056 },
BendPoints =
[
new ElkPoint { X = 1860, Y = 386.5908203125 },
],
},
],
};
var nodes = new[] { source, target };
ElkEdgeRoutingScoring.CountBoundarySlotViolations([edge], nodes)
.Should().BeGreaterThan(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations([edge], nodes)
.Should().Be(1);
var repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
[edge],
nodes,
53d,
enforceAllNodeEndpoints: true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations(repaired, nodes)
.Should().Be(0);
var repairedPath = ExtractPath(repaired.Single());
repairedPath.Should().HaveCountGreaterOrEqualTo(4);
Math.Abs(repairedPath[^2].Y - repairedPath[^1].Y).Should().BeLessThan(0.5d);
repairedPath.Zip(repairedPath.Skip(1), (start, end) =>
Math.Abs(start.X - end.X) <= 0.5d && Math.Abs(start.Y - end.Y) > 0.5d)
.Should()
.Contain(isVertical => isVertical);
var expectedEnd = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"left",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "left", 1).Single());
repairedPath[^1].X.Should().BeApproximately(expectedEnd.X, 0.5d);
repairedPath[^1].Y.Should().BeApproximately(expectedEnd.Y, 0.5d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenGatewayTargetSingletonLeftSlotUsesFallbackAxis_ShouldKeepAssignedFace()
{
var source = new ElkPositionedNode
{
Id = "source",
Label = "Execute Batch",
Kind = "TransportCall",
X = 1604,
Y = 320.5908203125,
Width = 208,
Height = 88,
};
var target = new ElkPositionedNode
{
Id = "target",
Label = "Retry Decision",
Kind = "Decision",
X = 1976,
Y = 420.5908203125,
Width = 188,
Height = 132,
};
var edge = new ElkRoutedEdge
{
Id = "edge/5",
SourceNodeId = source.Id,
TargetNodeId = target.Id,
Label = "on failure",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1812, Y = 386.5908203125 },
EndPoint = new ElkPoint { X = 1986.4506603822254, Y = 479.25312259732056 },
BendPoints =
[
new ElkPoint { X = 1860, Y = 386.5908203125 },
],
},
],
};
var path = ExtractPath(edge);
var desiredEndpoint = ElkBoundarySlots.BuildBoundarySlotPoint(
target,
"left",
ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(target, "left", 1).Single());
var postProcessorType = typeof(ElkEdgePostProcessor);
var resolveTargetApproachAxisValue = postProcessorType.GetMethod(
"ResolveTargetApproachAxisValue",
BindingFlags.NonPublic | BindingFlags.Static);
var buildTargetApproachCandidatePath = postProcessorType.GetMethod(
"BuildTargetApproachCandidatePath",
BindingFlags.NonPublic | BindingFlags.Static);
var canAcceptGatewayTargetRepair = postProcessorType.GetMethod(
"CanAcceptGatewayTargetRepair",
BindingFlags.NonPublic | BindingFlags.Static);
var resolveTargetApproachSide = postProcessorType.GetMethod(
"ResolveTargetApproachSide",
BindingFlags.NonPublic | BindingFlags.Static);
resolveTargetApproachAxisValue.Should().NotBeNull();
buildTargetApproachCandidatePath.Should().NotBeNull();
canAcceptGatewayTargetRepair.Should().NotBeNull();
resolveTargetApproachSide.Should().NotBeNull();
var targetAxis = (double)resolveTargetApproachAxisValue!.Invoke(null, [path, "left"])!;
var candidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
null,
[path, target, "left", desiredEndpoint, targetAxis])!;
var candidateEdge = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = candidate[0].X, Y = candidate[0].Y },
EndPoint = new ElkPoint { X = candidate[^1].X, Y = candidate[^1].Y },
BendPoints = candidate.Skip(1).SkipLast(1)
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
.ToArray(),
},
],
};
resolveTargetApproachSide.Invoke(null, [candidate, target]).Should().Be("left");
canAcceptGatewayTargetRepair.Invoke(null, [candidate, target]).Should().Be(true);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([candidateEdge], [source, target]).Should().Be(0);
ElkEdgeRoutingScoring.CountLongDiagonalViolations([candidateEdge], [source, target]).Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenLateSlotSnapTouchesMixedNodeFaceHandoff_ShouldKeepDistinctFaceSlots()
{
var process = new ElkPositionedNode
{
Id = "process",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var validateSuccess = new ElkPositionedNode
{
Id = "validate",
Label = "Validate Success",
Kind = "Decision",
X = 3406,
Y = 225.181640625,
Width = 188,
Height = 132,
};
var join = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var incoming = new ElkRoutedEdge
{
Id = "edge/in",
SourceNodeId = validateSuccess.Id,
TargetNodeId = process.Id,
Label = "repeat while state.printInsisAttempt eq 0",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3420.7314344414744, Y = 301.5249882115671 },
EndPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
BendPoints =
[
new ElkPoint { X = 3398, Y = 301.5249882115671 },
new ElkPoint { X = 3398, Y = -87.75 },
new ElkPoint { X = 1224, Y = -87.75 },
new ElkPoint { X = 1224, Y = 269.181640625 },
],
},
],
};
var outgoing = new ElkRoutedEdge
{
Id = "edge/out",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
EndPoint = new ElkPoint { X = 1305.1127839415904, Y = 215.68583544185816 },
BendPoints =
[
new ElkPoint { X = 1282.5912611218437, Y = 269.181640625 },
],
},
],
};
var body = new ElkRoutedEdge
{
Id = "edge/body",
SourceNodeId = process.Id,
TargetNodeId = "body",
Label = "body",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 291.181640625 },
EndPoint = new ElkPoint { X = 1290, Y = 347.61603420489007 },
BendPoints =
[
new ElkPoint { X = 1224, Y = 291.181640625 },
new ElkPoint { X = 1224, Y = 347.61603420489007 },
],
},
],
};
var bodyNode = new ElkPositionedNode
{
Id = "body",
Label = "Body",
Kind = "SetState",
X = 1290,
Y = 312.5908203125,
Width = 224,
Height = 104,
};
var nodes = new[] { process, validateSuccess, join, bodyNode };
ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing, body], nodes)
.Should().Be(1);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([incoming, outgoing, body], nodes)
.Should().BeGreaterThan(0);
var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing, body], nodes, 53d);
repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.SnapBoundarySlotAssignments(repaired, nodes, 53d);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void MixedNodeFaceHelpers_WhenMixedTopFaceEntriesAreOffLatticeWithoutLaneConflict_ShouldSnapToDiscreteSlots()
{
var process = new ElkPositionedNode
{
Id = "process",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var upstream = new ElkPositionedNode
{
Id = "upstream",
Label = "Upstream",
Kind = "Task",
X = 1012,
Y = 56,
Width = 160,
Height = 88,
};
var join = new ElkPositionedNode
{
Id = "join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var incoming = new ElkRoutedEdge
{
Id = "edge/in-top",
SourceNodeId = upstream.Id,
TargetNodeId = process.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1040, Y = 144 },
EndPoint = new ElkPoint { X = 1040, Y = 247.181640625 },
BendPoints =
[
new ElkPoint { X = 1040, Y = 188 },
],
},
],
};
var outgoing = new ElkRoutedEdge
{
Id = "edge/out-top",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1140, Y = 247.181640625 },
EndPoint = new ElkPoint { X = 1290, Y = 222.88924788024843 },
BendPoints =
[
new ElkPoint { X = 1140, Y = 180 },
],
},
],
};
var nodes = new[] { process, upstream, join };
ElkEdgeRoutingScoring.CountSharedLaneViolations([incoming, outgoing], nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations([incoming, outgoing], nodes)
.Should().BeGreaterThan(0);
var repaired = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts([incoming, outgoing], nodes, 53d);
repaired = ElkEdgePostProcessor.NormalizeBoundaryAngles(repaired, nodes);
repaired = ElkEdgePostProcessor.NormalizeSourceExitAngles(repaired, nodes);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().Be(0);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
var repairedIncoming = repaired.Single(edge => edge.Id == "edge/in-top").Sections.Single();
var repairedOutgoing = repaired.Single(edge => edge.Id == "edge/out-top").Sections.Single();
repairedIncoming.EndPoint.X.Should().NotBe(1040d);
repairedOutgoing.StartPoint.X.Should().NotBe(1140d);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenDecisionSourceSlotsNeedLateRestabilization_ShouldRepairGatewayExitsAndDefaultDetours()
{
var evaluateConditions = new ElkPositionedNode
{
Id = "start/9",
Label = "Evaluate Conditions",
Kind = "Decision",
X = 2290,
Y = 32.25,
Width = 188,
Height = 132,
};
var internalNotification = new ElkPositionedNode
{
Id = "start/9/true/1",
Label = "Internal Notification",
Kind = "Decision",
X = 2662,
Y = 639.4360656738281,
Width = 188,
Height = 132,
};
var hasRecipients = new ElkPositionedNode
{
Id = "start/9/true/2",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var end = new ElkPositionedNode
{
Id = "end",
Label = "End",
Kind = "End",
X = 4864,
Y = 331.8013916015625,
Width = 264,
Height = 132,
};
var toInternalNotification = new ElkRoutedEdge
{
Id = "edge/22",
SourceNodeId = evaluateConditions.Id,
TargetNodeId = internalNotification.Id,
Label = "when state.notificationHasBody",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2416.700665574371, Y = 141.28995821373923 },
EndPoint = new ElkPoint { X = 2726, Y = 660.4998954610621 },
BendPoints =
[
new ElkPoint { X = 2437.418589349298, Y = 170.79730419621083 },
new ElkPoint { X = 2573.636363636364, Y = 170.79730419621083 },
new ElkPoint { X = 2573.636363636364, Y = 660.4998954610621 },
],
},
],
};
var defaultToEnd = new ElkRoutedEdge
{
Id = "edge/23",
SourceNodeId = evaluateConditions.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2466.977224619808, Y = 105.98939547970912 },
EndPoint = new ElkPoint { X = 4888, Y = 331.8013916015625 },
BendPoints =
[
new ElkPoint { X = 4888, Y = 105.98939547970912 },
],
},
],
};
var defaultToRecipients = new ElkRoutedEdge
{
Id = "edge/25",
SourceNodeId = internalNotification.Id,
TargetNodeId = hasRecipients.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 2838.4874461780896, Y = 697.352783203125 },
EndPoint = new ElkPoint { X = 3849.7988202419156, Y = 646.940845586461 },
BendPoints =
[
new ElkPoint { X = 2838.4874461780896, Y = 631.4360656738281 },
new ElkPoint { X = 3849.7988202419156, Y = 631.4360656738281 },
],
},
],
};
var recipientsDefaultToEnd = new ElkRoutedEdge
{
Id = "edge/30",
SourceNodeId = hasRecipients.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3936.565656565657, Y = 718.0194498697916 },
EndPoint = new ElkPoint { X = 4864, Y = 355.8013916015625 },
BendPoints =
[
new ElkPoint { X = 3974, Y = 718.0194498697916 },
new ElkPoint { X = 3974, Y = 625.039776972473 },
new ElkPoint { X = 4840, Y = 625.039776972473 },
new ElkPoint { X = 4840, Y = 355.8013916015625 },
],
},
],
};
var nodes = new[] { evaluateConditions, internalNotification, hasRecipients, end };
var edges = new[] { toInternalNotification, defaultToEnd, defaultToRecipients, recipientsDefaultToEnd };
var initialBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes);
var initialGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes);
var initialEntry = ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes);
var initialDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes);
initialBoundarySlots.Should().BeGreaterThan(0);
initialGatewaySource.Should().BeGreaterThan(0);
initialDetour.Should().BeGreaterThanOrEqualTo(0);
var repaired = ElkEdgeRouterIterative.BuildFinalBoundarySlotCandidate(
edges,
nodes,
ElkLayoutDirection.LeftToRight,
53d,
["edge/22", "edge/23", "edge/25", "edge/30"]);
var repairedGatewaySource = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(repaired, nodes);
var repairedRecipientsDefaultToEnd = repaired.Single(edge => edge.Id == "edge/30");
var repairedRecipientsDefaultToEndPath = ExtractPath(repairedRecipientsDefaultToEnd);
var hasScoringCandidate = ElkEdgePostProcessor.TryBuildGatewaySourceScoringCandidate(
repairedRecipientsDefaultToEndPath,
hasRecipients,
nodes,
repairedRecipientsDefaultToEnd.SourceNodeId,
repairedRecipientsDefaultToEnd.TargetNodeId,
out var repairedRecipientsDefaultToEndScoringCandidate);
var scoringCandidateText = hasScoringCandidate
? string.Join(" -> ", repairedRecipientsDefaultToEndScoringCandidate.Select(point => $"({point.X:0.###},{point.Y:0.###})"))
: "<none>";
if (repairedGatewaySource > 0)
{
foreach (var edge in repaired)
{
var path = ExtractPath(edge);
TestContext.WriteLine($"{edge.Id}: {string.Join(" -> ", path.Select(point => $"({point.X:0.###},{point.Y:0.###})"))}");
}
}
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().Be(0);
repairedGatewaySource.Should().Be(0);
ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity(
repairedRecipientsDefaultToEndPath,
hasRecipients,
nodes,
repairedRecipientsDefaultToEnd.SourceNodeId,
repairedRecipientsDefaultToEnd.TargetNodeId)
.Should()
.BeFalse(
$"edge/30 path: {string.Join(" -> ", repairedRecipientsDefaultToEndPath.Select(point => $"({point.X:0.###},{point.Y:0.###})"))}; " +
$"scoring candidate: {scoringCandidateText}");
ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialEntry);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes)
.Should().Be(0);
}
[Test]
[Property("Intent", "Operational")]
public void BoundarySlotHelpers_WhenLateSlottingReintroducesMixedFaceAndEntryViolations_ShouldRestabilizeWinnerGeometry()
{
var process = new ElkPositionedNode
{
Id = "start/2/branch-1/1",
Label = "Process Batch",
Kind = "Repeat",
X = 992,
Y = 247.181640625,
Width = 208,
Height = 88,
};
var validateSuccess = new ElkPositionedNode
{
Id = "start/2/branch-1/1/body/5/true/1",
Label = "Validate Success",
Kind = "Decision",
X = 3406,
Y = 225.181640625,
Width = 188,
Height = 132,
};
var join = new ElkPositionedNode
{
Id = "start/2/join",
Label = "Parallel Execution Join",
Kind = "Join",
X = 1290,
Y = 116.5908203125,
Width = 176,
Height = 124,
};
var internalNotification = new ElkPositionedNode
{
Id = "start/9/true/1/true/1",
Label = "Internal Notification",
Kind = "TransportCall",
X = 3034,
Y = 653.352783203125,
Width = 208,
Height = 88,
};
var handled = new ElkPositionedNode
{
Id = "start/9/true/1/true/1/handled/1",
Label = "Set internalNotificationFailed",
Kind = "SetState",
X = 3406,
Y = 653.352783203125,
Width = 208,
Height = 88,
};
var hasRecipients = new ElkPositionedNode
{
Id = "start/9/true/2",
Label = "Has Recipients",
Kind = "Decision",
X = 3778,
Y = 631.352783203125,
Width = 188,
Height = 132,
};
var end = new ElkPositionedNode
{
Id = "end",
Label = "End",
Kind = "End",
X = 4864,
Y = 331.8013916015625,
Width = 264,
Height = 132,
};
var repeatReturn = new ElkRoutedEdge
{
Id = "edge/15",
SourceNodeId = validateSuccess.Id,
TargetNodeId = process.Id,
Label = "repeat while state.printInsisAttempt eq 0",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3420.73143444147, Y = 301.524988211567 },
EndPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
BendPoints =
[
new ElkPoint { X = 3398, Y = 301.524988211567 },
new ElkPoint { X = 3398, Y = -190.659090909091 },
new ElkPoint { X = 1224, Y = -190.659090909091 },
new ElkPoint { X = 1224, Y = 269.181640625 },
],
},
],
};
var joinExit = new ElkRoutedEdge
{
Id = "edge/17",
SourceNodeId = process.Id,
TargetNodeId = join.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 1200, Y = 269.181640625 },
EndPoint = new ElkPoint { X = 1298.10526315789, Y = 198.485557154605 },
BendPoints =
[
new ElkPoint { X = 1248, Y = 269.181640625 },
new ElkPoint { X = 1248, Y = 248.5908203125 },
],
},
],
};
var directFailure = new ElkRoutedEdge
{
Id = "edge/26",
SourceNodeId = internalNotification.Id,
TargetNodeId = handled.Id,
Label = "on failure / timeout",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3242, Y = 675.352783203125 },
EndPoint = new ElkPoint { X = 3406, Y = 697.352783203125 },
BendPoints =
[
new ElkPoint { X = 3406, Y = 675.352783203125 },
],
},
],
};
var notificationToRecipients = new ElkRoutedEdge
{
Id = "edge/27",
SourceNodeId = internalNotification.Id,
TargetNodeId = hasRecipients.Id,
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3242, Y = 719.352783203125 },
EndPoint = new ElkPoint { X = 3792.73143444147, Y = 707.696130789692 },
BendPoints =
[
new ElkPoint { X = 3253.63636363636, Y = 719.352783203125 },
new ElkPoint { X = 3253.63636363636, Y = 574.708792946555 },
new ElkPoint { X = 3814.59988578289, Y = 574.708792946555 },
new ElkPoint { X = 3814.59988578289, Y = 769.900087399336 },
],
},
],
};
var recipientsToEnd = new ElkRoutedEdge
{
Id = "edge/30",
SourceNodeId = hasRecipients.Id,
TargetNodeId = end.Id,
Label = "default",
Sections =
[
new ElkEdgeSection
{
StartPoint = new ElkPoint { X = 3940.02631578947, Y = 679.115941097862 },
EndPoint = new ElkPoint { X = 4864, Y = 439.801391601563 },
BendPoints =
[
new ElkPoint { X = 3974, Y = 679.115941097862 },
new ElkPoint { X = 3974, Y = 439.801391601563 },
],
},
],
};
var nodes = new[] { process, validateSuccess, join, internalNotification, handled, hasRecipients, end };
var edges = new[] { repeatReturn, joinExit, directFailure, notificationToRecipients, recipientsToEnd };
var initialSharedLanes = ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes);
var initialEntry = ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes);
var initialBoundarySlots = ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes);
var initialDetour = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes);
initialSharedLanes.Should().BeGreaterThan(0);
initialEntry.Should().BeGreaterThan(0);
var mixedFaceOnly = ElkEdgePostProcessor.SeparateMixedNodeFaceLaneConflicts(
edges,
nodes,
53d,
["edge/15", "edge/17"]);
ElkEdgeRoutingScoring.CountSharedLaneViolations(mixedFaceOnly, nodes)
.Should().Be(0);
var terminalOnly = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(
edges,
nodes,
53d,
["edge/26", "edge/27", "edge/30"]);
terminalOnly = ElkEdgePostProcessor.NormalizeBoundaryAngles(terminalOnly, nodes);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(terminalOnly, nodes)
.Should().Be(0);
var repaired = ElkEdgeRouterIterative.BuildFinalRestabilizedCandidate(
edges,
nodes,
ElkLayoutDirection.LeftToRight,
53d,
["edge/15", "edge/17", "edge/26", "edge/27", "edge/30"]);
ElkEdgeRoutingScoring.CountSharedLaneViolations(repaired, nodes)
.Should().BeLessThan(initialSharedLanes);
var repairedBadAngles = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(repaired, nodes, repairedBadAngles, 10)
.Should().BeLessThan(initialEntry, $"remaining bad-angle edges: {string.Join(", ", repairedBadAngles.Keys.OrderBy(id => id, StringComparer.Ordinal))}");
ElkEdgeRoutingScoring.CountBoundarySlotViolations(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialBoundarySlots);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(repaired, nodes)
.Should().BeLessThanOrEqualTo(initialDetour);
}
}