using System; using System.Collections.Generic; using System.Text.RegularExpressions; using AibisDream.FrameAnimation.Timeline; using NUnit.Framework; using UnityEngine; using UnityEngine.Playables; using UnityEngine.TestTools; using UnityEngine.Timeline; using UnityEngine.UI; namespace AibisDream.FrameAnimation.Tests.EditMode { public sealed class FrameAnimationTimelineTests { private readonly List createdObjects = new List(); [TearDown] public void TearDown() { for (var index = createdObjects.Count - 1; index >= 0; index--) { if (createdObjects[index] != null) { UnityEngine.Object.DestroyImmediate(createdObjects[index]); } } createdObjects.Clear(); } [Test] public void TimelineClip_ReportsDurationAndSupportedCapabilities() { var holdClip = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 2f, null, null); var loopClip = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, null, null); var asset = Track(ScriptableObject.CreateInstance()); asset.SetFrameClipForTests(holdClip); Assert.That(asset.duration, Is.EqualTo(0.1d).Within(0.000001d)); Assert.That(asset.TrySynchronizeSourceDuration(out var synchronizedDuration), Is.True); Assert.That(synchronizedDuration, Is.EqualTo(0.1d).Within(0.000001d)); Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.False); Assert.That(asset.clipCaps.HasFlag(ClipCaps.ClipIn), Is.True); Assert.That(asset.clipCaps.HasFlag(ClipCaps.SpeedMultiplier), Is.True); Assert.That(asset.clipCaps.HasFlag(ClipCaps.Blending), Is.False); Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.False); asset.SetFrameClipForTests(loopClip); Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.True); Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.True); asset.SetFrameClipForTests(null); Assert.That(asset.duration, Is.EqualTo(1d)); } [Test] public void Track_ScrubsBothDirectionsAndHonorsClipInAndSpeedMultiplier() { var first = CreateSprite(Color.red); var second = CreateSprite(Color.blue); var clip = CreateClip("Scrub", FrameClipEndBehavior.HoldLastFrame, 1f, first, second); var playerObject = Track(new GameObject("Timeline Sprite Player")); var renderer = playerObject.AddComponent(); var player = playerObject.AddComponent(); var setup = CreateTimeline(player, clip, 0d, 0.2d); setup.Clip.clipIn = 0.05d; setup.Clip.timeScale = 2d; Evaluate(setup.Director, 0.03d); Assert.That(renderer.sprite, Is.SameAs(second)); Evaluate(setup.Director, 0d); Assert.That(renderer.sprite, Is.SameAs(first)); Evaluate(setup.Director, 0.03d); Assert.That(renderer.sprite, Is.SameAs(second)); } [Test] public void Track_GapUsesPreviousTerminalStateAndOverlapPrefersLaterClip() { var baseline = CreateSprite(Color.white); var firstSprite = CreateSprite(Color.red); var secondSprite = CreateSprite(Color.blue); var hold = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 1f, firstSprite); var later = CreateClip("Later", FrameClipEndBehavior.HoldLastFrame, 1f, secondSprite); var playerObject = Track(new GameObject("Timeline Overlap Player")); var renderer = playerObject.AddComponent(); renderer.sprite = baseline; var player = playerObject.AddComponent(); var setup = CreateTimeline(player, hold, 0.1d, 0.3d); AddClip(setup.Track, later, 0.2d, 0.1d); Evaluate(setup.Director, 0.25d); Assert.That(renderer.sprite, Is.SameAs(secondSprite)); Evaluate(setup.Director, 0.35d); Assert.That(renderer.sprite, Is.SameAs(firstSprite)); Evaluate(setup.Director, 0.45d); Assert.That(renderer.sprite, Is.SameAs(firstSprite)); Evaluate(setup.Director, -0.01d); Assert.That(renderer.sprite, Is.SameAs(baseline)); } [Test] public void Track_AppliesClearAndHideTerminalStates() { var sprite = CreateSprite(Color.green); var clear = CreateClip("Clear", FrameClipEndBehavior.Clear, 1f, sprite); var hide = CreateClip("Hide", FrameClipEndBehavior.HideTarget, 1f, sprite); var playerObject = Track(new GameObject("Timeline End States")); var renderer = playerObject.AddComponent(); var player = playerObject.AddComponent(); var setup = CreateTimeline(player, clear, 0d, 0.1d); AddClip(setup.Track, hide, 0.2d, 0.1d); Evaluate(setup.Director, 0.15d); Assert.That(renderer.enabled, Is.True); Assert.That(renderer.sprite, Is.Null); Evaluate(setup.Director, 0.35d); Assert.That(renderer.enabled, Is.False); } [Test] public void Track_SamplesImageAndZeroSpeedClipStaysOnFirstFrame() { var first = CreateSprite(Color.yellow); var second = CreateSprite(Color.black); var clip = CreateClip("Zero", FrameClipEndBehavior.HoldLastFrame, 0f, first, second); var playerObject = Track(new GameObject( "Timeline Image Player", typeof(RectTransform), typeof(CanvasRenderer))); var image = playerObject.AddComponent(); var player = playerObject.AddComponent(); var setup = CreateTimeline(player, clip, 0d, 1d); Evaluate(setup.Director, 0.75d); Assert.That(image.sprite, Is.SameAs(first)); Assert.That(setup.Asset.duration, Is.EqualTo(1d)); } [Test] public void Track_InvalidClipLogsStructuredErrorWithoutThrowing() { var invalid = CreateClip("Empty", FrameClipEndBehavior.HoldLastFrame, 1f); var playerObject = Track(new GameObject("Timeline Invalid Clip")); playerObject.AddComponent(); var player = playerObject.AddComponent(); var setup = CreateTimeline(player, invalid, 0d, 1d); LogAssert.Expect( LogType.Error, new Regex("Frame Animation Timeline 播放失败.*InvalidPlayableData")); Assert.DoesNotThrow(() => Evaluate(setup.Director, 0.25d)); } [Test] public void Track_GathersSpriteAndEnabledPropertiesForPreview() { var clip = CreateClip( "Preview", FrameClipEndBehavior.HoldLastFrame, 1f, CreateSprite(Color.white)); var spriteObject = Track(new GameObject("Timeline Sprite Preview")); spriteObject.AddComponent(); var spritePlayer = spriteObject.AddComponent(); var spriteSetup = CreateTimeline(spritePlayer, clip, 0d, 0.1d); var spriteCollector = new RecordingPropertyCollector(); spriteSetup.Track.GatherProperties(spriteSetup.Director, spriteCollector); Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Sprite")); Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Enabled")); var imageObject = Track(new GameObject( "Timeline Image Preview", typeof(RectTransform), typeof(CanvasRenderer))); imageObject.AddComponent(); var imagePlayer = imageObject.AddComponent(); var imageSetup = CreateTimeline(imagePlayer, clip, 0d, 0.1d); var imageCollector = new RecordingPropertyCollector(); imageSetup.Track.GatherProperties(imageSetup.Director, imageCollector); Assert.That(imageCollector.Properties, Does.Contain("Image:m_Sprite")); Assert.That(imageCollector.Properties, Does.Contain("Image:m_Enabled")); } private TimelineSetup CreateTimeline( FrameClipPlayer player, FrameClip clip, double start, double duration) { var timeline = Track(ScriptableObject.CreateInstance()); var track = Track(timeline.CreateTrack()); var timelineClip = AddClip(track, clip, start, duration); var directorObject = Track(new GameObject("Frame Animation Director")); var director = directorObject.AddComponent(); director.playableAsset = timeline; director.SetGenericBinding(track, player); return new TimelineSetup( director, track, timelineClip, (FrameAnimationTimelineClip)timelineClip.asset); } private TimelineClip AddClip( FrameAnimationTrack track, FrameClip clip, double start, double duration) { var timelineClip = track.CreateClip(); var asset = Track((FrameAnimationTimelineClip)timelineClip.asset); asset.SetFrameClipForTests(clip); timelineClip.start = start; timelineClip.duration = duration; return timelineClip; } private static void Evaluate(PlayableDirector director, double time) { director.time = Math.Max(0d, time); director.Evaluate(); } private FrameClip CreateClip( string id, FrameClipEndBehavior endBehavior, float speed, params Sprite[] sprites) { var clip = Track(ScriptableObject.CreateInstance()); var frames = new List(); for (var index = 0; index < sprites.Length; index++) { frames.Add(new FrameAnimationFrame(sprites[index], 100, $"frame_{index}", index)); } clip.Configure(id, id, frames, speed, endBehavior); return clip; } private Sprite CreateSprite(Color color) { var texture = Track(new Texture2D(2, 2)); texture.SetPixels(new[] { color, color, color, color }); texture.Apply(); return Track(Sprite.Create(texture, new Rect(0, 0, 2, 2), Vector2.one * 0.5f)); } private T Track(T value) where T : UnityEngine.Object { createdObjects.Add(value); return value; } private readonly struct TimelineSetup { internal PlayableDirector Director { get; } internal FrameAnimationTrack Track { get; } internal TimelineClip Clip { get; } internal FrameAnimationTimelineClip Asset { get; } internal TimelineSetup( PlayableDirector director, FrameAnimationTrack track, TimelineClip clip, FrameAnimationTimelineClip asset) { Director = director; Track = track; Clip = clip; Asset = asset; } } private sealed class RecordingPropertyCollector : IPropertyCollector { internal List Properties { get; } = new List(); public void PushActiveGameObject(GameObject gameObject) { } public void PopActiveGameObject() { } public void AddFromClip(AnimationClip clip) { } public void AddFromClips(IEnumerable clips) { } public void AddFromName(string name) where T : Component => Properties.Add($"{typeof(T).Name}:{name}"); public void AddFromName(string name) { } public void AddFromClip(GameObject obj, AnimationClip clip) { } public void AddFromClips(GameObject obj, IEnumerable clips) { } public void AddFromName(GameObject obj, string name) where T : Component => Properties.Add($"{typeof(T).Name}:{name}"); public void AddFromName(GameObject obj, string name) { } public void AddFromName(Component component, string name) { } public void AddFromComponent(GameObject obj, Component component) { } public void AddObjectProperties(UnityEngine.Object obj, AnimationClip clip) { } } } }