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 DataModel_SerializesOptionalOverridesAndKeepsInternalIds() { var clip = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100, 100); var node = new AnimationNode( clip.Id, endBehaviorOverride: FrameClipEndBehavior.HideTarget, speedOverride: 2f); var flow = new AnimationFlow("Flow", "Flow", node.InternalId, FrameClipEndBehavior.Clear); var graph = CreateGraph("Graph", new[] { clip }, new[] { node }, Array.Empty(), new[] { flow }, flow.Id); var clone = Track(ScriptableObject.CreateInstance()); var json = EditorJsonUtility.ToJson(graph); EditorJsonUtility.FromJsonOverwrite(json, 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)); } [Test] public void Validator_ReportsDuplicatePlayableAndInvalidGraphStructure() { var first = CreateClip("Same", FrameClipEndBehavior.HoldLastFrame, 1f, 100); var second = CreateClip("Same", FrameClipEndBehavior.HoldLastFrame, 1f); var node = new AnimationNode("MissingClip", internalId: "invalid-id"); var flow = new AnimationFlow("Same", "Same Flow", node.InternalId); var graph = CreateGraph( "Invalid", new[] { first, second }, new[] { node }, Array.Empty(), new[] { flow }, "Missing"); var report = FrameAnimationGraphValidator.Validate(graph); Assert.That(report.HasErrors, Is.True); Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.PlayableIdDuplicate), Is.True); Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.ClipFramesEmpty), Is.True); Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.InternalIdInvalid), Is.True); Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.NodeClipMissing), Is.True); Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.DefaultPlayableInvalid), Is.True); } [Test] public void Resolver_RejectsCycleAndMultipleSuccessors() { var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100); var a = new AnimationNode(clip.Id); var b = new AnimationNode(clip.Id); var c = new AnimationNode(clip.Id); var flow = new AnimationFlow("Flow", "Flow", a.InternalId); var graph = CreateGraph( "Graph", new[] { clip }, new[] { a, b, c }, new[] { new AnimationEdge(a.InternalId, b.InternalId), new AnimationEdge(a.InternalId, c.InternalId), new AnimationEdge(b.InternalId, a.InternalId) }, new[] { flow }, flow.Id); var resolved = FrameAnimationResolver.TryResolve( graph, flow.Id, default, out _, out var error); Assert.That(resolved, Is.False); Assert.That(error.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.InvalidPlayableData)); } [Test] public void Session_LargeDeltaCrossesFlowAndLoopsTerminalClip() { 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( "Graph", 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)); } [Test] public void Session_ExactBoundaryAndRemainingTimeAdvanceDeterministically() { var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 200, 300); var graph = CreateGraph( "Graph", new[] { clip }, Array.Empty(), Array.Empty(), Array.Empty(), clip.Id); Assert.That(FrameAnimationResolver.TryResolve(graph, clip.Id, default, out var plan, out _), Is.True); var session = new FrameAnimationPlaybackSession(plan); session.Start(_ => { }); session.Evaluate(0.1d, 1f, _ => { }); Assert.That(session.CurrentFrameIndex, Is.EqualTo(1)); session.Evaluate(0.25d, 1f, _ => { }); Assert.That(session.CurrentFrameIndex, Is.EqualTo(2)); var completion = session.Evaluate(0.25d, 1f, _ => { }); Assert.That(completion.IsCompleted, Is.True); Assert.That(completion.EndBehavior, Is.EqualTo(FrameClipEndBehavior.HoldLastFrame)); } [Test] public void Session_UsesNodeSpeedInsteadOfClipSpeedAndKeepsPlayerMultiplier() { var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 0.5f, 100, 100); var node = new AnimationNode(clip.Id, speedOverride: 2f); var flow = new AnimationFlow("Flow", "Flow", node.InternalId); var graph = CreateGraph( "Graph", new[] { clip }, new[] { node }, Array.Empty(), new[] { flow }, flow.Id); Assert.That(FrameAnimationResolver.TryResolve(graph, flow.Id, default, out var plan, out _), Is.True); var session = new FrameAnimationPlaybackSession(plan); session.Start(_ => { }); session.Evaluate(0.025d, 2f, _ => { }); Assert.That(session.CurrentFrameIndex, Is.EqualTo(1)); } [Test] public void Resolver_EndBehaviorPrecedenceIsNodeThenRequestThenFlowThenClip() { 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("Graph", 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 Handle_CompletesOnceAndLateObserversReceiveSameResult() { var handle = new FrameAnimationPlaybackHandle(42); var callbackCount = 0; FrameAnimationPlaybackResult observed = default; handle.RegisterCompleted(result => { callbackCount++; observed = result; }); Assert.Throws(() => _ = handle.Result); 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(observed.RequestId, Is.EqualTo(42)); Assert.That(handle.WaitAsync().Result.RequestId, Is.EqualTo(42)); } private FrameClip CreateClip( string id, FrameClipEndBehavior endBehavior, float speed, params int[] durations) { var clip = Track(ScriptableObject.CreateInstance()); var frames = durations.Select((duration, index) => new FrameAnimationFrame(null, duration, $"frame_{index}", index)); clip.Configure(id, id, frames, speed, endBehavior); return clip; } private FrameAnimationGraph CreateGraph( string id, IEnumerable clips, IEnumerable nodes, IEnumerable edges, IEnumerable flows, string defaultPlayableId) { var graph = Track(ScriptableObject.CreateInstance()); graph.Configure(id, id, clips, nodes, edges, flows, defaultPlayableId); return graph; } private T Track(T value) where T : UnityEngine.Object { createdObjects.Add(value); return value; } } }