Add ElkSharp hybrid routing and edge refinement tests
- Hybrid iterative routing parity tests (deterministic replay, Sugiyama stability, no regression in violation counts) - Edge refinement tests for hybrid mode - Document processing scenario updates for rendering changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,54 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
{
|
{
|
||||||
var graph = BuildDocumentProcessingWorkflowGraph();
|
var graph = BuildDocumentProcessingWorkflowGraph();
|
||||||
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
var engine = new ElkSharpWorkflowRenderLayoutEngine();
|
||||||
|
var outputDir = Path.Combine(
|
||||||
|
Path.GetDirectoryName(typeof(DocumentProcessingWorkflowRenderingTests).Assembly.Location)!,
|
||||||
|
"TestResults", "workflow-renderings", DateTime.Today.ToString("yyyyMMdd"), "DocumentProcessingWorkflow", "layout-only");
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
var captureDiagnostics = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("STELLAOPS_ELKSHARP_CAPTURE_LAYOUT_DIAGNOSTICS"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var diagnosticsCapture = captureDiagnostics
|
||||||
|
? ElkLayoutDiagnostics.BeginCapture()
|
||||||
|
: null;
|
||||||
|
if (diagnosticsCapture is not null)
|
||||||
|
{
|
||||||
|
diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath;
|
||||||
|
diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath;
|
||||||
|
}
|
||||||
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest
|
||||||
{
|
{
|
||||||
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
});
|
});
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
if (diagnosticsCapture is not null)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
diagnosticsPath,
|
||||||
|
JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
TestContext.Out.WriteLine($"Layout-only wall time: {stopwatch.Elapsed.TotalSeconds:F2}s");
|
||||||
|
if (diagnosticsCapture is not null)
|
||||||
|
{
|
||||||
|
TestContext.Out.WriteLine($"Layout-only diagnostics: {diagnosticsPath}");
|
||||||
|
TestContext.Out.WriteLine($"Layout-only progress log: {progressLogPath}");
|
||||||
|
}
|
||||||
|
|
||||||
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
Assert.That(layout.Nodes.Count, Is.EqualTo(24));
|
||||||
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
Assert.That(layout.Edges.Count, Is.EqualTo(36));
|
||||||
@@ -926,4 +969,4 @@ public partial class DocumentProcessingWorkflowRenderingTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using FluentAssertions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
using StellaOps.ElkSharp;
|
||||||
|
|
||||||
|
namespace StellaOps.Workflow.Renderer.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public partial class ElkSharpEdgeRefinementTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Property("Intent", "Operational")]
|
||||||
|
public async Task LayoutAsync_WhenHybridDeterministicModeRenderedTwice_ShouldProduceDeterministicGeometry()
|
||||||
|
{
|
||||||
|
var graph = BuildElkSharpStressGraph();
|
||||||
|
var engine = new ElkSharpLayeredLayoutEngine();
|
||||||
|
var options = CreateHybridOptions();
|
||||||
|
|
||||||
|
var first = await engine.LayoutAsync(graph, options);
|
||||||
|
var second = await engine.LayoutAsync(graph, options);
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(first).Should().Be(JsonSerializer.Serialize(second));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Property("Intent", "Operational")]
|
||||||
|
public async Task LayoutAsync_WhenHybridDeterministicModeEnabled_ShouldKeepNodeGeometryStableAgainstLegacy()
|
||||||
|
{
|
||||||
|
var graph = BuildElkSharpStressGraph();
|
||||||
|
var engine = new ElkSharpLayeredLayoutEngine();
|
||||||
|
|
||||||
|
var legacy = await engine.LayoutAsync(graph, CreateLegacyOptions());
|
||||||
|
var hybrid = await engine.LayoutAsync(graph, CreateHybridOptions());
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(legacy.Nodes.OrderBy(node => node.Id, StringComparer.Ordinal).ToArray())
|
||||||
|
.Should()
|
||||||
|
.Be(JsonSerializer.Serialize(hybrid.Nodes.OrderBy(node => node.Id, StringComparer.Ordinal).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Property("Intent", "Operational")]
|
||||||
|
public async Task LayoutAsync_WhenHybridDeterministicModeEnabled_ShouldNotIncreasePrimaryViolationsAgainstLegacy()
|
||||||
|
{
|
||||||
|
var graph = BuildElkSharpStressGraph();
|
||||||
|
var engine = new ElkSharpLayeredLayoutEngine();
|
||||||
|
|
||||||
|
var legacy = await engine.LayoutAsync(graph, CreateLegacyOptions());
|
||||||
|
var hybrid = await engine.LayoutAsync(graph, CreateHybridOptions());
|
||||||
|
|
||||||
|
CountPrimaryViolations(hybrid).Should().BeLessThanOrEqualTo(CountPrimaryViolations(legacy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElkLayoutOptions CreateLegacyOptions()
|
||||||
|
{
|
||||||
|
return new ElkLayoutOptions
|
||||||
|
{
|
||||||
|
Direction = ElkLayoutDirection.LeftToRight,
|
||||||
|
Effort = ElkLayoutEffort.Best,
|
||||||
|
OrderingIterations = 18,
|
||||||
|
PlacementIterations = 10,
|
||||||
|
IterativeRouting = new IterativeRoutingOptions
|
||||||
|
{
|
||||||
|
Mode = IterativeRoutingMode.LegacyMultiStrategy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElkLayoutOptions CreateHybridOptions()
|
||||||
|
{
|
||||||
|
return new ElkLayoutOptions
|
||||||
|
{
|
||||||
|
Direction = ElkLayoutDirection.LeftToRight,
|
||||||
|
Effort = ElkLayoutEffort.Best,
|
||||||
|
OrderingIterations = 18,
|
||||||
|
PlacementIterations = 10,
|
||||||
|
IterativeRouting = new IterativeRoutingOptions
|
||||||
|
{
|
||||||
|
Mode = IterativeRoutingMode.HybridDeterministic,
|
||||||
|
MaxRepairWaves = 4,
|
||||||
|
MaxParallelRepairBuilds = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountPrimaryViolations(ElkLayoutResult layout)
|
||||||
|
{
|
||||||
|
var edges = layout.Edges.ToArray();
|
||||||
|
var nodes = layout.Nodes.ToArray();
|
||||||
|
|
||||||
|
return ElkEdgeRouterHighway.DetectRemainingBrokenHighways(edges, nodes).Count
|
||||||
|
+ ElkEdgeRoutingScoring.CountRepeatCollectorCorridorViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountRepeatCollectorNodeClearanceViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountSharedLaneViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountBelowGraphViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountUnderNodeViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountLongDiagonalViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountBadBoundaryAngles(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(edges, nodes)
|
||||||
|
+ ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
using StellaOps.ElkSharp;
|
||||||
|
using StellaOps.Workflow.Abstractions;
|
||||||
|
using StellaOps.Workflow.Renderer.ElkSharp;
|
||||||
|
|
||||||
|
namespace StellaOps.Workflow.Renderer.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ElkSharpWorkflowRenderLayoutEngineIterativeRoutingTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task LayoutAsync_WhenLeftToRightBest_ShouldEnableHybridDeterministicIterativeRouting()
|
||||||
|
{
|
||||||
|
var captureEngine = new CaptureElkLayoutEngine();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine(captureEngine);
|
||||||
|
|
||||||
|
await engine.LayoutAsync(
|
||||||
|
BuildGraph(),
|
||||||
|
new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.LeftToRight,
|
||||||
|
Effort = WorkflowRenderLayoutEffort.Best,
|
||||||
|
});
|
||||||
|
|
||||||
|
captureEngine.LastOptions.Should().NotBeNull();
|
||||||
|
captureEngine.LastOptions!.Direction.Should().Be(ElkLayoutDirection.LeftToRight);
|
||||||
|
captureEngine.LastOptions.Effort.Should().Be(ElkLayoutEffort.Best);
|
||||||
|
captureEngine.LastOptions.IterativeRouting.Should().NotBeNull();
|
||||||
|
captureEngine.LastOptions.IterativeRouting!.Enabled.Should().BeTrue();
|
||||||
|
captureEngine.LastOptions.IterativeRouting.Mode.Should().Be(IterativeRoutingMode.HybridDeterministic);
|
||||||
|
captureEngine.LastOptions.IterativeRouting.MaxRepairWaves.Should().Be(1);
|
||||||
|
captureEngine.LastOptions.IterativeRouting.MaxParallelRepairBuilds.Should().Be(Math.Max(1, Environment.ProcessorCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LayoutAsync_WhenTopToBottomBest_ShouldNotForceHybridIterativeRouting()
|
||||||
|
{
|
||||||
|
var captureEngine = new CaptureElkLayoutEngine();
|
||||||
|
var engine = new ElkSharpWorkflowRenderLayoutEngine(captureEngine);
|
||||||
|
|
||||||
|
await engine.LayoutAsync(
|
||||||
|
BuildGraph(),
|
||||||
|
new WorkflowRenderLayoutRequest
|
||||||
|
{
|
||||||
|
Direction = WorkflowRenderLayoutDirection.TopToBottom,
|
||||||
|
Effort = WorkflowRenderLayoutEffort.Best,
|
||||||
|
});
|
||||||
|
|
||||||
|
captureEngine.LastOptions.Should().NotBeNull();
|
||||||
|
captureEngine.LastOptions!.Direction.Should().Be(ElkLayoutDirection.TopToBottom);
|
||||||
|
captureEngine.LastOptions.IterativeRouting.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowRenderGraph BuildGraph()
|
||||||
|
{
|
||||||
|
return new WorkflowRenderGraph
|
||||||
|
{
|
||||||
|
Id = "capture",
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
new WorkflowRenderNode
|
||||||
|
{
|
||||||
|
Id = "start",
|
||||||
|
Label = "Start",
|
||||||
|
Kind = "Start",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Edges = [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CaptureElkLayoutEngine : IElkLayoutEngine
|
||||||
|
{
|
||||||
|
public ElkLayoutOptions? LastOptions { get; private set; }
|
||||||
|
|
||||||
|
public Task<ElkLayoutResult> LayoutAsync(
|
||||||
|
ElkGraph graph,
|
||||||
|
ElkLayoutOptions? options = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
LastOptions = options;
|
||||||
|
return Task.FromResult(new ElkLayoutResult
|
||||||
|
{
|
||||||
|
GraphId = graph.Id,
|
||||||
|
Nodes = graph.Nodes.Select(node => new ElkPositionedNode
|
||||||
|
{
|
||||||
|
Id = node.Id,
|
||||||
|
Label = node.Label,
|
||||||
|
Kind = node.Kind,
|
||||||
|
X = 0d,
|
||||||
|
Y = 0d,
|
||||||
|
Width = node.Width,
|
||||||
|
Height = node.Height,
|
||||||
|
}).ToArray(),
|
||||||
|
Edges = [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user