Add ElkSharp compound node support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user