OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / CurveEditorTests.cs

387 lines · csharp ref:
using System;
using System.Linq;

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

using IFSEngine.Animation;

using Xunit;

namespace IFSRenderer.AvaloniaShell.Tests;

/// <summary>
/// The animation curve editor.
/// </summary>
/// <remarks>
/// <para>
/// "I tried to animate a value from -5 to 5 and I wanted it to slow down from -1 to 1, had a
/// hard time doing that." The dopesheet shows WHEN a keyframe is, never WHAT it is worth, so
/// shaping a segment meant typing numbers and re-rendering to see the result.
/// </para>
/// <para>
/// The canonical WPF app is no help here - its CurveEditor.xaml is a placeholder reading
/// "🚧 Under development" - so these assert against the ENGINE's model rather than against
/// upstream behaviour. The load-bearing property is that the plot is a projection of
/// <see cref="Channel.EvaluateAt"/>: if it ever stops agreeing with the evaluator, the picture
/// is lying about what will render.
/// </para>
/// </remarks>
public class CurveEditorTests
{
    private static Dopesheet SheetWith(params (double T, double Value)[] keys)
    {
        var sheet = new Dopesheet { Length = TimeSpan.FromSeconds(10), Fps = 30 };
        var channel = new Channel { Name = "Brightness" };
        foreach ((double t, double v) in keys)
            channel.Keyframes.Add(new Keyframe { t = t, Value = v });
        sheet.Channels["Brightness"] = channel;
        return sheet;
    }

    /// <summary>Lays the control out so the pixel mappings have real bounds to work with.</summary>
    private static CurveCanvas Laid(Dopesheet sheet, string path = "Brightness")
    {
        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(path);
        window.Measure(new Size(820, 420));
        window.Arrange(new Rect(0, 0, 820, 420));
        return curve;
    }

    [Fact]
    public void PlotIsAProjectionOfTheEnginesEvaluator()
    {
        Dopesheet sheet = SheetWith((0, -5), (5, 0), (10, 5));
        Channel channel = sheet.Channels["Brightness"];

        var samples = CurveCanvas.Sample(channel, 10, 64);

        Assert.Equal(64, samples.Count);
        foreach ((double t, double value) in samples)
            Assert.Equal(channel.EvaluateAt(t), value);
    }

    [Fact]
    public void EmptyChannelIsNotSampled()
    {
        // Channel.EvaluateAt calls Keyframes.Last() and throws on an empty list.
        Assert.Empty(CurveCanvas.Sample(new Channel(), 10, 32));
        Assert.Empty(CurveCanvas.Sample(null, 10, 32));
    }

    [Fact]
    public void FittedRangeCoversCatmullRomOvershoot()
    {
        // CatmullRom overshoots its endpoints, and the overshoot is the thing you most need to
        // see - it is what pushes a parameter past a limit you thought you had bounded.
        Dopesheet sheet = SheetWith((0, 0), (3, 0), (5, 10), (7, 10), (10, 0));
        Channel channel = sheet.Channels["Brightness"];

        double peak = CurveCanvas.Sample(channel, 10, 512).Max(s => s.Value);
        (double min, double max) = CurveCanvas.FitRange(channel, 10);

        Assert.True(peak > 10, $"expected an overshoot above the keyed 10, got {peak}");
        Assert.True(max >= peak, $"fitted max {max} clips the overshoot at {peak}");
        Assert.True(min <= 0);
    }

    [Fact]
    public void ConstantChannelStillGetsAUsableAxis()
    {
        (double min, double max) = CurveCanvas.FitRange(SheetWith((0, 2), (10, 2)).Channels["Brightness"], 10);
        Assert.True(max - min >= 0.05, "a flat channel must not collapse the value axis");
    }

    [Theory]
    [InlineData(0.9, 1)]
    [InlineData(1.4, 2)]
    [InlineData(3.0, 5)]
    [InlineData(7.0, 10)]
    [InlineData(0.021, 0.05)]
    public void GridStepsAreRound(double raw, double expected) =>
        Assert.Equal(expected, CurveCanvas.NiceStep(raw), 10);

    [AvaloniaFact]
    public void ValueAndPixelRoundTrip()
    {
        CurveCanvas curve = Laid(SheetWith((0, -5), (10, 5)));

        foreach (double v in new[] { -5.0, -1.0, 0.0, 1.0, 5.0 })
            Assert.Equal(v, curve.YToValue(curve.ValueToY(v)), 6);
    }

    [AvaloniaFact]
    public void DraggingAKeyframeChangesItsValueAndTime()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        CurveCanvas curve = Laid(sheet);
        Keyframe key = sheet.Channels["Brightness"].Keyframes[1];

        curve.BeginDrag(key, new Point(curve.TimeToX(10), curve.ValueToY(5)));
        curve.DragTo(new Point(curve.TimeToX(6), curve.ValueToY(2)), KeyModifiers.None);

        Assert.Equal(6, key.t, 3);
        Assert.Equal(2, key.Value, 3);
    }

    [AvaloniaFact]
    public void ShiftLocksTheDragToOneAxis()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        CurveCanvas curve = Laid(sheet);
        Keyframe key = sheet.Channels["Brightness"].Keyframes[1];

        // Mostly vertical movement, so time must be left alone.
        curve.BeginDrag(key, new Point(curve.TimeToX(10), curve.ValueToY(5)));
        curve.DragTo(new Point(curve.TimeToX(9.9), curve.ValueToY(1)), KeyModifiers.Shift);

        Assert.Equal(10, key.t, 3);
        Assert.Equal(1, key.Value, 3);
    }

    [AvaloniaFact]
    public void CtrlSnapsTheDraggedTimeToTheFrameGrid()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        CurveCanvas curve = Laid(sheet);
        curve.SnapFps = 30;
        Keyframe key = sheet.Channels["Brightness"].Keyframes[1];

        curve.BeginDrag(key, new Point(curve.TimeToX(10), curve.ValueToY(5)));
        curve.DragTo(new Point(curve.TimeToX(5.017), curve.ValueToY(0)), KeyModifiers.Control);

        Assert.Equal(0, Math.Round(key.t * 30) - key.t * 30, 6);
    }

    [AvaloniaFact]
    public void InsertingOnALinearSegmentLeavesTheCurveAlone()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        Channel channel = sheet.Channels["Brightness"];
        foreach (Keyframe k in channel.Keyframes)
        {
            k.InterpolationMode = InterpolationMode.Linear;
            k.EasingDirection = EasingDirection.InOut;
            k.EasingPower = 1;                       // InOut at power 1 is the identity
        }

        CurveCanvas curve = Laid(sheet);
        double[] before = CurveCanvas.Sample(channel, 10, 128).Select(s => s.Value).ToArray();

        curve.InsertKeyframeAt(4);

        Assert.Equal(3, channel.Keyframes.Count);
        double[] after = CurveCanvas.Sample(channel, 10, 128).Select(s => s.Value).ToArray();
        for (int i = 0; i < before.Length; i++)
            Assert.Equal(before[i], after[i], 6);
    }

    /// <summary>
    /// Splitting an EASED segment necessarily re-shapes it, and the insert lands on the curve
    /// regardless.
    /// </summary>
    /// <remarks>
    /// Easing is applied to each segment's normalized parameter (Channel.cs:41-47), so two
    /// half-length segments each run the full easing curve over half the span. No choice of
    /// inherited settings can reproduce the original - the model simply cannot express it, and
    /// pretending otherwise in the UI would be the lie. What IS guaranteed is that the new
    /// keyframe sits exactly where the curve was, so the click lands where it looked like it
    /// would.
    /// </remarks>
    [AvaloniaFact]
    public void InsertingOnAnEasedSegmentReShapesItButLandsOnTheCurve()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        Channel channel = sheet.Channels["Brightness"];
        foreach (Keyframe k in channel.Keyframes)
        {
            k.InterpolationMode = InterpolationMode.Linear;
            k.EasingDirection = EasingDirection.InOut;
            k.EasingPower = 0.4;
        }

        CurveCanvas curve = Laid(sheet);
        double expected = channel.EvaluateAt(4);
        double[] before = CurveCanvas.Sample(channel, 10, 128).Select(s => s.Value).ToArray();

        curve.InsertKeyframeAt(4);

        // The new key sits exactly where the curve was, so the click landed where it looked.
        Keyframe inserted = channel.Keyframes.Single(k => Math.Abs(k.t - 4) < 0.05);
        Assert.Equal(expected, inserted.Value, 6);
        Assert.Equal(expected, channel.EvaluateAt(4), 6);

        // But the halves really did re-ease - documented, not hidden.
        double[] after = CurveCanvas.Sample(channel, 10, 128).Select(s => s.Value).ToArray();
        Assert.True(before.Zip(after).Any(p => Math.Abs(p.First - p.Second) > 1e-6),
            "splitting an eased segment is expected to re-shape it; if this fails, the easing "
            + "model changed and the insert can now be made shape-preserving");
    }

    [AvaloniaFact]
    public void InsertedKeyframeInheritsTheSegmentItSplits()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        Channel channel = sheet.Channels["Brightness"];
        channel.Keyframes[0].InterpolationMode = InterpolationMode.Linear;
        channel.Keyframes[0].EasingDirection = EasingDirection.In;
        channel.Keyframes[0].EasingPower = 2.5;

        CurveCanvas curve = Laid(sheet);
        curve.InsertKeyframeAt(4);

        Keyframe inserted = channel.Keyframes.Single(k => Math.Abs(k.t - 4) < 0.05);
        Assert.Equal(InterpolationMode.Linear, inserted.InterpolationMode);
        Assert.Equal(EasingDirection.In, inserted.EasingDirection);
        Assert.Equal(2.5, inserted.EasingPower, 6);
    }

    [AvaloniaFact]
    public void DeletingTheLastKeyframeRemovesTheChannel()
    {
        Dopesheet sheet = SheetWith((0, 1));
        CurveCanvas curve = Laid(sheet);

        curve.DeleteKeyframe(sheet.Channels["Brightness"].Keyframes[0]);

        Assert.False(sheet.Channels.ContainsKey("Brightness"));
    }

    [AvaloniaFact]
    public void EveryMutationRecordsAnUndoStateFirst()
    {
        Dopesheet sheet = SheetWith((0, -5), (10, 5));
        CurveCanvas curve = Laid(sheet);
        int snapshots = 0;
        curve.SnapshotRequested = () => snapshots++;

        curve.InsertKeyframeAt(4);
        Assert.Equal(1, snapshots);

        curve.DeleteKeyframe(sheet.Channels["Brightness"].Keyframes[0]);
        Assert.Equal(2, snapshots);
    }

    /// <summary>
    /// The reported use case: -5 to 5, slowed through the middle of its travel.
    /// </summary>
    /// <remarks>
    /// With no tangents in the model, the two routes are easing power below 1 on an InOut
    /// keyframe, and inserting keys and spreading them in time. Both must actually work, since
    /// they are what the editor is for.
    /// </remarks>
    [Fact]
    public void SlowingTheMiddleOfTheTravelIsExpressible()
    {
        static double MiddleFraction(Channel channel)
        {
            // Share of the clip spent crossing -1..1, the middle fifth of a -5..5 travel.
            var samples = CurveCanvas.Sample(channel, 10, 2001);
            return samples.Count(s => s.Value is >= -1 and <= 1) / (double)samples.Count;
        }

        var linear = new Channel { Name = "n" };
        linear.Keyframes.Add(new Keyframe { t = 0, Value = -5, InterpolationMode = InterpolationMode.Linear, EasingDirection = EasingDirection.InOut, EasingPower = 1 });
        linear.Keyframes.Add(new Keyframe { t = 10, Value = 5, InterpolationMode = InterpolationMode.Linear, EasingDirection = EasingDirection.InOut, EasingPower = 1 });

        var eased = new Channel { Name = "n" };
        eased.Keyframes.Add(new Keyframe { t = 0, Value = -5, InterpolationMode = InterpolationMode.Linear, EasingDirection = EasingDirection.InOut, EasingPower = 0.35 });
        eased.Keyframes.Add(new Keyframe { t = 10, Value = 5, InterpolationMode = InterpolationMode.Linear, EasingDirection = EasingDirection.InOut, EasingPower = 0.35 });

        var keyed = new Channel { Name = "n" };
        foreach ((double t, double v) in new[] { (0.0, -5.0), (2.0, -1.0), (8.0, 1.0), (10.0, 5.0) })
            keyed.Keyframes.Add(new Keyframe { t = t, Value = v, InterpolationMode = InterpolationMode.Linear, EasingDirection = EasingDirection.InOut, EasingPower = 1 });

        double baseline = MiddleFraction(linear);
        Assert.InRange(baseline, 0.15, 0.25);                  // 2 of 10 units, evenly travelled
        Assert.True(MiddleFraction(eased) > baseline + 0.1,
            "InOut easing below power 1 must dwell in the middle of the travel");
        Assert.True(MiddleFraction(keyed) > 0.5,
            "spreading the -1 and 1 keys apart must dwell in the middle of the travel");
    }

    private static Channel Segment(EasingDirection direction, double power)
    {
        var channel = new Channel { Name = "n" };
        channel.Keyframes.Add(new Keyframe { t = 0, Value = 0, InterpolationMode = InterpolationMode.Linear, EasingDirection = direction, EasingPower = power });
        channel.Keyframes.Add(new Keyframe { t = 10, Value = 10, InterpolationMode = InterpolationMode.Linear, EasingDirection = direction, EasingPower = power });
        return channel;
    }

    /// <summary>
    /// Every easing must satisfy f(0)=0 and f(1)=1, so a segment starts at the previous
    /// keyframe and ends at the next one.
    /// </summary>
    /// <remarks>
    /// This is the property <see cref="EasingDirection.Out"/> violated: it was <c>1 - t^p</c>,
    /// which is 1 at t=0 and 0 at t=1, so the segment ran BACKWARDS at every power including 1.
    /// Nothing asserted the boundaries, so nothing caught it.
    /// </remarks>
    [Theory]
    [InlineData(EasingDirection.In, 0.5)]
    [InlineData(EasingDirection.In, 1)]
    [InlineData(EasingDirection.In, 3)]
    [InlineData(EasingDirection.Out, 0.5)]
    [InlineData(EasingDirection.Out, 1)]
    [InlineData(EasingDirection.Out, 3)]
    [InlineData(EasingDirection.InOut, 0.5)]
    [InlineData(EasingDirection.InOut, 1)]
    [InlineData(EasingDirection.InOut, 3)]
    public void EverySegmentRunsFromItsStartValueToItsEndValue(EasingDirection direction, double power)
    {
        Channel channel = Segment(direction, power);

        // Sampled very close in, not at the ends: t=0 and t=10 take EvaluateAt's endpoint
        // branches, which return the keyframe values without consulting the easing at all.
        // A power below 1 is near-vertical at one end, so the offset has to be small enough
        // that its steepness does not read as a boundary violation.
        Assert.Equal(0, channel.EvaluateAt(1e-6), 1);
        Assert.Equal(10, channel.EvaluateAt(10 - 1e-6), 1);
    }

    /// <summary>Easing changes the pacing within a segment, never its direction.</summary>
    [Theory]
    [InlineData(EasingDirection.In, 2)]
    [InlineData(EasingDirection.Out, 2)]
    [InlineData(EasingDirection.InOut, 2)]
    public void EasingIsMonotonic(EasingDirection direction, double power)
    {
        Channel channel = Segment(direction, power);

        double previous = double.NegativeInfinity;
        for (int i = 0; i <= 1000; i++)
        {
            double v = channel.EvaluateAt(10.0 * i / 1000);
            Assert.True(v >= previous - 1e-9,
                $"{direction} at power {power} went backwards at t={10.0 * i / 1000}");
            previous = v;
        }
    }

    /// <summary>In accelerates out of the start; Out decelerates into the end.</summary>
    [Fact]
    public void InAndOutAreMirrorsOfEachOther()
    {
        Channel easeIn = Segment(EasingDirection.In, 3);
        Channel easeOut = Segment(EasingDirection.Out, 3);

        // Ease-in is slow to leave 0, so it sits BELOW the linear midpoint at half time.
        Assert.True(easeIn.EvaluateAt(5) < 5, $"ease-in at half time was {easeIn.EvaluateAt(5)}");
        // Ease-out is quick to leave and slow to arrive, so it sits ABOVE.
        Assert.True(easeOut.EvaluateAt(5) > 5, $"ease-out at half time was {easeOut.EvaluateAt(5)}");

        // And they are reflections: in(t) == 10 - out(10-t).
        for (int i = 0; i <= 20; i++)
        {
            double t = 10.0 * i / 20;
            Assert.Equal(easeIn.EvaluateAt(t), 10 - easeOut.EvaluateAt(10 - t), 6);
        }
    }
}