📄 ui/tests/PhysicsLayoutTests/SettleShould.cs
using System;
using System.Linq;
using System.Threading;
using Avalonia;
using Cfa.Ui.Graph;
using Shouldly;
using Xunit;

namespace Cfa.Ui.Tests.PhysicsLayoutTests;

public sealed class SettleShould
{
    [Fact]
    public void KeepTheOriginPinnedWhilePullingADistantChildCloser()
    {
        // Arrange
        var layout = new PhysicsLayout([new(-1, 100), new(0, 100)], initialPositions: [default, new Point(1000, 0)]);

        // Act
        layout.Settle();

        // Assert
        layout.Positions[0].ShouldBe(default(Point));
        Distance(layout.Positions[0], layout.Positions[1]).ShouldBe(105, 2);
        layout.IsStable.ShouldBeTrue();
        layout.MaximumOverlap.ShouldBeLessThan(0.05);
        layout.Iterations.ShouldBeLessThan(600);
    }

    [Fact]
    public void LetDescendantsEnterAnAncestorsZoneButKeepUnrelatedNodesOutside()
    {
        // Arrange
        var layout = new PhysicsLayout([new(-1, 100), new(0, 600), new(1, 50), new(0, 100)]);

        // Act
        layout.Settle();

        // Assert
        layout.MinimumDistance(1, 2).ShouldBe(50);
        layout.MinimumDistance(1, 3).ShouldBe(600);
        Distance(layout.Positions[1], layout.Positions[2]).ShouldBeLessThan(150);
        Distance(layout.Positions[1], layout.Positions[3]).ShouldBeGreaterThanOrEqualTo(599.9);
        AssertExclusions(layout);
    }

    [Fact]
    public void ExemptGrandchildrenAndDeeperDescendantsNotJustImmediateChildren()
    {
        // Arrange
        var layout = new PhysicsLayout([new(-1, 1000), new(0, 100), new(1, 70), new(2, 30), new(0, 60)]);

        // Act
        var deepDistance = layout.MinimumDistance(0, 3);
        var unrelatedDistance = layout.MinimumDistance(3, 4);

        // Assert
        deepDistance.ShouldBe(30);
        unrelatedDistance.ShouldBe(60);
        layout.MinimumDistance(3, 0).ShouldBe(deepDistance);
        layout.MinimumDistance(2, 2).ShouldBe(0);
    }

    [Theory]
    [InlineData(4)]
    [InlineData(12)]
    [InlineData(32)]
    public void SeparateSiblingsAroundTheirParent(int count)
    {
        // Arrange
        var nodes = Enumerable.Range(0, count + 1).Select(i => new PhysicsNode(i == 0 ? -1 : 0, 100)).ToArray();
        var layout = new PhysicsLayout(nodes);

        // Act
        layout.Settle();

        // Assert
        AssertExclusions(layout);
        layout.Positions.ShouldContain(point => point.X < -10);
        layout.Positions.ShouldContain(point => point.X > 10);
        layout.Positions.ShouldContain(point => point.Y < -10);
        layout.Positions.ShouldContain(point => point.Y > 10);
    }

    [Fact]
    public void KeepAFarBranchesOwnHelpersCloseWhileRepellingItsSiblings()
    {
        // Arrange
        var layout = new PhysicsLayout([
            new(-1, 100),
            new(0, 700),
            new(1, 80),
            new(1, 80),
            new(0, 100),
            new(4, 80),
            new(4, 80),
        ]);

        // Act
        layout.Settle();

        // Assert
        Distance(layout.Positions[1], layout.Positions[2]).ShouldBeLessThan(250);
        Distance(layout.Positions[1], layout.Positions[3]).ShouldBeLessThan(250);
        foreach (var outside in new[] { 0, 4, 5, 6 })
            Distance(layout.Positions[1], layout.Positions[outside]).ShouldBeGreaterThanOrEqualTo(699.9);
        AssertExclusions(layout);
    }

    [Fact]
    public void ResolveCoincidentNodesWithoutRandomnessOrNonfiniteCoordinates()
    {
        // Arrange
        PhysicsNode[] nodes = [new(-1, 100), new(0, 100), new(0, 100), new(1, 60)];
        var layout = new PhysicsLayout(nodes, initialPositions: new Point[nodes.Length]);
        var second = new PhysicsLayout(nodes, initialPositions: new Point[nodes.Length]);

        // Act
        layout.Settle();
        second.Settle();

        // Assert
        layout.Positions.ShouldBe(second.Positions);
        layout.Positions.ShouldAllBe(point => double.IsFinite(point.X) && double.IsFinite(point.Y));
        AssertExclusions(layout);
    }

    [Fact]
    public void StopAtTheIterationLimitEvenWithoutConvergence()
    {
        // Arrange
        var layout = new PhysicsLayout(
            [new(-1, 100), new(0, 100)],
            new PhysicsOptions { MaxIterations = 2 },
            [default, new Point(1000, 0)]
        );

        // Act
        layout.Settle();

        // Assert
        layout.Iterations.ShouldBe(2);
        layout.IsStable.ShouldBeFalse();
    }

    [Fact]
    public void HonorCancellationBeforeMovingAnything()
    {
        // Arrange
        var layout = new PhysicsLayout([new(-1, 100), new(0, 100)]);
        var before = layout.Positions.ToArray();
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        // Act
        var settle = () => layout.Settle(cancellation.Token);

        // Assert
        settle.ShouldThrow<OperationCanceledException>();
        layout.Positions.ShouldBe(before);
        layout.Iterations.ShouldBe(0);
    }

    [Fact]
    public void HandleEmptyAndSingleNodeSimulations()
    {
        // Arrange
        var empty = new PhysicsLayout([]);
        var single = new PhysicsLayout([new(-1, 100)]);

        // Act
        empty.Settle();
        single.Settle();

        // Assert
        empty.Positions.ShouldBeEmpty();
        single.Positions.ShouldHaveSingleItem().ShouldBe(default(Point));
        empty.IsStable.ShouldBeTrue();
        single.IsStable.ShouldBeTrue();
    }

    [Fact]
    public void SupportParentBeforeChildInputEvenWhenSubtreesAreInterleaved()
    {
        // Arrange
        var layout = new PhysicsLayout([new(-1, 100), new(0, 400), new(0, 200), new(1, 50), new(2, 60)]);

        // Act
        layout.Settle();

        // Assert
        layout.MinimumDistance(1, 3).ShouldBe(50);
        layout.MinimumDistance(1, 4).ShouldBe(400);
        AssertExclusions(layout);
    }

    [Fact]
    public void KeepBothFarBranchesOutsideEachOthersZonesWhileTheirHelpersRemainInside()
    {
        // Arrange
        var layout = new PhysicsLayout([
            new(-1, 100),
            new(0, 500),
            new(1, 80),
            new(1, 80),
            new(0, 500),
            new(4, 80),
            new(4, 80),
        ]);

        // Act
        layout.Settle();

        // Assert
        AssertExclusions(layout);
        foreach (var pair in new[] { (1, 2), (1, 3), (4, 5), (4, 6) })
            Distance(layout.Positions[pair.Item1], layout.Positions[pair.Item2]).ShouldBeLessThan(200);
        Distance(layout.Positions[1], layout.Positions[4]).ShouldBeGreaterThanOrEqualTo(499.9);
    }

    [Fact]
    public void SettleAMixedHierarchyWithNoDirectionalRestrictions()
    {
        // Arrange
        var random = new Random(7);
        var nodes = Enumerable
            .Range(0, 40)
            .Select(i => new PhysicsNode(i == 0 ? -1 : random.Next(i), i % 5 == 0 ? 400 : 100))
            .ToArray();
        var layout = new PhysicsLayout(nodes);

        // Act
        layout.Settle();

        // Assert
        AssertExclusions(layout);
        layout.Positions.ShouldAllBe(point => double.IsFinite(point.X) && double.IsFinite(point.Y));
        layout.Iterations.ShouldBeLessThanOrEqualTo(600);
    }

    private static double Distance(Point first, Point second) =>
        new Vector(first.X - second.X, first.Y - second.Y).Length;

    private static void AssertExclusions(PhysicsLayout layout)
    {
        for (var i = 0; i < layout.Positions.Count; i++)
        for (var j = i + 1; j < layout.Positions.Count; j++)
            Distance(layout.Positions[i], layout.Positions[j])
                .ShouldBeGreaterThanOrEqualTo(
                    layout.MinimumDistance(i, j) - 0.1,
                    $"Nodes {i} and {j} violate their exclusion radii."
                );
    }
}