using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
///
/// 帧动画运行时模型的稳定契约。编辑器窗口与具体布局不在此处测试。
///
public sealed class FrameAnimationCoreTests
{
private readonly List createdObjects =
new List();
[TearDown]
public void TearDown()
{
foreach (var createdObject in createdObjects.Where(item => item != null))
UnityEngine.Object.DestroyImmediate(createdObject);
createdObjects.Clear();
}
[Test]
public void GraphSerialization_PreservesRuntimeIdentityAndOverrides()
{
var clip = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100);
var node = new AnimationNode(
clip.Id,
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
speedOverride: 2f);
var flow = new AnimationFlow(
"Flow",
"Flow",
node.InternalId,
FrameClipEndBehavior.Clear,
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle);
var graph = CreateGraph(
new[] { clip },
new[] { node },
Array.Empty(),
new[] { flow },
flow.Id);
var clone = Track(ScriptableObject.CreateInstance());
EditorJsonUtility.FromJsonOverwrite(EditorJsonUtility.ToJson(graph), clone);
Assert.That(clone.Nodes[0].InternalId, Is.EqualTo(node.InternalId));
Assert.That(
clone.Nodes[0].EndBehaviorOverride,
Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(clone.Nodes[0].SpeedOverride, Is.EqualTo(2f));
Assert.That(
clone.Flows[0].EndBehaviorOverride,
Is.EqualTo(FrameClipEndBehavior.Clear));
Assert.That(
clone.Flows[0].AsyncCompletionMode,
Is.EqualTo(AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle));
}
[Test]
public void InvalidTopology_IsRejectedBeforePlayback()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var first = new AnimationNode(clip.Id);
var second = new AnimationNode(clip.Id);
var third = new AnimationNode(clip.Id);
var flow = new AnimationFlow("Flow", "Flow", first.InternalId);
var graph = CreateGraph(
new[] { clip },
new[] { first, second, third },
new[]
{
new AnimationEdge(first.InternalId, second.InternalId),
new AnimationEdge(first.InternalId, third.InternalId),
new AnimationEdge(second.InternalId, first.InternalId)
},
new[] { flow },
flow.Id);
var report = FrameAnimationGraphValidator.Validate(graph);
var resolved = FrameAnimationResolver.TryResolve(
graph,
flow.Id,
default,
out _,
out var error);
Assert.That(report.HasErrors, Is.True);
Assert.That(resolved, Is.False);
Assert.That(
error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.InvalidPlayableData));
}
[Test]
public void PlaybackSession_CrossesFlowAndLoopsTerminalClip()
{
var intro = CreateClip("Intro", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 100);
var idle = CreateClip("Idle", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 100, 100);
var introNode = new AnimationNode(intro.Id);
var idleNode = new AnimationNode(
idle.Id,
endBehaviorOverride: FrameClipEndBehavior.Loop);
var flow = new AnimationFlow("IntroToIdle", "Intro To Idle", introNode.InternalId);
var graph = CreateGraph(
new[] { intro, idle },
new[] { introNode, idleNode },
new[] { new AnimationEdge(introNode.InternalId, idleNode.InternalId) },
new[] { flow },
flow.Id);
Assert.That(
FrameAnimationResolver.TryResolve(
graph,
flow.Id,
default,
out var plan,
out var error),
Is.True,
error.ToString());
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
var evaluation = session.Evaluate(1.05d, 1f, _ => { });
Assert.That(evaluation.IsCompleted, Is.False);
Assert.That(session.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(session.CurrentNodeId, Is.EqualTo(idleNode.InternalId));
Assert.That(session.CurrentFrameIndex, Is.EqualTo(2));
Assert.That(
plan.AsyncCompletionMode,
Is.EqualTo(AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart));
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_FlowAsyncCompletionHonorsTerminalLoopPolicy()
{
var intro = CreateClip("Intro", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var idle = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100);
var completeOnStartPlan = new ResolvedPlaybackPlan(
"CompleteOnStart",
true,
new[]
{
new ResolvedPlaybackStep(
intro,
"intro-node",
1f,
FrameClipEndBehavior.HoldLastFrame),
new ResolvedPlaybackStep(
idle,
"idle-node",
1f,
FrameClipEndBehavior.Loop)
},
AnimationFlowAsyncCompletionMode.CompleteOnTerminalLoopStart);
var completeOnStartSession = new FrameAnimationPlaybackSession(completeOnStartPlan);
completeOnStartSession.Start(_ => { });
Assert.That(completeOnStartSession.HasReachedAsyncCompletionPoint, Is.False);
completeOnStartSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(completeOnStartSession.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(completeOnStartSession.CurrentFrameIndex, Is.Zero);
Assert.That(completeOnStartSession.HasReachedAsyncCompletionPoint, Is.True);
var waitForCyclePlan = new ResolvedPlaybackPlan(
"WaitForCycle",
true,
completeOnStartPlan.Steps,
AnimationFlowAsyncCompletionMode.WaitForTerminalLoopFirstCycle);
var waitForCycleSession = new FrameAnimationPlaybackSession(waitForCyclePlan);
waitForCycleSession.Start(_ => { });
waitForCycleSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(waitForCycleSession.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(waitForCycleSession.HasReachedAsyncCompletionPoint, Is.False);
waitForCycleSession.Evaluate(0.1d, 1f, _ => { });
Assert.That(waitForCycleSession.HasCompletedFirstTerminalLoopCycle, Is.True);
Assert.That(waitForCycleSession.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_DirectLoopClipAlwaysWaitsForFirstCycle()
{
var loop = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, 100);
Assert.That(
FrameAnimationResolver.TryResolveClip(
loop,
default,
out var plan,
out var error),
Is.True,
error.ToString());
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.False);
session.Evaluate(0.1d, 1f, _ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
[Test]
public void PlaybackSession_FiniteFlowCompletesNaturallyForEitherPolicy()
{
var clip = CreateClip("Finite", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
foreach (AnimationFlowAsyncCompletionMode mode in Enum.GetValues(
typeof(AnimationFlowAsyncCompletionMode)))
{
var plan = new ResolvedPlaybackPlan(
$"Finite-{mode}",
true,
new[]
{
new ResolvedPlaybackStep(
clip,
"finite-node",
1f,
FrameClipEndBehavior.HoldLastFrame)
},
mode);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
Assert.That(session.HasReachedAsyncCompletionPoint, Is.False);
var evaluation = session.Evaluate(0.1d, 1f, _ => { });
Assert.That(evaluation.IsCompleted, Is.True);
Assert.That(session.HasReachedAsyncCompletionPoint, Is.True);
}
}
[Test]
public void PlaybackSession_SeekSamplesVariableDurationsAtExactBoundaries()
{
var clip = CreateClip(
"Variable",
FrameClipEndBehavior.HoldLastFrame,
1f,
100,
200,
50);
Assert.That(
FrameAnimationResolver.TryResolveClip(
clip,
default,
out var plan,
out var error),
Is.True,
error.ToString());
var session = new FrameAnimationPlaybackSession(plan);
AssertSeekFrame(session, -1d, 0, false);
AssertSeekFrame(session, 0.099d, 0, false);
AssertSeekFrame(session, 0.1d, 1, false);
AssertSeekFrame(session, 0.299d, 1, false);
AssertSeekFrame(session, 0.3d, 2, false);
AssertSeekFrame(session, 0.35d, 2, true);
}
[Test]
public void PlaybackSession_SeekSupportsBackwardMovementAndMultipleStepSpeeds()
{
var intro = CreateClip(
"Intro",
FrameClipEndBehavior.HoldLastFrame,
1f,
100,
100);
var idle = CreateClip(
"Idle",
FrameClipEndBehavior.HoldLastFrame,
1f,
100,
100);
var plan = new ResolvedPlaybackPlan(
"Flow",
true,
new[]
{
new ResolvedPlaybackStep(
intro,
"intro-node",
2f,
FrameClipEndBehavior.HoldLastFrame),
new ResolvedPlaybackStep(
idle,
"idle-node",
0.5f,
FrameClipEndBehavior.Loop)
});
var session = new FrameAnimationPlaybackSession(plan);
AssertSeekFrame(session, 0.31d, 1, false);
Assert.That(session.CurrentClipId, Is.EqualTo(idle.Id));
Assert.That(session.CurrentNodeId, Is.EqualTo("idle-node"));
AssertSeekFrame(session, 0.5d, 0, false);
Assert.That(session.HasCompletedFirstTerminalLoopCycle, Is.True);
AssertSeekFrame(session, 0.05d, 1, false);
Assert.That(session.CurrentClipId, Is.EqualTo(intro.Id));
Assert.That(session.CurrentNodeId, Is.EqualTo("intro-node"));
Assert.That(session.HasCompletedFirstTerminalLoopCycle, Is.False);
}
[Test]
public void PlaybackSession_SeekHandlesLoopCyclesAndZeroSpeed()
{
var loop = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, 100, 200);
Assert.That(
FrameAnimationResolver.TryResolveClip(
loop,
default,
out var loopPlan,
out var loopError),
Is.True,
loopError.ToString());
var loopSession = new FrameAnimationPlaybackSession(loopPlan);
AssertSeekFrame(loopSession, 0.299d, 1, false);
Assert.That(loopSession.HasCompletedFirstTerminalLoopCycle, Is.False);
AssertSeekFrame(loopSession, 0.3d, 0, false);
Assert.That(loopSession.HasCompletedFirstTerminalLoopCycle, Is.True);
AssertSeekFrame(loopSession, 0.4d, 1, false);
var stopped = CreateClip("Stopped", FrameClipEndBehavior.Loop, 0f, 100, 100);
Assert.That(
FrameAnimationResolver.TryResolveClip(
stopped,
default,
out var stoppedPlan,
out var stoppedError),
Is.True,
stoppedError.ToString());
var stoppedSession = new FrameAnimationPlaybackSession(stoppedPlan);
AssertSeekFrame(stoppedSession, 100d, 0, false);
Assert.That(stoppedSession.HasCompletedFirstTerminalLoopCycle, Is.False);
}
[Test]
public void PlaybackSession_SeekLongClipAppliesOnlyResolvedFrame()
{
var clip = CreateClip(
"Long",
FrameClipEndBehavior.HoldLastFrame,
1f,
Enumerable.Repeat(10, 1000).ToArray());
Assert.That(
FrameAnimationResolver.TryResolveClip(
clip,
default,
out var plan,
out var error),
Is.True,
error.ToString());
var session = new FrameAnimationPlaybackSession(plan);
var applyCount = 0;
var evaluation = session.Seek(9.999d, _ => applyCount++);
Assert.That(evaluation.IsCompleted, Is.False);
Assert.That(session.CurrentFrameIndex, Is.EqualTo(999));
Assert.That(applyCount, Is.EqualTo(1));
}
[Test]
public void EndBehavior_NodeOverrideHasHighestPrecedence()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var node = new AnimationNode(
clip.Id,
endBehaviorOverride: FrameClipEndBehavior.HideTarget);
var flow = new AnimationFlow(
"Flow",
"Flow",
node.InternalId,
FrameClipEndBehavior.Clear);
var graph = CreateGraph(
new[] { clip },
new[] { node },
Array.Empty(),
new[] { flow },
flow.Id);
Assert.That(
FrameAnimationResolver.TryResolve(
graph,
flow.Id,
new FrameAnimationPlayOptions(FrameClipEndBehavior.Loop),
out var plan,
out _),
Is.True);
Assert.That(
plan.Steps[0].TerminalEndBehavior,
Is.EqualTo(FrameClipEndBehavior.HideTarget));
}
[Test]
public void PlaybackHandle_CompletesExactlyOnce()
{
var handle = new FrameAnimationPlaybackHandle(42);
var callbackCount = 0;
handle.RegisterCompleted(_ => callbackCount++);
var completion = new FrameAnimationPlaybackResult(
42,
"Clip",
FrameAnimationCompletionReason.Completed,
FrameAnimationPlaybackError.None);
Assert.That(handle.TryComplete(completion), Is.True);
Assert.That(handle.TryComplete(completion), Is.False);
handle.RegisterCompleted(_ => callbackCount++);
Assert.That(callbackCount, Is.EqualTo(2));
Assert.That(handle.Result.RequestId, Is.EqualTo(42));
}
private static void AssertSeekFrame(
FrameAnimationPlaybackSession session,
double seconds,
int expectedFrameIndex,
bool expectedCompleted)
{
var applyCount = 0;
var evaluation = session.Seek(seconds, _ => applyCount++);
var snapshot = session.Snapshot;
Assert.That(evaluation.IsCompleted, Is.EqualTo(expectedCompleted));
Assert.That(snapshot.FrameIndex, Is.EqualTo(expectedFrameIndex));
Assert.That(snapshot.IsCompleted, Is.EqualTo(expectedCompleted));
Assert.That(applyCount, Is.EqualTo(1));
}
private FrameClip CreateClip(
string id,
FrameClipEndBehavior endBehavior,
float speed,
params int[] durations)
{
var clip = Track(ScriptableObject.CreateInstance());
clip.Configure(
id,
id,
durations.Select((duration, index) =>
new FrameAnimationFrame(null, duration, $"frame_{index}", index)),
speed,
endBehavior);
return clip;
}
private FrameAnimationGraph CreateGraph(
IEnumerable clips,
IEnumerable nodes,
IEnumerable edges,
IEnumerable flows,
string defaultPlayableId)
{
var graph = Track(ScriptableObject.CreateInstance());
graph.Configure(
"Graph",
"Graph",
clips,
nodes,
edges,
flows,
defaultPlayableId);
return graph;
}
private T Track(T value) where T : UnityEngine.Object
{
createdObjects.Add(value);
return value;
}
}
}