482 lines
18 KiB
C#
482 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using NUnit.Framework;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace AibisDream.FrameAnimation.Tests.EditMode
|
|
{
|
|
/// <summary>
|
|
/// 帧动画运行时模型的稳定契约。编辑器窗口与具体布局不在此处测试。
|
|
/// </summary>
|
|
public sealed class FrameAnimationCoreTests
|
|
{
|
|
private readonly List<UnityEngine.Object> createdObjects =
|
|
new List<UnityEngine.Object>();
|
|
|
|
[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<AnimationEdge>(),
|
|
new[] { flow },
|
|
flow.Id);
|
|
var clone = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
|
|
|
|
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<AnimationEdge>(),
|
|
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<FrameClip>());
|
|
clip.Configure(
|
|
id,
|
|
id,
|
|
durations.Select((duration, index) =>
|
|
new FrameAnimationFrame(null, duration, $"frame_{index}", index)),
|
|
speed,
|
|
endBehavior);
|
|
return clip;
|
|
}
|
|
|
|
private FrameAnimationGraph CreateGraph(
|
|
IEnumerable<FrameClip> clips,
|
|
IEnumerable<AnimationNode> nodes,
|
|
IEnumerable<AnimationEdge> edges,
|
|
IEnumerable<AnimationFlow> flows,
|
|
string defaultPlayableId)
|
|
{
|
|
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
|
|
graph.Configure(
|
|
"Graph",
|
|
"Graph",
|
|
clips,
|
|
nodes,
|
|
edges,
|
|
flows,
|
|
defaultPlayableId);
|
|
return graph;
|
|
}
|
|
|
|
private T Track<T>(T value) where T : UnityEngine.Object
|
|
{
|
|
createdObjects.Add(value);
|
|
return value;
|
|
}
|
|
}
|
|
}
|