From 5722d36c0e27f1796bb9db4bbcf67c6ee0d257fe Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 17:25:28 +0300 Subject: [PATCH] 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) --- ...cessingWorkflowRenderingTests.Scenarios.cs | 45 +++++++- .../ElkSharpEdgeRefinementTests.Hybrid.cs | 106 ++++++++++++++++++ ...enderLayoutEngine.IterativeRoutingTests.cs | 101 +++++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Hybrid.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngine.IterativeRoutingTests.cs diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs index 36aebaf63..3d563158c 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Scenarios.cs @@ -16,11 +16,54 @@ public partial class DocumentProcessingWorkflowRenderingTests { 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", "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 { 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.Edges.Count, Is.EqualTo(36)); @@ -926,4 +969,4 @@ public partial class DocumentProcessingWorkflowRenderingTests } } -} \ No newline at end of file +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Hybrid.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Hybrid.cs new file mode 100644 index 000000000..284a66697 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpEdgeRefinementTests.Hybrid.cs @@ -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); + } +} diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngine.IterativeRoutingTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngine.IterativeRoutingTests.cs new file mode 100644 index 000000000..46925f3a6 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/ElkSharpWorkflowRenderLayoutEngine.IterativeRoutingTests.cs @@ -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 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 = [], + }); + } + } +}