elksharp stabilization

This commit is contained in:
master
2026-03-24 08:38:09 +02:00
parent d788ee757e
commit 71edccd485
18 changed files with 6083 additions and 36 deletions

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using NUnit.Framework;
using StellaOps.ElkSharp;
using StellaOps.Workflow.Abstractions;
using StellaOps.Workflow.Renderer.ElkSharp;
using StellaOps.Workflow.Renderer.Svg;
@@ -101,8 +102,7 @@ public class DocumentProcessingWorkflowRenderingTests
}
[Test]
[Category("RenderingArtifacts")]
public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings()
public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotBacktrackIntoCheckResult()
{
var graph = BuildDocumentProcessingWorkflowGraph();
var engine = new ElkSharpWorkflowRenderLayoutEngine();
@@ -112,20 +112,53 @@ public class DocumentProcessingWorkflowRenderingTests
Direction = WorkflowRenderLayoutDirection.LeftToRight,
});
var svgRenderer = new WorkflowRenderSvgRenderer();
var svgDoc = svgRenderer.Render(layout, "DocumentProcessingWorkflow [ElkSharp]");
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]
[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);
}
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
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 }));
var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json");
await File.WriteAllTextAsync(
diagnosticsPath,
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
WorkflowRenderPngExporter? pngExporter = null;
string? pngPath = null;
try
@@ -143,6 +176,88 @@ public class DocumentProcessingWorkflowRenderingTests
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} " +
$"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " +
$"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} " +
$"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " +
$"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.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins.");
Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach.");
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;
@@ -178,4 +293,90 @@ public class DocumentProcessingWorkflowRenderingTests
TestContext.Out.WriteLine($"Edge-node crossings: {crossings}");
Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes");
}
private static bool HasTargetApproachBacktracking(WorkflowRenderRoutedEdge edge, WorkflowRenderPositionedNode targetNode)
{
var path = new List<WorkflowRenderPoint>();
foreach (var section in edge.Sections)
{
if (path.Count == 0)
{
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
if (path.Count < 3)
{
return false;
}
var side = ResolveBoundarySide(path[^1], targetNode);
if (side is not "left" and not "right" and not "top" and not "bottom")
{
return false;
}
const double tolerance = 0.5d;
var startIndex = Math.Max(0, path.Count - 5);
var axisValues = new List<double>(path.Count - startIndex);
for (var i = startIndex; i < path.Count; i++)
{
var value = side is "left" or "right"
? path[i].X
: path[i].Y;
if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance)
{
axisValues.Add(value);
}
}
if (axisValues.Count < 3)
{
return false;
}
var targetAxis = side switch
{
"left" => targetNode.X,
"right" => targetNode.X + targetNode.Width,
"top" => targetNode.Y,
"bottom" => targetNode.Y + targetNode.Height,
_ => double.NaN,
};
return side switch
{
"left" or "top" => axisValues.Any(value => value > targetAxis + tolerance),
"right" or "bottom" => axisValues.Any(value => value < targetAxis - tolerance),
_ => false,
};
}
private static string ResolveBoundarySide(WorkflowRenderPoint point, WorkflowRenderPositionedNode node)
{
var left = Math.Abs(point.X - node.X);
var right = Math.Abs(point.X - (node.X + node.Width));
var top = Math.Abs(point.Y - node.Y);
var bottom = Math.Abs(point.Y - (node.Y + node.Height));
var min = Math.Min(Math.Min(left, right), Math.Min(top, bottom));
if (Math.Abs(min - left) < 0.5d)
{
return "left";
}
if (Math.Abs(min - right) < 0.5d)
{
return "right";
}
if (Math.Abs(min - top) < 0.5d)
{
return "top";
}
return "bottom";
}
}

View File

@@ -714,12 +714,14 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
var successTwo = result.Edges.Single(edge => edge.Id == "success-2").Sections.Single();
var failure = result.Edges.Single(edge => edge.Id == "failure-1").Sections.Single();
// All three edges should reach the end node with distinct approach Y values
var successBundleYOne = ResolvePreTargetBundleY(successOne);
var successBundleYTwo = ResolvePreTargetBundleY(successTwo);
var failureBundleY = ResolvePreTargetBundleY(failure);
successBundleYOne.Should().BeApproximately(successBundleYTwo, 0.01d);
failureBundleY.Should().NotBeApproximately(successBundleYOne, 0.01d);
var allApproachYs = new[] { successBundleYOne, successBundleYTwo, failureBundleY };
allApproachYs.Should().OnlyHaveUniqueItems(
"each edge should approach the target at a distinct Y coordinate");
}
[Test]
@@ -841,7 +843,7 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
}
[Test]
public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldUseSharedSourceCollectorColumn()
public async Task LayoutAsync_WhenBackwardFamilySharesTarget_ShouldStackOuterCollectorLanes()
{
var engine = new ElkSharpWorkflowRenderLayoutEngine();
var graph = new WorkflowRenderGraph
@@ -937,22 +939,42 @@ public class ElkSharpWorkflowRenderLayoutEngineTests
.Select(edge => edge.Sections.Single())
.ToArray();
loopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
var sharedCollectorX = loopEdges[0].BendPoints.ElementAt(0).X;
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(0).X - sharedCollectorX) <= 0.01d);
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).X - sharedCollectorX) <= 0.01d);
var sharedCorridorY = loopEdges[0].BendPoints.ElementAt(1).Y;
loopEdges.Should().OnlyContain(section => Math.Abs(section.BendPoints.ElementAt(1).Y - sharedCorridorY) <= 0.01d);
var outerLoopEdges = loopEdges
.Where(section => section.BendPoints.Min(point => point.Y) < section.EndPoint.Y - 1d)
.ToArray();
outerLoopEdges.Should().HaveCountGreaterOrEqualTo(2);
outerLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 3);
var directLoopEdges = loopEdges.Except(outerLoopEdges).ToArray();
directLoopEdges.Should().OnlyContain(section => section.BendPoints.Count >= 2);
var collectorX = outerLoopEdges
.Select(section => section.BendPoints.Max(point => point.X))
.DistinctBy(x => Math.Round(x, 2))
.ToArray();
collectorX.Should().HaveCount(1);
var outerLaneYs = outerLoopEdges
.Select(section => section.BendPoints.Min(point => point.Y))
.OrderBy(y => y)
.ToArray();
outerLaneYs.Should().OnlyHaveUniqueItems();
outerLaneYs.Should().BeInAscendingOrder();
}
private static double ResolvePreTargetBundleY(WorkflowRenderEdgeSection section)
{
if (section.BendPoints.Count == 0)
{
return section.StartPoint.Y;
}
var preTargetX = section.BendPoints.Max(point => point.X);
var bundlePoint = section.BendPoints
.Where(point => Math.Abs(point.X - preTargetX) <= 0.01d && Math.Abs(point.Y - section.EndPoint.Y) > 0.01d)
.OrderBy(point => point.Y)
.First();
.FirstOrDefault();
return bundlePoint.Y;
return bundlePoint?.Y ?? section.BendPoints.Last().Y;
}
}