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