using System;
using System.Collections.Generic;
using System.Linq;
using IFSEngine.Animation;
using Newtonsoft.Json;
using Xunit;
namespace IFSRenderer.AvaloniaShell.Tests;
/// <summary>
/// Blender-style keyframe types and bezier handles.
/// </summary>
/// <remarks>
/// <para>
/// Reported: "if you make value 0, to value 1 then for a few frames hold value 1, then go to
/// value 2, it's impossible to make it stay at value 1 in those frames because it wobbles up
/// and down [...] 5s and 7s, the keys are identical, if the values are identical, why does the
/// focus distance change?"
/// </para>
/// <para>
/// The answer was <see cref="InterpolationMode.CatmullRom"/>, which every keyframe gets by
/// default: its tangent is <c>(p2 - p0) / 2</c>, taken from the neighbours' VALUES alone
/// (Channel.cs:94). It never flattens, so two equal keys with unequal neighbours still get a
/// slope and the segment has to leave the held value and come back. It also ignores the time
/// axis, so uneven key spacing overshoots.
/// </para>
/// <para>
/// Blender does not have this problem because its default is Bezier with AUTO CLAMPED handles,
/// which zeroes the tangent at a local extremum. These tests pin that behaviour.
/// </para>
/// </remarks>
public class KeyframeTypeTests
{
/// <summary>
/// Builds a channel the way the app does - through the ◇ diamond - so the defaults under
/// test are the ones a user actually gets, not ones the test chose.
/// </summary>
private static Channel Keyed(params (double T, double Value)[] keys)
{
var sheet = new Dopesheet { Length = TimeSpan.FromSeconds(12), Fps = 30 };
foreach ((double t, double v) in keys)
sheet.AddOrUpdateChannel("Focus", "Camera.FocusDistance",
TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), v);
return sheet.Channels["Camera.FocusDistance"];
}
private static IEnumerable<double> Across(Channel c, double from, double to, int n = 400) =>
Enumerable.Range(0, n + 1).Select(i => c.EvaluateAt(from + (to - from) * i / n));
[Fact]
public void AHoldBetweenTwoEqualKeysStaysFlat()
{
Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2));
double worst = Across(c, 5, 7).Max(v => Math.Abs(v - 1.0));
Assert.True(worst < 1e-9,
$"held value drifted by {worst:P2} between its two identical keyframes");
}
[Fact]
public void TheHoldIsFlatWithoutFlatteningTheRampsAroundIt()
{
Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2));
// The ramps still move, monotonically, all the way to their endpoints.
Assert.True(c.EvaluateAt(4) > 0 && c.EvaluateAt(4) < 1);
Assert.True(c.EvaluateAt(8) > 1 && c.EvaluateAt(8) < 2);
Assert.Equal(0, c.EvaluateAt(3), 9);
Assert.Equal(2, c.EvaluateAt(9), 9);
}
[Fact]
public void ARampDoesNotOvershootItsEndpoints()
{
// Uneven spacing: the old tangent ignored dt entirely, so the tight pair overshot.
Channel c = Keyed((0, 0), (1, 1), (9, 2));
double max = Across(c, 0, 9).Max();
Assert.True(max <= 2 + 1e-9, $"curve reached {max}, past its highest keyframe (2)");
}
// ---------------------------------------------------------------- every preset
public static TheoryData<KeyframeType> AllPresets()
{
var data = new TheoryData<KeyframeType>();
foreach (KeyframeType t in Enum.GetValues<KeyframeType>())
if (t != KeyframeType.Legacy)
data.Add(t);
return data;
}
/// <summary>
/// Every preset but <see cref="KeyframeType.Constant"/>, which by definition does NOT
/// approach the next keyframe's value - it holds its own until the next key is reached.
/// </summary>
public static TheoryData<KeyframeType> InterpolatingPresets()
{
var data = new TheoryData<KeyframeType>();
foreach (KeyframeType t in Enum.GetValues<KeyframeType>())
if (t is not (KeyframeType.Legacy or KeyframeType.Constant))
data.Add(t);
return data;
}
/// <summary>
/// The property <see cref="EasingDirection.Out"/> violated for years: a segment has to run
/// from its own keyframe's value to the next one's. <c>1 - t^p</c> is 1 at t=0, so every
/// segment played backwards, at every power, and nothing caught it.
/// </summary>
/// <remarks>
/// The tolerance is 0.5% of the segment's 10-unit range rather than an exact match, because
/// <see cref="KeyframeType.Circular"/>'s ease-in is vertical at its end: a millionth of a
/// second from the last keyframe it is still legitimately 1.4e-4 short, and tightening this
/// past that would be asserting something untrue about a circle. The bug this guards
/// against missed by the FULL range, not by a rounding error.
/// </remarks>
[Theory]
[MemberData(nameof(InterpolatingPresets))]
public void EverySegmentRunsFromItsStartValueToItsEndValue(KeyframeType type)
{
foreach (EasingDirection direction in Enum.GetValues<EasingDirection>())
{
Channel c = Typed(type, direction, (0, -5), (10, 5));
Assert.True(Math.Abs(c.EvaluateAt(0.000001) - -5) < 0.05,
$"{type}/{direction} starts at {c.EvaluateAt(0.000001)}, not -5");
Assert.True(Math.Abs(c.EvaluateAt(9.999999) - 5) < 0.05,
$"{type}/{direction} ends at {c.EvaluateAt(9.999999)}, not 5");
}
}
/// <summary>
/// <see cref="KeyframeType.Exponential"/> specifically: Penner's form is 2^-10 at t=0, so
/// it starts a thousandth of the range ABOVE its own keyframe unless normalized.
/// </summary>
[Fact]
public void ExponentialStartsExactlyAtItsKeyframe()
{
Channel c = Typed(KeyframeType.Exponential, EasingDirection.In, (0, 0), (10, 1));
// Un-normalized this is 2^-10 = 0.00098 immediately off the keyframe, a million times
// the bound below; normalized it decays with the offset.
Assert.True(c.EvaluateAt(1e-9) < 1e-9, $"exponential starts at {c.EvaluateAt(1e-9)}");
}
[Theory]
[MemberData(nameof(AllPresets))]
public void NoPresetProducesANonFiniteValue(KeyframeType type)
{
foreach (EasingDirection direction in Enum.GetValues<EasingDirection>())
{
Channel c = Typed(type, direction, (0, -5), (3, 12), (4, 12), (10, 0));
Assert.All(Across(c, -1, 11), v => Assert.True(double.IsFinite(v), $"{type}/{direction} produced {v}"));
}
}
[Fact]
public void ConstantHoldsTheLeftKeyRightUpToTheNextOne()
{
Channel c = Typed(KeyframeType.Constant, EasingDirection.InOut, (0, 3), (10, 9));
Assert.All(Across(c, 0, 9.99), v => Assert.Equal(3, v, 9));
Assert.Equal(9, c.EvaluateAt(10), 9);
}
[Fact]
public void LinearIsAStraightLine()
{
Channel c = Typed(KeyframeType.Linear, EasingDirection.InOut, (0, 0), (10, 10));
for (double t = 0; t <= 10; t += 0.5)
Assert.Equal(t, c.EvaluateAt(t), 9);
}
/// <summary>Blender's Back and Elastic are meant to leave the range; the rest are not.</summary>
[Theory]
[InlineData(KeyframeType.Sinusoidal)]
[InlineData(KeyframeType.Quadratic)]
[InlineData(KeyframeType.Cubic)]
[InlineData(KeyframeType.Exponential)]
[InlineData(KeyframeType.Circular)]
[InlineData(KeyframeType.Bounce)]
public void AnEasingPresetStaysWithinItsEndpoints(KeyframeType type)
{
Channel c = Typed(type, EasingDirection.InOut, (0, 0), (10, 1));
Assert.All(Across(c, 0, 10), v => Assert.InRange(v, -1e-9, 1 + 1e-9));
}
// ---------------------------------------------------------------- handles
[Fact]
public void VectorHandlesMakeASegmentStraight()
{
Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
foreach (Keyframe k in c.Keyframes)
k.LeftHandleType = k.RightHandleType = HandleType.Vector;
for (double t = 0; t <= 10; t += 0.5)
Assert.Equal(t, c.EvaluateAt(t), 6);
}
/// <summary>
/// Automatic smooths through the key and is ALLOWED to overshoot; auto-clamped is the one
/// that does not. Both must still be time-aware, which the Catmull-Rom tangent was not.
/// </summary>
[Fact]
public void AutoClampedDoesNotOvershootWhereAutomaticMay()
{
Channel clamped = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (1, 10), (9, 10.5));
foreach (Keyframe k in clamped.Keyframes)
k.LeftHandleType = k.RightHandleType = HandleType.AutoClamped;
Assert.All(Across(clamped, 0, 9), v => Assert.InRange(v, -1e-9, 10.5 + 1e-9));
}
[Fact]
public void DraggingAHandleChangesTheCurveItself()
{
Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
double before = c.EvaluateAt(3);
Keyframe first = c.Keyframes[0];
first.RightHandleType = HandleType.Free;
first.RightHandleT = 3;
first.RightHandleValue = 8;
Assert.NotEqual(before, c.EvaluateAt(3), 3);
// ...and it still starts and ends where its keyframes say.
Assert.Equal(0, c.EvaluateAt(0), 9);
Assert.Equal(10, c.EvaluateAt(10), 9);
}
/// <summary>
/// Handles that reach past the segment would fold the curve back and stop it being a
/// function of time. They are shortened before the solve, so the result stays single-valued
/// and monotonic in time.
/// </summary>
[Fact]
public void OverlongHandlesDoNotFoldTheCurve()
{
Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
Keyframe a = c.Keyframes[0], b = c.Keyframes[1];
a.RightHandleType = b.LeftHandleType = HandleType.Free;
a.RightHandleT = 40;
a.RightHandleValue = 0;
b.LeftHandleT = -30;
b.LeftHandleValue = 10;
List<double> vs = Across(c, 0, 10).ToList();
Assert.All(vs, v => Assert.True(double.IsFinite(v)));
for (int i = 1; i < vs.Count; i++)
Assert.True(vs[i] >= vs[i - 1] - 1e-6, $"curve went backwards at sample {i}");
}
// ---------------------------------------------------------------- file format
/// <summary>
/// A keyframe from a file written before any of this existed has no Type property, so it
/// deserializes to <see cref="KeyframeType.Legacy"/> and evaluates through the old path.
/// Existing animations must not change shape because the app was updated.
/// </summary>
[Fact]
public void AKeyframeWithoutATypeIsLegacyAndEvaluatesAsItAlwaysDid()
{
const string legacy = """
{"Name":"Brightness","Keyframes":[
{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":0.0,"Value":0.0},
{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":5.0,"Value":1.0},
{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":7.0,"Value":1.0},
{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":9.0,"Value":2.0}],
"AudioChannelDriver":null}
""";
Channel c = JsonConvert.DeserializeObject<Channel>(legacy);
Assert.All(c.Keyframes, k => Assert.Equal(KeyframeType.Legacy, k.Type));
// Including the wobble - this is the OLD behaviour, preserved on purpose.
Assert.True(Across(c, 5, 7).Max(v => Math.Abs(v - 1.0)) > 0.04);
}
/// <summary>
/// A legacy keyframe must round-trip byte-identically, or every existing .ifsjson grows
/// eleven fields the moment it is opened and saved.
/// </summary>
[Fact]
public void ALegacyKeyframeGainsNoFieldsWhenWritten()
{
string json = JsonConvert.SerializeObject(new Keyframe { t = 1, Value = 2 });
Assert.Equal("""{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":1.0,"Value":2.0}""", json);
}
/// <summary>
/// The upstream Windows app knows nothing about <see cref="KeyframeType"/> and its
/// evaluator ends in <c>throw new NotImplementedException()</c>, so every preset must still
/// leave a legacy InterpolationMode it can read.
/// </summary>
[Theory]
[MemberData(nameof(AllPresets))]
public void EveryPresetLeavesAnInterpolationModeUpstreamCanRead(KeyframeType type)
{
var k = new Keyframe { t = 0, Value = 0, Type = type };
Assert.InRange((int)k.InterpolationMode, 0, 2);
Assert.Contains($"\"InterpolationMode\":{(int)k.InterpolationMode}",
JsonConvert.SerializeObject(k));
}
[Fact]
public void ABezierKeyframeWritesItsTypeAndHandles()
{
var k = new Keyframe
{
t = 1, Value = 2,
Type = KeyframeType.Bezier,
LeftHandleType = HandleType.Free,
RightHandleType = HandleType.Free,
LeftHandleT = 0.5, LeftHandleValue = 1.5,
RightHandleT = 1.5, RightHandleValue = 2.5,
};
Keyframe back = JsonConvert.DeserializeObject<Keyframe>(JsonConvert.SerializeObject(k));
Assert.Equal(KeyframeType.Bezier, back.Type);
Assert.Equal(HandleType.Free, back.LeftHandleType);
Assert.Equal(0.5, back.LeftHandleT, 9);
Assert.Equal(2.5, back.RightHandleValue, 9);
}
/// <summary>Builds a channel whose keyframes all carry one preset.</summary>
private static Channel Typed(KeyframeType type, EasingDirection direction,
params (double T, double Value)[] keys)
{
Channel c = Keyed(keys);
foreach (Keyframe k in c.Keyframes)
{
k.Type = type;
k.EasingDirection = direction;
}
return c;
}
}