Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 279 additions & 1 deletion Westermo.GraphX.Controls.Avalonia.Tests/GeometryCachingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
using Westermo.GraphX.Common.Enums;
using Westermo.GraphX.Common.Models;
using Westermo.GraphX.Controls.Controls;
using Westermo.GraphX.Controls.Controls.EdgeLabels;
using Westermo.GraphX.Controls.Controls.EdgePointers;
using Westermo.GraphX.Controls.Controls.VertexLabels;
using Westermo.GraphX.Controls.Models.Interfaces;
using Westermo.GraphX.Logic.Models;

namespace Westermo.GraphX.Controls.Avalonia.Tests;
Expand All @@ -27,6 +31,21 @@ private class TEdge(TVertex s, TVertex t) : EdgeBase<TVertex>(s, t)
public override Westermo.GraphX.Measure.Point[]? RoutingPoints { get; set; } = null;
}

private sealed class PlainEdgeLabel : EdgeLabelControl { }

private sealed class PlainLabelFactory<TLabel> : ILabelFactory<Control>
where TLabel : Control, new()
{
public IEnumerable<Control> CreateLabel<TCtrl>(TCtrl control) => [new TLabel()];
}

private static void MeasureAndArrange(EdgeControl edge)
{
edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
}

private static void EnsureVertexTemplate(VertexControl vc)
{
if (vc.Template == null)
Expand Down Expand Up @@ -72,6 +91,41 @@ private static void EnsureEdgeTemplate(EdgeControl ec)
ec.ApplyTemplate();
}

private static void EnsureEdgeTemplateWithTargetPointer(EdgeControl ec, double pointerWidth = 10, double pointerHeight = 10)
{
var content = new Grid();
var path = new global::Avalonia.Controls.Shapes.Path
{
Name = "PART_edgePath",
Stroke = Brushes.Black,
StrokeThickness = 1
};
var pointer = new DefaultEdgePointer
{
Name = "PART_EdgePointerForTarget",
Width = pointerWidth,
Height = pointerHeight,
Content = new global::Avalonia.Controls.Shapes.Polygon
{
Points = [new Point(0, 0), new Point(pointerWidth, pointerHeight / 2), new Point(0, pointerHeight)],
Fill = Brushes.Black
}
};
content.Children.Add(path);
content.Children.Add(pointer);
var ns = new NameScope();
ns.Register("PART_edgePath", path);
ns.Register("PART_EdgePointerForTarget", pointer);
var functor = new Func<IServiceProvider?, object?>(_ => new TemplateResult<Control>(content, ns));
ec.Template = new ControlTemplate
{
TargetType = typeof(EdgeControl),
Content = functor
};

ec.ApplyTemplate();
}

private (GraphArea<TVertex, TEdge, BidirectionalGraph<TVertex, TEdge>> area,
VertexControl sourceVc, VertexControl targetVc, EdgeControl edge) CreateSimpleGraph()
{
Expand Down Expand Up @@ -120,6 +174,46 @@ private static void EnsureEdgeTemplate(EdgeControl ec)
return (area, sourceVc, targetVc, edge);
}

private (GraphArea<TVertex, TEdge, BidirectionalGraph<TVertex, TEdge>> area,
VertexControl sourceVc, EdgeControl edge) CreateSelfLoopGraph()
{
var graph = new BidirectionalGraph<TVertex, TEdge>();
var v1 = new TVertex("A") { ID = 1 };
graph.AddVertex(v1);
var e = new TEdge(v1, v1);
graph.AddEdge(e);

var lc = new GXLogicCore<TVertex, TEdge, BidirectionalGraph<TVertex, TEdge>>
{
Graph = graph,
EnableParallelEdges = false
};

var area = new GraphArea<TVertex, TEdge, BidirectionalGraph<TVertex, TEdge>>
{
LogicCore = lc,
Width = 500,
Height = 400
};

var positions = new Dictionary<TVertex, Point>
{
[v1] = new Point(50, 100)
};

area.PreloadGraph(positions, showObjectsIfPosSpecified: true);

var sourceVc = area.VertexList[v1];
sourceVc.Width = 40;
sourceVc.Height = 30;
EnsureVertexTemplate(sourceVc);

var edge = (EdgeControl)area.EdgesList[e];
EnsureEdgeTemplate(edge);

return (area, sourceVc, edge);
}

[Test]
public async Task GeometryBounds_ReturnsNullBeforeFirstUpdate()
{
Expand Down Expand Up @@ -176,4 +270,188 @@ public async Task Geometry_IsReused_WhenMeasureIsInvalidatedWithoutInputChanges(

await Assert.That(edge.GetLineGeometry()).IsSameReferenceAs(initialGeometry);
}
}

[Test]
public async Task GeometryCacheBenchmarkSwitch_RebuildsWithoutCacheAndResumesReuseWhenEnabled()
{
var (_, _, _, edge) = CreateSimpleGraph();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

edge.UseGeometryCache = false;
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
var firstUncachedGeometry = edge.GetLineGeometry();

edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(firstUncachedGeometry);

edge.UseGeometryCache = true;
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
var firstCachedGeometry = edge.GetLineGeometry();

edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
await Assert.That(edge.GetLineGeometry()).IsSameReferenceAs(firstCachedGeometry);
}

[Test]
public async Task Geometry_IsRebuilt_WhenPointerDesiredSizeChangesBeforeEdgeMeasure()
{
var (_, _, _, edge) = CreateSimpleGraph();
EnsureEdgeTemplateWithTargetPointer(edge);
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

var initialGeometry = edge.GetLineGeometry();
var targetPointer = edge.GetEdgePointerForTarget() as Control;
await Assert.That(targetPointer).IsNotNull();
await Assert.That(targetPointer!.DesiredSize.Width).IsEqualTo(10);

// Changing the pointer size invalidates its own measure first. The edge cache check must
// observe the fresh DesiredSize during the same pass so the endpoint offsets are rebuilt immediately.
targetPointer.Width = 20;
edge.InvalidateMeasure();

await Assert.That(targetPointer.DesiredSize.Width).IsEqualTo(10);

edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(targetPointer.DesiredSize.Width).IsEqualTo(20);
await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(initialGeometry);
}

[Test]
public async Task Geometry_IsRebuilt_WhenReversePathChanges()
{
var (_, _, _, edge) = CreateSimpleGraph();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
var initialGeometry = edge.GetLineGeometry();

// ReversePath only affects the traversal order used when the final StreamGeometry is built
// (it doesn't change the underlying point values), so the cache must treat it as an explicit
// input rather than relying on the point-signature comparison to notice the change.
((TEdge)edge.Edge!).ReversePath = true;
edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(edge.GetLineGeometry()).IsNotSameReferenceAs(initialGeometry);
}

[Test]
public async Task Geometry_IsCleared_WhenShowSelfLoopIndicatorChanges()
{
var (_, _, edge) = CreateSelfLoopGraph();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(edge.GetLineGeometry()).IsNotNull();

edge.ShowSelfLoopIndicator = false;
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(edge.GetLineGeometry()).IsNull();
}

[Test]
public async Task Geometry_UpdatesEndpoint_WhenVertexShapeChanges()
{
var (_, source, target, edge) = CreateSimpleGraph();
target.SetPosition(250, 180);
GraphAreaBase.SetFinalY(target, 180);
source.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
source.VertexShape = VertexShape.Circle;
MeasureAndArrange(edge);
var oldEndpoint = edge.SourceEndpoint;

source.VertexShape = VertexShape.Rectangle;
MeasureAndArrange(edge);

await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint);
}

[Test]
public async Task Geometry_UpdatesEndpoints_WhenParallelEdgesAreEnabled()
{
var (area, source, target, edge) = CreateSimpleGraph();
source.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
target.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.IsParallel = true;
edge.ParallelEdgeOffset = 25;
MeasureAndArrange(edge);
var oldEndpoint = edge.SourceEndpoint;

area.LogicCore!.EnableParallelEdges = true;
MeasureAndArrange(edge);

await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint);
}

[Test]
public async Task PointerOrientation_IsCalculatedOnlyForPointersThatNeedIt()
{
var (_, _, _, edge) = CreateSimpleGraph();
MeasureAndArrange(edge);
await Assert.That(edge.TargetPointerComputedAngle).IsEqualTo(0);

EnsureEdgeTemplateWithTargetPointer(edge);
var pointer = (DefaultEdgePointer)edge.GetEdgePointerForTarget()!;
pointer.NeedRotation = false;
MeasureAndArrange(edge);
await Assert.That(edge.TargetPointerComputedAngle).IsEqualTo(0);

pointer.NeedRotation = true;
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width + 1, edge.DesiredSize.Height));
await Assert.That(Math.Abs(edge.TargetPointerComputedAngle)).IsGreaterThan(0);
}

[Test]
public async Task GetRelatedEdgeControls_ReturnsSelfLoopOnlyOnce()
{
var (area, source, edge) = CreateSelfLoopGraph();
var vertex = (TVertex)source.Vertex!;
var secondLoop = new TEdge(vertex, vertex);
area.LogicCore!.Graph.AddEdge(secondLoop);
var secondControl = area.ControlFactory.CreateEdgeControl(source, source, secondLoop);
area.AddEdge(secondLoop, secondControl);

var related = area.GetRelatedEdgeControls(source, EdgesType.All);

await Assert.That(related.Count).IsEqualTo(2);
await Assert.That(related.Count(control => ReferenceEquals(control, edge))).IsEqualTo(1);
await Assert.That(related.Count(control => ReferenceEquals(control, secondControl))).IsEqualTo(1);
}

[Test]
public async Task ClearLayout_ReleasesGeneratedLabelsThatAreNotAttached()
{
var (area, _, _, _) = CreateSimpleGraph();
area.VertexLabelFactory = new PlainLabelFactory<VertexLabelControl>();
area.EdgeLabelFactory = new PlainLabelFactory<PlainEdgeLabel>();
area.PreloadGraph(new Dictionary<TVertex, Point>
{
[area.LogicCore!.Graph.Vertices.First()] = new Point(50, 100),
[area.LogicCore.Graph.Vertices.Last()] = new Point(250, 100)
});
await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(2);
await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(1);

area.ClearLayout(removeCustomObjects: false);
await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(2);
await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(1);

area.ClearLayout();

await Assert.That(area.GeneratedVertexLabelCount).IsEqualTo(0);
await Assert.That(area.GeneratedEdgeLabelCount).IsEqualTo(0);
}
}
36 changes: 36 additions & 0 deletions Westermo.GraphX.Controls.Avalonia.Tests/VcpEdgeGeometryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,40 @@ public async Task EdgeGeometry_UsesConnectionPointCenterWhenShapeNone()
await Assert.That(actualTarget.X).IsBetween(targetCenter.X - eps, targetCenter.X + eps);
await Assert.That(actualTarget.Y).IsBetween(targetCenter.Y - eps, targetCenter.Y + eps);
}

[Test]
public async Task EdgeGeometry_UpdatesEndpoint_WhenConnectionPointShapeChanges()
{
var (_, _, _, cp, _, _, edge) =
CreateAreaWithVcp(VertexShape.Circle, VertexShape.Circle, bothEndpoints: true);
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
var oldEndpoint = edge.SourceEndpoint;

cp.Shape = VertexShape.None;
edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint);
await Assert.That(edge.SourceEndpoint).IsEqualTo(cp.RectangularSize.Center());
}

[Test]
public async Task EdgeGeometry_UpdatesEndpoint_WhenConnectionPointSizeChanges()
{
var (_, _, _, cp, _, _, edge) =
CreateAreaWithVcp(VertexShape.Circle, VertexShape.Circle, bothEndpoints: true);
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));
var oldEndpoint = edge.SourceEndpoint;

cp.Width = 30;
cp.Update();
edge.InvalidateMeasure();
edge.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
edge.Arrange(new Rect(0, 0, edge.DesiredSize.Width, edge.DesiredSize.Height));

await Assert.That(edge.SourceEndpoint).IsNotEqualTo(oldEndpoint);
}
}
Loading
Loading