Add ElkSharp compound node support

This commit is contained in:
master
2026-03-28 13:36:52 +02:00
parent 7748c75934
commit 717316d5a0
9 changed files with 1451 additions and 8 deletions

View File

@@ -0,0 +1,289 @@
using FluentAssertions;
using NUnit.Framework;
using StellaOps.ElkSharp;
namespace StellaOps.Workflow.Renderer.Tests;
[TestFixture]
public class ElkSharpCompoundLayoutTests
{
[Test]
public async Task LayoutAsync_WhenCompoundParentIsUsedAsEdgeEndpoint_ShouldRejectTheGraph()
{
var engine = new ElkSharpLayeredLayoutEngine();
var graph = new ElkGraph
{
Id = "invalid-compound-endpoint",
Nodes =
[
CreateNode("group", "Group", width: 260, height: 180),
CreateNode("child", "Child", parentNodeId: "group"),
CreateNode("peer", "Peer"),
],
Edges =
[
new ElkEdge
{
Id = "e1",
SourceNodeId = "group",
TargetNodeId = "peer",
},
],
};
var act = async () => await engine.LayoutAsync(graph);
await act.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage("*leaf nodes only*");
}
[Test]
public async Task LayoutAsync_WhenSiblingsShareAParent_ShouldKeepThemAdjacentInLayerOrdering()
{
var engine = new ElkSharpLayeredLayoutEngine();
var graph = new ElkGraph
{
Id = "compound-adjacency",
Nodes =
[
CreateNode("start", "Start", kind: "Start", width: 90, height: 48),
CreateNode("group", "Group", width: 260, height: 190),
CreateNode("child-a", "Child A", parentNodeId: "group"),
CreateNode("child-b", "Child B", parentNodeId: "group"),
CreateNode("peer", "Peer"),
],
Edges =
[
CreateEdge("e-a", "start", "child-a"),
CreateEdge("e-b", "start", "child-b"),
CreateEdge("e-peer", "start", "peer"),
],
};
var result = await engine.LayoutAsync(graph);
var orderedLayer = result.Nodes
.Where(node => node.Id is "child-a" or "child-b" or "peer")
.OrderBy(node => node.Y)
.Select(node => node.Id)
.ToArray();
Math.Abs(Array.IndexOf(orderedLayer, "child-a") - Array.IndexOf(orderedLayer, "child-b"))
.Should()
.Be(1);
}
[Test]
public async Task LayoutAsync_WhenCompoundParentWrapsChildren_ShouldRespectHeaderPaddingAndMinimumSize()
{
var engine = new ElkSharpLayeredLayoutEngine();
var options = new ElkLayoutOptions
{
CompoundPadding = 24,
CompoundHeaderHeight = 26,
};
var graph = new ElkGraph
{
Id = "compound-bounds",
Nodes =
[
CreateNode("group", "Group", width: 220, height: 180),
CreateNode("a", "A", parentNodeId: "group"),
CreateNode("b", "B", parentNodeId: "group"),
],
Edges =
[
CreateEdge("e1", "a", "b"),
],
};
var result = await engine.LayoutAsync(graph, options);
var group = result.Nodes.Single(node => node.Id == "group");
var children = result.Nodes.Where(node => node.ParentNodeId == "group").ToArray();
var childMinX = children.Min(node => node.X);
var childMaxX = children.Max(node => node.X + node.Width);
var childMinY = children.Min(node => node.Y);
var childMaxY = children.Max(node => node.Y + node.Height);
(childMinX - group.X).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
(childMinY - group.Y).Should().BeGreaterThanOrEqualTo(options.CompoundPadding + options.CompoundHeaderHeight - 1d);
((group.X + group.Width) - childMaxX).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
((group.Y + group.Height) - childMaxY).Should().BeGreaterThanOrEqualTo(options.CompoundPadding - 1d);
group.Width.Should().BeGreaterThanOrEqualTo(220d);
group.Height.Should().BeGreaterThanOrEqualTo(180d);
}
[Test]
public async Task LayoutAsync_WhenNestedCompoundsExist_ShouldWrapNestedParentsBottomUp()
{
var engine = new ElkSharpLayeredLayoutEngine();
var options = new ElkLayoutOptions
{
CompoundPadding = 20,
CompoundHeaderHeight = 24,
};
var graph = new ElkGraph
{
Id = "nested-compounds",
Nodes =
[
CreateNode("outer", "Outer", width: 260, height: 200),
CreateNode("inner", "Inner", width: 220, height: 180, parentNodeId: "outer"),
CreateNode("inner-a", "Inner A", parentNodeId: "inner"),
CreateNode("inner-b", "Inner B", parentNodeId: "inner"),
CreateNode("outer-leaf", "Outer Leaf", parentNodeId: "outer"),
],
Edges =
[
CreateEdge("e1", "outer-leaf", "inner-a"),
CreateEdge("e2", "inner-a", "inner-b"),
],
};
var result = await engine.LayoutAsync(graph, options);
var outer = result.Nodes.Single(node => node.Id == "outer");
var inner = result.Nodes.Single(node => node.Id == "inner");
var innerChildren = result.Nodes.Where(node => node.ParentNodeId == "inner").ToArray();
inner.X.Should().BeGreaterThanOrEqualTo(outer.X + options.CompoundPadding - 1d);
inner.Y.Should().BeGreaterThanOrEqualTo(outer.Y + options.CompoundPadding + options.CompoundHeaderHeight - 1d);
(inner.X + inner.Width).Should().BeLessThanOrEqualTo(outer.X + outer.Width - options.CompoundPadding + 1d);
(inner.Y + inner.Height).Should().BeLessThanOrEqualTo(outer.Y + outer.Height - options.CompoundPadding + 1d);
innerChildren.Should().NotBeEmpty();
innerChildren.Should().OnlyContain(node =>
node.X >= inner.X + options.CompoundPadding - 1d
&& node.Y >= inner.Y + options.CompoundPadding + options.CompoundHeaderHeight - 1d
&& node.X + node.Width <= inner.X + inner.Width - options.CompoundPadding + 1d
&& node.Y + node.Height <= inner.Y + inner.Height - options.CompoundPadding + 1d);
}
[Test]
public async Task LayoutAsync_WhenCompoundGraphContainsEmptyParent_ShouldKeepTheEmptyParentVisible()
{
var engine = new ElkSharpLayeredLayoutEngine();
var graph = new ElkGraph
{
Id = "compound-empty-parent",
Nodes =
[
CreateNode("group", "Group", width: 240, height: 180),
CreateNode("child", "Child", parentNodeId: "group"),
CreateNode("empty", "Empty", width: 200, height: 120),
],
Edges =
[
CreateEdge("e1", "child", "empty"),
],
};
var result = await engine.LayoutAsync(graph);
var empty = result.Nodes.Single(node => node.Id == "empty");
empty.Width.Should().Be(200d);
empty.Height.Should().Be(120d);
double.IsNaN(empty.X).Should().BeFalse();
double.IsNaN(empty.Y).Should().BeFalse();
}
[Test]
public async Task LayoutAsync_WhenEdgeCrossesCompoundBoundaries_ShouldExposeBoundaryCrossingPoints()
{
var engine = new ElkSharpLayeredLayoutEngine();
var graph = new ElkGraph
{
Id = "compound-boundary-crossings",
Nodes =
[
CreateNode("source-group", "Source Group", width: 250, height: 190),
CreateNode("source", "Source", parentNodeId: "source-group"),
CreateNode("target-group", "Target Group", width: 250, height: 190),
CreateNode("target", "Target", parentNodeId: "target-group"),
],
Edges =
[
CreateEdge("e1", "source", "target"),
],
};
var result = await engine.LayoutAsync(graph);
var sourceGroup = result.Nodes.Single(node => node.Id == "source-group");
var targetGroup = result.Nodes.Single(node => node.Id == "target-group");
var edge = result.Edges.Single(routedEdge => routedEdge.Id == "e1");
var path = BuildPath(edge);
path.Skip(1).SkipLast(1).Should().Contain(point => IsOnBoundary(point, sourceGroup));
path.Skip(1).SkipLast(1).Should().Contain(point => IsOnBoundary(point, targetGroup));
}
private static ElkNode CreateNode(
string id,
string label,
string kind = "Task",
double width = 160,
double height = 72,
string? parentNodeId = null) =>
new()
{
Id = id,
Label = label,
Kind = kind,
Width = width,
Height = height,
ParentNodeId = parentNodeId,
};
private static ElkEdge CreateEdge(string id, string sourceNodeId, string targetNodeId) =>
new()
{
Id = id,
SourceNodeId = sourceNodeId,
TargetNodeId = targetNodeId,
};
private static IReadOnlyList<ElkPoint> BuildPath(ElkRoutedEdge edge)
{
var points = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
AppendPoint(points, section.StartPoint);
foreach (var bendPoint in section.BendPoints)
{
AppendPoint(points, bendPoint);
}
AppendPoint(points, section.EndPoint);
}
return points;
}
private static void AppendPoint(ICollection<ElkPoint> points, ElkPoint point)
{
if (points.Count > 0 && points.Last() is { } previousPoint && AreClose(previousPoint, point))
{
return;
}
points.Add(point);
}
private static bool IsOnBoundary(ElkPoint point, ElkPositionedNode node)
{
const double tolerance = 0.75d;
var onLeftOrRight = Math.Abs(point.X - node.X) <= tolerance
|| Math.Abs(point.X - (node.X + node.Width)) <= tolerance;
var onTopOrBottom = Math.Abs(point.Y - node.Y) <= tolerance
|| Math.Abs(point.Y - (node.Y + node.Height)) <= tolerance;
var withinHorizontalSpan = point.X >= node.X - tolerance && point.X <= node.X + node.Width + tolerance;
var withinVerticalSpan = point.Y >= node.Y - tolerance && point.Y <= node.Y + node.Height + tolerance;
return (onLeftOrRight && withinVerticalSpan) || (onTopOrBottom && withinHorizontalSpan);
}
private static bool AreClose(ElkPoint left, ElkPoint right) =>
Math.Abs(left.X - right.X) <= 0.01d
&& Math.Abs(left.Y - right.Y) <= 0.01d;
}