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);
}
}
}