Refactor ElkSharp routing sources into partial modules
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
using StellaOps.Workflow.Renderer.Svg;
|
||||
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
public partial class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
|
||||
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
|
||||
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
|
||||
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
|
||||
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
|
||||
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
|
||||
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
|
||||
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var distinctLayerXs = visibleNodes
|
||||
.Select(node => node.X)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
|
||||
var minInLayerGap = visibleNodes
|
||||
.GroupBy(node => node.X)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(node => node.Y).ToArray();
|
||||
if (ordered.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
return ordered
|
||||
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
|
||||
.Min();
|
||||
})
|
||||
.DefaultIfEmpty(double.MaxValue)
|
||||
.Min();
|
||||
Assert.That(
|
||||
minLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
|
||||
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
|
||||
Assert.That(
|
||||
minInLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
|
||||
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[0], sourceNode),
|
||||
Is.EqualTo("right"),
|
||||
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
|
||||
var shortestDirectLength = 0d;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
{
|
||||
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
|
||||
}
|
||||
|
||||
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
|
||||
Assert.That(
|
||||
path.Min(point => point.Y),
|
||||
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
|
||||
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
|
||||
Assert.That(
|
||||
shortestDirectLength,
|
||||
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
|
||||
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/3"
|
||||
&& routedEdge.TargetNodeId == "start/4/batched");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeTopEntryIntoHasRecipients()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/9/true/1"
|
||||
&& routedEdge.TargetNodeId == "start/9/true/2");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Internal Notification -> Has Recipients must not stay horizontal to the gateway and then drop a tiny vertical stub into the target face.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(3));
|
||||
Assert.That(
|
||||
HasShortGatewayTargetOrthogonalHook(path, targetNode),
|
||||
Is.False,
|
||||
"Internal Notification -> Has Recipients must not change into the gateway boundary direction within less than one node depth of the target.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
|
||||
|
||||
Assert.That(
|
||||
path.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepDecisionSourceExitsOnDiscreteBoundarySlots()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var elkNodes = layout.Nodes.Select(ToElkNode).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
|
||||
|
||||
Assert.That(
|
||||
count,
|
||||
Is.EqualTo(0),
|
||||
$"Selected layout must keep decision source exits on the discrete boundary-slot lattice after winner refinement. Offenders: {string.Join(", ", severityByEdgeId.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => entry.Key))}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
if (File.Exists(diagnosticsPath))
|
||||
{
|
||||
File.Delete(diagnosticsPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
{
|
||||
pngPath = Path.Combine(outputDir, "elksharp.png");
|
||||
pngExporter = new WorkflowRenderPngExporter();
|
||||
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
|
||||
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
|
||||
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
|
||||
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
|
||||
|
||||
// Render every iteration of every strategy as SVG only
|
||||
var variantsDir = Path.Combine(outputDir, "strategy-variants");
|
||||
Directory.CreateDirectory(variantsDir);
|
||||
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
|
||||
{
|
||||
foreach (var attemptDiag in stratDiag.AttemptDetails)
|
||||
{
|
||||
if (attemptDiag.Edges is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
|
||||
var sc = attemptDiag.Score;
|
||||
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
|
||||
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
|
||||
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
|
||||
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
|
||||
$"sl={sc.SharedLaneViolations} bs={sc.BoundarySlotViolations} " +
|
||||
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
|
||||
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
|
||||
attemptSvg.Svg);
|
||||
}
|
||||
|
||||
if (stratDiag.BestEdges is not null)
|
||||
{
|
||||
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
|
||||
var bestSc = stratDiag.BestScore;
|
||||
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
|
||||
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
|
||||
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
|
||||
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
|
||||
$"sl={bestSc?.SharedLaneViolations} bs={bestSc?.BoundarySlotViolations} " +
|
||||
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
|
||||
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
|
||||
bestSvg.Svg);
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
|
||||
|
||||
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
|
||||
.SelectMany(strategy => strategy.AttemptDetails)
|
||||
.Where(attempt => attempt.Attempt > 1
|
||||
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
|
||||
Assert.That(
|
||||
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
|
||||
Is.True,
|
||||
"Local repair attempts must reroute only the penalized subset of edges.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
|
||||
var boundaryAngleOffenders = layout.Edges
|
||||
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
|
||||
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
|
||||
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
}).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
|
||||
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
|
||||
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
|
||||
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BoundarySlotViolations, Is.EqualTo(0), "Selected layout must not concentrate more than one edge onto the same discrete side slot or leave side endpoints off the evenly spread slot lattice.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
|
||||
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
|
||||
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
|
||||
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
|
||||
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
|
||||
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
|
||||
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoops = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
|
||||
Is.True,
|
||||
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
|
||||
|
||||
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
|
||||
{
|
||||
return new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = baseLayout.GraphId,
|
||||
Nodes = baseLayout.Nodes,
|
||||
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceNodeId = e.SourceNodeId,
|
||||
TargetNodeId = e.TargetNodeId,
|
||||
Kind = e.Kind,
|
||||
Label = e.Label,
|
||||
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
|
||||
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
|
||||
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = pts[i];
|
||||
var p2 = pts[i + 1];
|
||||
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
|
||||
crossings++;
|
||||
}
|
||||
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DocumentProcessingWorkflow_WhenInspectingLatestElkSharpArtifact_ShouldReportBoundarySlotOffenders()
|
||||
{
|
||||
var workflowRenderingsDirectory = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults",
|
||||
"workflow-renderings");
|
||||
var outputDir = Directory.GetDirectories(workflowRenderingsDirectory)
|
||||
.OrderByDescending(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.Select(path => Path.Combine(path, "DocumentProcessingWorkflow"))
|
||||
.First(Directory.Exists);
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
Assert.That(File.Exists(jsonPath), Is.True);
|
||||
|
||||
var layout = JsonSerializer.Deserialize<WorkflowRenderLayoutResult>(
|
||||
File.ReadAllText(jsonPath),
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
Assert.That(layout, Is.Not.Null);
|
||||
|
||||
var elkNodes = layout!.Nodes.Select(node => new ElkPositionedNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
}).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
|
||||
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var count = ElkEdgeRoutingScoring.CountBoundarySlotViolations(elkEdges, elkNodes, severityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"boundary-slot artifact count: {count}");
|
||||
foreach (var offender in severityByEdgeId.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var serviceNodes = elkNodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var minLineClearance = serviceNodes.Length > 0
|
||||
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
||||
: 50d;
|
||||
var nodesById = elkNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var graphMinY = elkNodes.Min(node => node.Y);
|
||||
var graphMaxY = elkNodes.Max(node => node.Y + node.Height);
|
||||
var (sourceSlots, targetSlots) = ElkEdgePostProcessor.ResolveCombinedBoundarySlots(
|
||||
elkEdges,
|
||||
nodesById,
|
||||
graphMinY,
|
||||
graphMaxY,
|
||||
restrictedEdgeIds: null,
|
||||
enforceAllNodeEndpoints: true);
|
||||
if (sourceSlots.TryGetValue("edge/25", out var sourceSlot))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 source-slot: side={sourceSlot.Side} boundary=({sourceSlot.Boundary.X:F3},{sourceSlot.Boundary.Y:F3})");
|
||||
}
|
||||
|
||||
if (targetSlots.TryGetValue("edge/25", out var targetSlot))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 target-slot: side={targetSlot.Side} boundary=({targetSlot.Boundary.X:F3},{targetSlot.Boundary.Y:F3})");
|
||||
|
||||
var edge25 = elkEdges.Single(edge => edge.Id == "edge/25");
|
||||
var edge25Path = ExtractElkPath(edge25);
|
||||
var buildTargetApproachCandidatePath = typeof(ElkEdgePostProcessor).GetMethod(
|
||||
"BuildTargetApproachCandidatePath",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.That(buildTargetApproachCandidatePath, Is.Not.Null);
|
||||
var reflectedTargetCandidate = (List<ElkPoint>)buildTargetApproachCandidatePath!.Invoke(
|
||||
null,
|
||||
[edge25Path, elkNodes.Single(node => node.Id == edge25.TargetNodeId), targetSlot.Side, targetSlot.Boundary, edge25Path[2].Y])!;
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 reflected-target-candidate: {string.Join(" -> ", reflectedTargetCandidate.Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var snapped = ElkEdgePostProcessor.SnapBoundarySlotAssignments(elkEdges, elkNodes, minLineClearance);
|
||||
var snappedSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var snappedCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(snapped, elkNodes, snappedSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"boundary-slot snapped count: {snappedCount}");
|
||||
if (snappedSeverityByEdgeId.TryGetValue("edge/25", out _))
|
||||
{
|
||||
var snappedEdge = snapped.Single(candidate => candidate.Id == "edge/25");
|
||||
TestContext.Out.WriteLine(
|
||||
$"edge/25 snapped: {string.Join(" -> ", ExtractElkPath(snappedEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var detourSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var detourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(elkEdges, elkNodes, detourSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"detour artifact count: {detourCount}");
|
||||
foreach (var offender in detourSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key} detour={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
|
||||
var shortcutCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts([edge], elkNodes)[0];
|
||||
if (!ExtractElkPath(shortcutCandidate).SequenceEqual(ExtractElkPath(edge), ElkPointComparer.Instance))
|
||||
{
|
||||
var shortcutDetourSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var shortcutGatewaySeverity = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var shortcutDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(
|
||||
[shortcutCandidate],
|
||||
elkNodes,
|
||||
shortcutDetourSeverity,
|
||||
1);
|
||||
var shortcutGatewayCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(
|
||||
[shortcutCandidate],
|
||||
elkNodes,
|
||||
shortcutGatewaySeverity,
|
||||
1);
|
||||
TestContext.Out.WriteLine(
|
||||
$" shortcut -> detour={shortcutDetourCount} gateway-source={shortcutGatewayCount}: {string.Join(" -> ", ExtractElkPath(shortcutCandidate).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
else
|
||||
{
|
||||
TestContext.Out.WriteLine(" shortcut -> unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
var gatewaySourceSeverityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var gatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(elkEdges, elkNodes, gatewaySourceSeverityByEdgeId, 1);
|
||||
TestContext.Out.WriteLine($"gateway-source artifact count: {gatewaySourceCount}");
|
||||
foreach (var offender in gatewaySourceSeverityByEdgeId.OrderByDescending(pair => pair.Value).ThenBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var edge = elkEdges.Single(candidate => candidate.Id == offender.Key);
|
||||
TestContext.Out.WriteLine(
|
||||
$"{offender.Key} gateway-source={offender.Value}: {string.Join(" -> ", ExtractElkPath(edge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var sharedLaneConflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Distinct()
|
||||
.OrderBy(conflict => conflict.LeftEdgeId, StringComparer.Ordinal)
|
||||
.ThenBy(conflict => conflict.RightEdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"shared-lane artifact count: {sharedLaneConflicts.Length}");
|
||||
foreach (var conflict in sharedLaneConflicts)
|
||||
{
|
||||
TestContext.Out.WriteLine($"shared-lane artifact pair: {conflict.LeftEdgeId}+{conflict.RightEdgeId}");
|
||||
}
|
||||
|
||||
var layoutNodesById = layout.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
var gatewayCornerDiagonalOffenders = layout.Edges
|
||||
.Where(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-corner artifact count: {gatewayCornerDiagonalOffenders.Length}");
|
||||
foreach (var edgeId in gatewayCornerDiagonalOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-corner artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewayInteriorAdjacentOffenders = layout.Edges
|
||||
.Where(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.SourceNodeId], fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layoutNodesById[edge.TargetNodeId], fromSource: false))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact count: {gatewayInteriorAdjacentOffenders.Length}");
|
||||
foreach (var edgeId in gatewayInteriorAdjacentOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-interior-adjacent artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layoutNodesById[edge.SourceNodeId]))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-curl artifact count: {gatewaySourceCurlOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceCurlOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-curl artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact count: {gatewaySourceFaceMismatchOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceFaceMismatchOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-face-mismatch artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layoutNodesById[edge.SourceNodeId], layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-detour artifact count: {gatewaySourceDetourOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceDetourOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-detour artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layoutNodesById[edge.SourceNodeId]))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact count: {gatewaySourceVertexExitOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceVertexExitOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-vertex-exit artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"gateway-source-scoring artifact count: {gatewaySourceScoringOffenders.Length}");
|
||||
foreach (var edgeId in gatewaySourceScoringOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"gateway-source-scoring artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoopOffenders = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal)
|
||||
&& HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d))
|
||||
.Select(edge => edge.Id)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"process-batch-clearance artifact count: {processBatchLoopOffenders.Length}");
|
||||
foreach (var edgeId in processBatchLoopOffenders)
|
||||
{
|
||||
TestContext.Out.WriteLine($"process-batch-clearance artifact edge: {edgeId}");
|
||||
}
|
||||
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var points = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
points.AddRange(section.BendPoints);
|
||||
points.Add(section.EndPoint);
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var start = points[i];
|
||||
var end = points[i + 1];
|
||||
if (Math.Abs(start.Y - end.Y) < 2d && start.Y > node.Y && start.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
else if (Math.Abs(start.X - end.X) < 2d && start.X > node.X && start.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height)
|
||||
{
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TestContext.Out.WriteLine($"edge-node-crossing artifact count: {crossings}");
|
||||
|
||||
var focusedSharedLaneCandidate = ElkEdgePostProcessor.SeparateSharedLaneConflicts(
|
||||
elkEdges,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
["edge/15", "edge/17", "edge/35"]);
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane candidate counts: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedSharedLaneCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedSharedLaneCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedSharedLaneCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedSharedLaneCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedSharedLaneCandidate, elkNodes)}");
|
||||
foreach (var edgeId in new[] { "edge/15", "edge/17", "edge/35" })
|
||||
{
|
||||
var currentEdge = elkEdges.Single(edge => edge.Id == edgeId);
|
||||
var candidateEdge = focusedSharedLaneCandidate.Single(edge => edge.Id == edgeId);
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane {edgeId} current: {string.Join(" -> ", ExtractElkPath(currentEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused shared-lane {edgeId} candidate: {string.Join(" -> ", ExtractElkPath(candidateEdge).Select(point => $"({point.X:F3},{point.Y:F3})"))}");
|
||||
}
|
||||
|
||||
var combinedFocus = detourSeverityByEdgeId.Keys
|
||||
.Concat(gatewaySourceSeverityByEdgeId.Keys)
|
||||
.OrderBy(edgeId => edgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (combinedFocus.Length > 0)
|
||||
{
|
||||
var batchCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(batchCandidate, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(batchCandidate, elkNodes, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(batchCandidate, elkNodes, minLineClearance, combinedFocus);
|
||||
batchCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
batchCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
combinedFocus,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
var batchDetourCount = ElkEdgeRoutingScoring.CountExcessiveDetourViolations(batchCandidate, elkNodes);
|
||||
var batchGatewaySourceCount = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(batchCandidate, elkNodes);
|
||||
var batchBoundarySlotCount = ElkEdgeRoutingScoring.CountBoundarySlotViolations(batchCandidate, elkNodes);
|
||||
var batchEntryCount = ElkEdgeRoutingScoring.CountBadEntryAngles(batchCandidate, elkNodes);
|
||||
var batchSharedLaneCount = ElkEdgeRoutingScoring.CountSharedLaneViolations(batchCandidate, elkNodes);
|
||||
TestContext.Out.WriteLine(
|
||||
$"batch-candidate counts: detour={batchDetourCount} gateway-source={batchGatewaySourceCount} boundary-slots={batchBoundarySlotCount} entry={batchEntryCount} shared-lanes={batchSharedLaneCount}");
|
||||
|
||||
foreach (var focusEdgeId in combinedFocus)
|
||||
{
|
||||
var focusedCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(focusedCandidate, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(focusedCandidate, elkNodes, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(focusedCandidate, elkNodes, minLineClearance, [focusEdgeId]);
|
||||
focusedCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
focusedCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
[focusEdgeId],
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
TestContext.Out.WriteLine(
|
||||
$"focused {focusEdgeId}: detour={ElkEdgeRoutingScoring.CountExcessiveDetourViolations(focusedCandidate, elkNodes)} gateway-source={ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(focusedCandidate, elkNodes)} boundary-slots={ElkEdgeRoutingScoring.CountBoundarySlotViolations(focusedCandidate, elkNodes)} entry={ElkEdgeRoutingScoring.CountBadEntryAngles(focusedCandidate, elkNodes)} shared-lanes={ElkEdgeRoutingScoring.CountSharedLaneViolations(focusedCandidate, elkNodes)}");
|
||||
}
|
||||
|
||||
var subsetResults = new List<(string[] Focus, int Detour, int GatewaySource, int BoundarySlots, int Entry, int SharedLanes)>();
|
||||
for (var mask = 1; mask < (1 << combinedFocus.Length); mask++)
|
||||
{
|
||||
var subset = combinedFocus
|
||||
.Where((_, index) => (mask & (1 << index)) != 0)
|
||||
.ToArray();
|
||||
var subsetCandidate = ElkEdgePostProcessor.PreferShortestBoundaryShortcuts(elkEdges, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.RepairBoundaryAnglesAndTargetApproaches(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SpreadRectTargetApproachFeederBands(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.ElevateUnderNodeViolations(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.FinalizeDecisionTargetEntries(subsetCandidate, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.FinalizeGatewayBoundaryGeometry(subsetCandidate, elkNodes, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.PolishTargetPeerConflicts(subsetCandidate, elkNodes, minLineClearance, subset);
|
||||
subsetCandidate = ElkEdgePostProcessor.SnapBoundarySlotAssignments(
|
||||
subsetCandidate,
|
||||
elkNodes,
|
||||
minLineClearance,
|
||||
subset,
|
||||
enforceAllNodeEndpoints: true);
|
||||
|
||||
subsetResults.Add((
|
||||
subset,
|
||||
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountBoundarySlotViolations(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountBadEntryAngles(subsetCandidate, elkNodes),
|
||||
ElkEdgeRoutingScoring.CountSharedLaneViolations(subsetCandidate, elkNodes)));
|
||||
}
|
||||
|
||||
foreach (var result in subsetResults
|
||||
.OrderBy(item => item.BoundarySlots)
|
||||
.ThenBy(item => item.GatewaySource)
|
||||
.ThenBy(item => item.Detour)
|
||||
.ThenBy(item => item.Entry)
|
||||
.ThenBy(item => item.SharedLanes)
|
||||
.ThenBy(item => item.Focus.Length)
|
||||
.ThenBy(item => string.Join(",", item.Focus), StringComparer.Ordinal)
|
||||
.Take(12))
|
||||
{
|
||||
TestContext.Out.WriteLine(
|
||||
$"subset [{string.Join(", ", result.Focus)}]: detour={result.Detour} gateway-source={result.GatewaySource} boundary-slots={result.BoundarySlots} entry={result.Entry} shared-lanes={result.SharedLanes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
@@ -9,7 +10,7 @@ using StellaOps.Workflow.Renderer.Svg;
|
||||
namespace StellaOps.Workflow.Renderer.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class DocumentProcessingWorkflowRenderingTests
|
||||
public partial class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
private static WorkflowRenderGraph BuildDocumentProcessingWorkflowGraph()
|
||||
{
|
||||
@@ -85,451 +86,46 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLayoutOnly_ShouldProduceFinitePositions()
|
||||
private sealed class ElkPointComparer : IEqualityComparer<ElkPoint>
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
internal static readonly ElkPointComparer Instance = new();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
public bool Equals(ElkPoint? x, ElkPoint? y)
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||
Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True);
|
||||
var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d);
|
||||
var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d);
|
||||
var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d));
|
||||
var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d);
|
||||
var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d);
|
||||
var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d);
|
||||
var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
||||
var distinctLayerXs = visibleNodes
|
||||
.Select(node => node.X)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min();
|
||||
var minInLayerGap = visibleNodes
|
||||
.GroupBy(node => node.X)
|
||||
.Select(group =>
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
var ordered = group.OrderBy(node => node.Y).ToArray();
|
||||
if (ordered.Length < 2)
|
||||
{
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
return ordered
|
||||
.Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height))
|
||||
.Min();
|
||||
})
|
||||
.DefaultIfEmpty(double.MaxValue)
|
||||
.Min();
|
||||
Assert.That(
|
||||
minLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d),
|
||||
$"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width).");
|
||||
Assert.That(
|
||||
minInLayerGap,
|
||||
Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d),
|
||||
$"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == "start/2/branch-1/1/body/5");
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/7");
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[0], sourceNode),
|
||||
Is.EqualTo("right"),
|
||||
"Setting configParameters should leave from its east side toward Evaluate Conditions.");
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear.");
|
||||
var shortestDirectLength = 0d;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
{
|
||||
shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y);
|
||||
}
|
||||
|
||||
var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y);
|
||||
Assert.That(
|
||||
path.Min(point => point.Y),
|
||||
Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d),
|
||||
"Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear.");
|
||||
Assert.That(
|
||||
shortestDirectLength,
|
||||
Is.LessThanOrEqualTo(boundaryDirectLength + 48d),
|
||||
"Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge =>
|
||||
routedEdge.SourceNodeId == "start/3"
|
||||
&& routedEdge.TargetNodeId == "start/4/batched");
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
|
||||
Assert.That(
|
||||
HasTargetApproachBacktracking(edge, targetNode),
|
||||
Is.False,
|
||||
"Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry.");
|
||||
Assert.That(path.Count, Is.GreaterThanOrEqualTo(2));
|
||||
Assert.That(
|
||||
ResolveBoundarySide(path[^1], targetNode),
|
||||
Is.EqualTo("left"),
|
||||
"Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35");
|
||||
var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId);
|
||||
var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId);
|
||||
var path = FlattenPath(edge);
|
||||
var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d;
|
||||
|
||||
Assert.That(
|
||||
path.Max(point => point.Y),
|
||||
Is.LessThanOrEqualTo(maxAllowedY),
|
||||
"Local repeat-return lanes must not drop into a lower detour band when an upper return is available.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Category("RenderingArtifacts")]
|
||||
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
|
||||
{
|
||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var outputDir = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
using var diagnosticsCapture = ElkLayoutDiagnostics.BeginCapture();
|
||||
var progressLogPath = Path.Combine(outputDir, "elksharp.progress.log");
|
||||
if (File.Exists(progressLogPath))
|
||||
{
|
||||
File.Delete(progressLogPath);
|
||||
}
|
||||
|
||||
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
|
||||
if (File.Exists(diagnosticsPath))
|
||||
{
|
||||
File.Delete(diagnosticsPath);
|
||||
}
|
||||
|
||||
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var svgRenderer = new WorkflowRenderSvgRenderer();
|
||||
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
|
||||
|
||||
var svgPath = Path.Combine(outputDir, "elksharp.svg");
|
||||
await File.WriteAllTextAsync(svgPath, svgDoc.Svg);
|
||||
|
||||
var jsonPath = Path.Combine(outputDir, "elksharp.json");
|
||||
await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
diagnosticsPath,
|
||||
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
WorkflowRenderPngExporter? pngExporter = null;
|
||||
string? pngPath = null;
|
||||
try
|
||||
{
|
||||
pngPath = Path.Combine(outputDir, "elksharp.png");
|
||||
pngExporter = new WorkflowRenderPngExporter();
|
||||
await pngExporter.ExportAsync(svgDoc, pngPath, scale: 2f);
|
||||
TestContext.Out.WriteLine($"PNG generated at: {pngPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.Out.WriteLine($"PNG export failed (non-fatal): {ex.Message}");
|
||||
TestContext.Out.WriteLine($"SVG available at: {svgPath}");
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"SVG: {svgPath}");
|
||||
TestContext.Out.WriteLine($"JSON: {jsonPath}");
|
||||
TestContext.Out.WriteLine($"Diagnostics: {diagnosticsPath}");
|
||||
TestContext.Out.WriteLine($"Progress log: {progressLogPath}");
|
||||
|
||||
// Render every iteration of every strategy as SVG only
|
||||
var variantsDir = Path.Combine(outputDir, "strategy-variants");
|
||||
Directory.CreateDirectory(variantsDir);
|
||||
foreach (var stratDiag in diagnosticsCapture.Diagnostics.IterativeStrategies)
|
||||
{
|
||||
foreach (var attemptDiag in stratDiag.AttemptDetails)
|
||||
{
|
||||
if (attemptDiag.Edges is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attemptLayout = BuildVariantLayout(layout, attemptDiag.Edges);
|
||||
var sc = attemptDiag.Score;
|
||||
var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " +
|
||||
$"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " +
|
||||
$"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " +
|
||||
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
|
||||
$"sl={sc.SharedLaneViolations} " +
|
||||
$"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}";
|
||||
var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-att{attemptDiag.Attempt:D2}.svg"),
|
||||
attemptSvg.Svg);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stratDiag.BestEdges is not null)
|
||||
if (x is null || y is null)
|
||||
{
|
||||
var bestLayout = BuildVariantLayout(layout, stratDiag.BestEdges);
|
||||
var bestSc = stratDiag.BestScore;
|
||||
var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " +
|
||||
$"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " +
|
||||
$"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " +
|
||||
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
|
||||
$"sl={bestSc?.SharedLaneViolations} " +
|
||||
$"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}";
|
||||
var bestSvg = svgRenderer.Render(bestLayout, bestLabel);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(variantsDir, $"s{stratDiag.StrategyIndex:D2}-{stratDiag.OrderingName}-BEST.svg"),
|
||||
bestSvg.Svg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.Abs(x.X - y.X) <= 0.5d && Math.Abs(x.Y - y.Y) <= 0.5d;
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Strategy variants: {variantsDir}");
|
||||
|
||||
var localRepairAttempts = diagnosticsCapture.Diagnostics.IterativeStrategies
|
||||
.SelectMany(strategy => strategy.AttemptDetails)
|
||||
.Where(attempt => attempt.Attempt > 1
|
||||
&& string.Equals(attempt.RouteDiagnostics?.Mode, "local-repair", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(localRepairAttempts, Is.Not.Empty, "Expected later attempts to use targeted local repair.");
|
||||
Assert.That(
|
||||
localRepairAttempts.All(attempt => attempt.RouteDiagnostics!.RoutedEdges < attempt.RouteDiagnostics.TotalEdges),
|
||||
Is.True,
|
||||
"Local repair attempts must reroute only the penalized subset of edges.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated.");
|
||||
var boundaryAngleOffenders = layout.Edges
|
||||
.SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes))
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "<none>" : string.Join(", ", boundaryAngleOffenders))}");
|
||||
var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "<none>" : string.Join(", ", targetJoinOffenders))}");
|
||||
var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode
|
||||
public int GetHashCode(ElkPoint obj)
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
}).ToArray();
|
||||
var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge
|
||||
{
|
||||
Id = edge.Id,
|
||||
SourceNodeId = edge.SourceNodeId,
|
||||
TargetNodeId = edge.TargetNodeId,
|
||||
Kind = edge.Kind,
|
||||
Label = edge.Label,
|
||||
Sections = edge.Sections.Select(section => new ElkEdgeSection
|
||||
{
|
||||
StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y },
|
||||
EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y },
|
||||
BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes)
|
||||
.Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}")
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "<none>" : string.Join(", ", sharedLaneOffenders))}");
|
||||
var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "<none>" : string.Join(", ", belowGraphOffenders))}");
|
||||
var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "<none>" : string.Join(", ", underNodeOffenders))}");
|
||||
var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray();
|
||||
TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "<none>" : string.Join(", ", longDiagonalOffenders))}");
|
||||
var gatewaySourceScoringOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
|
||||
Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
|
||||
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted.");
|
||||
var gatewayCornerDiagonalCount = layout.Edges.Count(edge =>
|
||||
HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices.");
|
||||
var gatewayInteriorAdjacentCount = layout.Edges.Count(edge =>
|
||||
HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true)
|
||||
|| HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false));
|
||||
Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor.");
|
||||
var gatewaySourceCurlCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceCurlOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back.");
|
||||
var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceFaceMismatchOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face.");
|
||||
var gatewaySourceDetourCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes));
|
||||
var gatewaySourceDetourOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available.");
|
||||
var gatewaySourceVertexExitCount = layout.Edges.Count(edge =>
|
||||
HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)));
|
||||
var gatewaySourceVertexExitOffenders = layout.Edges
|
||||
.Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId)))
|
||||
.Select(edge => edge.Id)
|
||||
.ToArray();
|
||||
Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner.");
|
||||
Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused.");
|
||||
var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3");
|
||||
var processBatchLoops = layout.Edges
|
||||
.Where(edge => edge.TargetNodeId == "start/2/branch-1/1"
|
||||
&& edge.Label.StartsWith("repeat while", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
Assert.That(
|
||||
processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)),
|
||||
Is.True,
|
||||
"Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band.");
|
||||
|
||||
static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges)
|
||||
{
|
||||
return new WorkflowRenderLayoutResult
|
||||
{
|
||||
GraphId = baseLayout.GraphId,
|
||||
Nodes = baseLayout.Nodes,
|
||||
Edges = edges.Select(e => new WorkflowRenderRoutedEdge
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceNodeId = e.SourceNodeId,
|
||||
TargetNodeId = e.TargetNodeId,
|
||||
Kind = e.Kind,
|
||||
Label = e.Label,
|
||||
Sections = e.Sections.Select(s => new WorkflowRenderEdgeSection
|
||||
{
|
||||
StartPoint = new WorkflowRenderPoint { X = s.StartPoint.X, Y = s.StartPoint.Y },
|
||||
EndPoint = new WorkflowRenderPoint { X = s.EndPoint.X, Y = s.EndPoint.Y },
|
||||
BendPoints = s.BendPoints.Select(p => new WorkflowRenderPoint { X = p.X, Y = p.Y }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray(),
|
||||
};
|
||||
return HashCode.Combine(Math.Round(obj.X, 3), Math.Round(obj.Y, 3));
|
||||
}
|
||||
}
|
||||
|
||||
// Verify zero edge-node crossings
|
||||
var crossings = 0;
|
||||
foreach (var node in layout.Nodes)
|
||||
private static List<ElkPoint> ExtractElkPath(ElkRoutedEdge edge)
|
||||
{
|
||||
var path = new List<ElkPoint>();
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
foreach (var edge in layout.Edges)
|
||||
if (path.Count == 0)
|
||||
{
|
||||
if (edge.SourceNodeId == node.Id || edge.TargetNodeId == node.Id) continue;
|
||||
foreach (var section in edge.Sections)
|
||||
{
|
||||
var pts = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
pts.AddRange(section.BendPoints);
|
||||
pts.Add(section.EndPoint);
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
var p1 = pts[i];
|
||||
var p2 = pts[i + 1];
|
||||
if (Math.Abs(p1.Y - p2.Y) < 2 && p1.Y > node.Y && p1.Y < node.Y + node.Height)
|
||||
{
|
||||
if (Math.Max(p1.X, p2.X) > node.X && Math.Min(p1.X, p2.X) < node.X + node.Width)
|
||||
crossings++;
|
||||
}
|
||||
else if (Math.Abs(p1.X - p2.X) < 2 && p1.X > node.X && p1.X < node.X + node.Width)
|
||||
{
|
||||
if (Math.Max(p1.Y, p2.Y) > node.Y && Math.Min(p1.Y, p2.Y) < node.Y + node.Height)
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
path.Add(section.StartPoint);
|
||||
}
|
||||
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
}
|
||||
|
||||
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
|
||||
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<WorkflowRenderPoint> FlattenPath(WorkflowRenderRoutedEdge edge)
|
||||
@@ -560,7 +156,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
|
||||
if (targetNode.Kind is "Decision" or "Fork" or "Join")
|
||||
{
|
||||
return HasGatewayTargetApproachBacktracking(path);
|
||||
return HasGatewayTargetApproachBacktracking(path, targetNode);
|
||||
}
|
||||
|
||||
var side = ResolveBoundarySide(path[^1], targetNode);
|
||||
@@ -689,13 +285,20 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList<WorkflowRenderPoint> path)
|
||||
private static bool HasGatewayTargetApproachBacktracking(
|
||||
IReadOnlyList<WorkflowRenderPoint> path,
|
||||
WorkflowRenderPositionedNode targetNode)
|
||||
{
|
||||
if (path.Count < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasShortGatewayTargetOrthogonalHook(path, targetNode))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var startIndex = Math.Max(0, path.Count - 4);
|
||||
var nearEnd = path.Skip(startIndex).ToArray();
|
||||
@@ -757,6 +360,39 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasShortGatewayTargetOrthogonalHook(
|
||||
IReadOnlyList<WorkflowRenderPoint> path,
|
||||
WorkflowRenderPositionedNode targetNode)
|
||||
{
|
||||
if (path.Count < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double tolerance = 0.5d;
|
||||
var boundaryPoint = path[^1];
|
||||
var exteriorPoint = path[^2];
|
||||
var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X);
|
||||
var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y);
|
||||
var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance;
|
||||
var finalIsVertical = finalDy > tolerance && finalDx <= tolerance;
|
||||
if (!finalIsHorizontal && !finalIsVertical)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var finalStubLength = finalIsHorizontal ? finalDx : finalDy;
|
||||
var requiredDepth = Math.Min(targetNode.Width, targetNode.Height);
|
||||
var predecessor = path[^3];
|
||||
var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X);
|
||||
var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y);
|
||||
const double minimumApproachSpan = 24d;
|
||||
return finalStubLength + tolerance < requiredDepth
|
||||
&& (finalIsHorizontal
|
||||
? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d
|
||||
: predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetTargetApproachJoinOffenders(
|
||||
IReadOnlyCollection<WorkflowRenderRoutedEdge> edges,
|
||||
IReadOnlyCollection<WorkflowRenderPositionedNode> nodes)
|
||||
@@ -775,36 +411,51 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetEdges = group.ToArray();
|
||||
for (var i = 0; i < targetEdges.Length; i++)
|
||||
var elkTargetNode = new ElkPositionedNode
|
||||
{
|
||||
var leftPath = ExtractPath(targetEdges[i]);
|
||||
if (leftPath.Count < 2)
|
||||
Id = targetNode.Id,
|
||||
Label = targetNode.Label,
|
||||
Kind = targetNode.Kind,
|
||||
X = targetNode.X,
|
||||
Y = targetNode.Y,
|
||||
Width = targetNode.Width,
|
||||
Height = targetNode.Height,
|
||||
};
|
||||
var sideGroups = group
|
||||
.Select(edge => new
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var leftSide = ResolveTargetApproachJoinSide(leftPath, targetNode);
|
||||
for (var j = i + 1; j < targetEdges.Length; j++)
|
||||
Edge = edge,
|
||||
Path = ExtractPath(edge),
|
||||
})
|
||||
.Where(entry => entry.Path.Count >= 2)
|
||||
.Select(entry => new
|
||||
{
|
||||
var rightPath = ExtractPath(targetEdges[j]);
|
||||
if (rightPath.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entry.Edge,
|
||||
entry.Path,
|
||||
Side = ResolveTargetApproachJoinSide(entry.Path, targetNode),
|
||||
})
|
||||
.GroupBy(entry => entry.Side, StringComparer.Ordinal);
|
||||
|
||||
var rightSide = ResolveTargetApproachJoinSide(rightPath, targetNode);
|
||||
if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var sideGroup in sideGroups)
|
||||
{
|
||||
var sideEntries = sideGroup.ToArray();
|
||||
var requiredGap = ElkBoundarySlots.ResolveRequiredBoundarySlotGap(
|
||||
elkTargetNode,
|
||||
sideGroup.Key,
|
||||
sideEntries.Length,
|
||||
minLineClearance);
|
||||
|
||||
if (!HasTargetApproachJoin(leftPath, rightPath, minLineClearance, 3))
|
||||
for (var i = 0; i < sideEntries.Length; i++)
|
||||
{
|
||||
for (var j = i + 1; j < sideEntries.Length; j++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!HasTargetApproachJoin(sideEntries[i].Path, sideEntries[j].Path, requiredGap, 3))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return $"{targetEdges[i].Id}+{targetEdges[j].Id}@{targetNode.Id}/{leftSide}";
|
||||
yield return $"{sideEntries[i].Edge.Id}+{sideEntries[j].Edge.Id}@{targetNode.Id}/{sideGroup.Key}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -833,6 +484,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
double minLineClearance,
|
||||
int maxSegmentsFromEnd)
|
||||
{
|
||||
var effectiveClearance = Math.Max(0d, minLineClearance - 0.5d);
|
||||
var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd);
|
||||
var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd);
|
||||
|
||||
@@ -840,7 +492,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
{
|
||||
foreach (var rightSegment in rightSegments)
|
||||
{
|
||||
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, minLineClearance))
|
||||
if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, effectiveClearance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1555,7 +1207,7 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
}
|
||||
|
||||
var averageShapeSize = (serviceNodes.Average(node => node.Width) + serviceNodes.Average(node => node.Height)) / 2d;
|
||||
var maxDiagonalLength = Math.Max(96d, averageShapeSize * 2d);
|
||||
var maxDiagonalLength = averageShapeSize;
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
@@ -1659,4 +1311,4 @@ public class DocumentProcessingWorkflowRenderingTests
|
||||
|
||||
return ResolveBoundarySide(boundaryPoint, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Workflow.Abstractions;
|
||||
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||
|
||||
@@ -149,6 +150,174 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
lowerEdge.StartPoint.Y.Should().BeGreaterThanOrEqualTo(sourceCenterY);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenDecisionSourceExitsTowardLowerBranch_ShouldUseDiagonalGatewayExit()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "decision-exit-diagonal",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "source",
|
||||
Label = "Source",
|
||||
Kind = "Decision",
|
||||
Width = 144,
|
||||
Height = 120,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "upper",
|
||||
Label = "Upper",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "lower",
|
||||
Label = "Lower",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-upper",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "upper",
|
||||
Label = "when true",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "e-lower",
|
||||
SourceNodeId = "source",
|
||||
TargetNodeId = "lower",
|
||||
Label = "otherwise",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var source = result.Nodes.Single(x => x.Id == "source");
|
||||
var lowerSection = result.Edges.Single(x => x.Id == "e-lower").Sections.Single();
|
||||
var lowerPath = BuildPath(lowerSection);
|
||||
var exitPoint = lowerPath[0];
|
||||
var nextPoint = lowerPath[1];
|
||||
|
||||
Math.Abs(nextPoint.X - exitPoint.X).Should().BeGreaterThan(3d);
|
||||
Math.Abs(nextPoint.Y - exitPoint.Y).Should().BeGreaterThan(3d);
|
||||
AssertDiamondBoundaryPoint(exitPoint, source);
|
||||
ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(ToElkNode(source), new ElkPoint { X = nextPoint.X, Y = nextPoint.Y })
|
||||
.Should()
|
||||
.BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenDecisionTargetIsReachedOffAxis_ShouldUseDiagonalGatewayEntry()
|
||||
{
|
||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||
var graph = new WorkflowRenderGraph
|
||||
{
|
||||
Id = "decision-entry-diagonal",
|
||||
Nodes =
|
||||
[
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "upper",
|
||||
Label = "Upper",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "lower",
|
||||
Label = "Lower",
|
||||
Kind = "SetState",
|
||||
Width = 180,
|
||||
Height = 84,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "gate",
|
||||
Label = "Gate",
|
||||
Kind = "Decision",
|
||||
Width = 156,
|
||||
Height = 132,
|
||||
},
|
||||
new WorkflowRenderNode
|
||||
{
|
||||
Id = "end",
|
||||
Label = "End",
|
||||
Kind = "End",
|
||||
Width = 80,
|
||||
Height = 40,
|
||||
},
|
||||
],
|
||||
Edges =
|
||||
[
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "upper-gate",
|
||||
SourceNodeId = "upper",
|
||||
TargetNodeId = "gate",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "lower-gate",
|
||||
SourceNodeId = "lower",
|
||||
TargetNodeId = "gate",
|
||||
},
|
||||
new WorkflowRenderEdge
|
||||
{
|
||||
Id = "gate-end",
|
||||
SourceNodeId = "gate",
|
||||
TargetNodeId = "end",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var result = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||
{
|
||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||
});
|
||||
|
||||
var gate = result.Nodes.Single(x => x.Id == "gate");
|
||||
var incomingSections = result.Edges
|
||||
.Where(x => x.TargetNodeId == "gate")
|
||||
.Select(x => x.Sections.Single())
|
||||
.ToArray();
|
||||
|
||||
var incomingPaths = incomingSections
|
||||
.Select(BuildPath)
|
||||
.ToArray();
|
||||
|
||||
incomingPaths.All(path => IsPointOnDiamondBoundary(path[^1], gate)).Should().BeTrue();
|
||||
incomingPaths.Any(path =>
|
||||
{
|
||||
var prev = path[^2];
|
||||
var end = path[^1];
|
||||
return Math.Abs(end.X - prev.X) > 3d
|
||||
&& Math.Abs(end.Y - prev.Y) > 3d;
|
||||
}).Should().BeTrue();
|
||||
incomingPaths.All(path =>
|
||||
!ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(
|
||||
ToElkNode(gate),
|
||||
new ElkPoint { X = path[^2].X, Y = path[^2].Y }))
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LayoutAsync_WhenSameLaneStateBoxesConnected_ShouldAnchorToBoxBorders()
|
||||
{
|
||||
@@ -940,22 +1109,22 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
.ToArray();
|
||||
|
||||
var outerLoopEdges = loopEdges
|
||||
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
|
||||
.Where(section => BuildPath(section).Any(point => point.Y < section.EndPoint.Y - 1d))
|
||||
.ToArray();
|
||||
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
|
||||
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
|
||||
outerLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 4);
|
||||
|
||||
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
|
||||
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
|
||||
directLoopEdges.Should().OnlyContain(section => BuildPath(section).Count >= 2);
|
||||
|
||||
var collectorX = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Max(point => point.X))
|
||||
.Select(section => BuildPath(section).Max(point => point.X))
|
||||
.DistinctBy(x => Math.Round(x, 2))
|
||||
.ToArray();
|
||||
collectorX.Should().HaveCount(1);
|
||||
collectorX.Should().HaveCountLessOrEqualTo(2);
|
||||
|
||||
var outerLaneYs = outerLoopEdges
|
||||
.Select(section => section.BendPoints.Min(point => point.Y))
|
||||
.Select(section => BuildPath(section).Min(point => point.Y))
|
||||
.OrderBy(y => y)
|
||||
.ToArray();
|
||||
outerLaneYs.Should().OnlyHaveUniqueItems();
|
||||
@@ -977,4 +1146,40 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
|
||||
|
||||
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WorkflowRenderPoint> BuildPath(WorkflowRenderEdgeSection section)
|
||||
{
|
||||
var path = new List<WorkflowRenderPoint> { section.StartPoint };
|
||||
path.AddRange(section.BendPoints);
|
||||
path.Add(section.EndPoint);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void AssertDiamondBoundaryPoint(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
|
||||
{
|
||||
IsPointOnDiamondBoundary(point, node).Should().BeTrue();
|
||||
}
|
||||
|
||||
private static bool IsPointOnDiamondBoundary(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
|
||||
{
|
||||
var centerX = node.X + (node.Width / 2d);
|
||||
var centerY = node.Y + (node.Height / 2d);
|
||||
var normalized = (Math.Abs(point.X - centerX) / Math.Max(node.Width / 2d, 0.001d))
|
||||
+ (Math.Abs(point.Y - centerY) / Math.Max(node.Height / 2d, 0.001d));
|
||||
return Math.Abs(normalized - 1d) <= 0.08d;
|
||||
}
|
||||
|
||||
private static ElkPositionedNode ToElkNode(WorkflowRenderPositionedNode node)
|
||||
{
|
||||
return new ElkPositionedNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Label,
|
||||
Kind = node.Kind,
|
||||
X = node.X,
|
||||
Y = node.Y,
|
||||
Width = node.Width,
|
||||
Height = node.Height,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user