OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / CurveDragStabilityTests.cs

144 lines · csharp ref:
using System;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Input;

using IFSEngine.Animation;

using Xunit;

namespace IFSRenderer.AvaloniaShell.Tests;

/// <summary>
/// The curve editor crashed the app after dragging a keyframe for a second or two.
/// </summary>
/// <remarks>
/// <para>
/// <c>System.OverflowException: Value was either too large or too small for a Decimal</c>, from
/// <c>AnimationPanel.RefreshKeyframeFields</c> on pointer release. The cast was only where it
/// surfaced. The cause was a feedback loop between the drag and the auto-fitted value axis:
/// </para>
/// <para>
/// auto-fit widens the axis to include the dragged key AND pads it by 15% -> the unchanged
/// pointer position now maps through a wider axis to a larger value -> which widens the axis
/// again. About 1.3x per frame, so a held drag runs past decimal's ~7.9e28 ceiling in seconds.
/// </para>
/// <para>
/// The engine was cleared first: <c>Channel.EvaluateAt</c> stayed finite on coincident keys,
/// one-ULP-apart keys, all-keys-on-one-time and 1e300 values, so nothing here is a workaround
/// for an engine defect.
/// </para>
/// </remarks>
public class CurveDragStabilityTests
{
    private static (CurveCanvas Curve, Channel Channel) Laid()
    {
        var sheet = new Dopesheet { Length = TimeSpan.FromSeconds(10), Fps = 30 };
        var channel = new Channel { Name = "Brightness" };
        channel.Keyframes.Add(new Keyframe { t = 0, Value = -5 });
        channel.Keyframes.Add(new Keyframe { t = 10, Value = 5 });
        sheet.Channels["Brightness"] = channel;

        var curve = new CurveCanvas { Width = 800, Height = 400 };
        var window = new Window { Width = 820, Height = 420, Content = curve };
        window.Show();
        curve.SetDopesheet(sheet);
        curve.SetChannel("Brightness");
        window.Measure(new Size(820, 420));
        window.Arrange(new Rect(0, 0, 820, 420));
        return (curve, channel);
    }

    /// <summary>Holding a drag still must not move the value it is reporting.</summary>
    [AvaloniaFact]
    public void AHeldDragDoesNotRunAway()
    {
        (CurveCanvas curve, Channel channel) = Laid();
        Keyframe key = channel.Keyframes[1];

        curve.ApplyAutoFit();
        var at = new Point(curve.TimeToX(10), curve.ValueToY(2));
        curve.BeginDrag(key, at);

        double first = double.NaN;
        for (int frame = 0; frame < 200; frame++)
        {
            curve.ApplyAutoFit();               // what Render does every frame
            curve.DragTo(at, KeyModifiers.None);
            if (frame == 0)
                first = key.Value;
        }

        Assert.Equal(first, key.Value, 6);
        Assert.True(Math.Abs(key.Value) < 1e6,
            $"value ran away to {key.Value:0.###e+00} while the pointer never moved");
    }

    /// <summary>Whatever a drag produces, the panel must be able to display it.</summary>
    [AvaloniaFact]
    public void ADraggedValueAlwaysSurvivesTheDecimalConversion()
    {
        (CurveCanvas curve, Channel channel) = Laid();
        Keyframe key = channel.Keyframes[1];

        curve.ApplyAutoFit();
        curve.BeginDrag(key, new Point(curve.TimeToX(10), curve.ValueToY(5)));

        // Includes positions far outside the control: a captured pointer is not clamped to it.
        foreach (double y in new[] { -1e6, -5000.0, -1.0, 0.0, 200.0, 400.0, 5000.0, 1e6 })
        {
            curve.ApplyAutoFit();
            curve.DragTo(new Point(curve.TimeToX(5), y), KeyModifiers.None);
            Exception thrown = Record.Exception(() =>
                AnimationPanel.ToDecimal(key.Value, -1_000_000_000m, 1_000_000_000m, 0m));
            Assert.Null(thrown);
        }
    }

    [Theory]
    [InlineData(double.NaN, 0)]
    [InlineData(double.PositiveInfinity, 100)]
    [InlineData(double.NegativeInfinity, -100)]
    [InlineData(1e300, 100)]
    [InlineData(-1e300, -100)]
    [InlineData(2.5, 2.5)]
    public void OutOfRangeDoublesAreClampedRatherThanThrown(double value, double expected) =>
        Assert.Equal((decimal)expected, AnimationPanel.ToDecimal(value, -100m, 100m, 0m));

    /// <summary>A poisoned sample must not take the whole axis with it.</summary>
    [Fact]
    public void NonFiniteSamplesDoNotPoisonTheFittedRange()
    {
        var channel = new Channel { Name = "n" };
        channel.Keyframes.Add(new Keyframe { t = 0, Value = 1 });
        channel.Keyframes.Add(new Keyframe { t = 5, Value = double.NaN });
        channel.Keyframes.Add(new Keyframe { t = 10, Value = 3 });

        (double min, double max) = CurveCanvas.FitRange(channel, 10);

        Assert.False(double.IsNaN(min) || double.IsNaN(max), "NaN propagated into the axis");
        Assert.True(min < max);
    }

    /// <summary>Auto-fit still works the moment the drag ends.</summary>
    [AvaloniaFact]
    public void ReleasingTheDragLetsTheAxisFitAgain()
    {
        (CurveCanvas curve, Channel channel) = Laid();
        Keyframe key = channel.Keyframes[1];

        curve.ApplyAutoFit();
        curve.BeginDrag(key, new Point(curve.TimeToX(10), curve.ValueToY(5)));
        curve.DragTo(new Point(curve.TimeToX(10), curve.ValueToY(40)), KeyModifiers.None);
        curve.EndDrag();

        curve.ApplyAutoFit();

        // The key is now at 40, so it has to be inside the plot rather than off the top.
        double y = curve.ValueToY(key.Value);
        Assert.InRange(y, 0, curve.Bounds.Height);
    }
}