using System; using System.IO; using System.Linq; using System.Text; using AibisDream.FrameAnimation.Editor; using NUnit.Framework; using UnityEditor; using UnityEngine; namespace AibisDream.FrameAnimation.Tests.EditMode { public sealed class FrameAnimationImportTests { private const string TestRoot = "Assets/Tests/FrameAnimation/GeneratedImportTests"; private const string TexturePath = TestRoot + "/Atlas.png"; private const string JsonPath = TestRoot + "/Atlas.json"; private const string GraphPath = TestRoot + "/ImportGraph.asset"; [SetUp] public void SetUp() { AssetDatabase.DeleteAsset(TestRoot); EnsureFolder(TestRoot); } [TearDown] public void TearDown() { AssetDatabase.DeleteAsset(TestRoot); AssetDatabase.Refresh(); } [Test] public void Parser_PreservesObjectAndArrayOrderAndUnicode() { Assert.That(AsepriteJsonParser.TryParse(ObjectJson(), "source", out var objectDocument, out var objectIssue), Is.True, objectIssue?.Message); Assert.That(AsepriteJsonParser.TryParse(ArrayJson(), "source", out var arrayDocument, out var arrayIssue), Is.True, arrayIssue?.Message); CollectionAssert.AreEqual( new[] { "第二帧.png", "第一帧.png" }, objectDocument.Frames.Select(frame => frame.FrameName)); CollectionAssert.AreEqual( objectDocument.Frames.Select(frame => frame.FrameName), arrayDocument.Frames.Select(frame => frame.FrameName)); CollectionAssert.AreEqual(new[] { "待机", "眨眼" }, objectDocument.Tags.Select(tag => tag.Name)); } [TestCase("forward", new[] { 0, 1, 2, 3 })] [TestCase("reverse", new[] { 3, 2, 1, 0 })] [TestCase("pingpong", new[] { 0, 1, 2, 3, 2, 1 })] [TestCase("pingpong_reverse", new[] { 3, 2, 1, 0, 1, 2 })] public void TagExpansion_IsDeterministic(string direction, int[] expected) { var tag = new AsepriteSourceTag("测试", 0, 3, direction); Assert.That(AsepriteJsonParser.TryExpandTag(tag, 4, "source", out var actual, out var issue), Is.True, issue?.Message); CollectionAssert.AreEqual(expected, actual); } [TestCase("pingpong")] [TestCase("pingpong_reverse")] public void TagExpansion_SingleFrameDoesNotDuplicateEndpoint(string direction) { var tag = new AsepriteSourceTag("单帧", 1, 1, direction); Assert.That(AsepriteJsonParser.TryExpandTag(tag, 3, "source", out var actual, out _), Is.True); CollectionAssert.AreEqual(new[] { 1 }, actual); } [Test] public void WritableRefresh_CreatesAndUpdatesStableSubAssetsAndMissingState() { var graph = CreateWritableGraph(ObjectJson()); var source = graph.ImportSources.Single(); var firstPreview = FrameAnimationImportService.PreviewAll(graph); Assert.That(firstPreview.HasErrors, Is.False, JoinIssues(firstPreview)); Assert.That(firstPreview.Sources.Single().SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Added), Is.EqualTo(2)); Assert.That(FrameAnimationImportService.Apply(firstPreview, true, out var firstError), Is.True, firstError); Assert.That(graph.Clips.Count, Is.EqualTo(2)); var idle = graph.Clips.Single(clip => clip.ImportInfo.SourceTagName == "待机"); Assert.That(AssetDatabase.IsSubAsset(idle), Is.True); Assert.That(AssetDatabase.GetAssetPath(idle), Is.EqualTo(GraphPath)); Assert.That(idle.Frames[0].FrameName, Is.EqualTo("第二帧.png")); Assert.That(source.LastSourceHash, Is.Not.Empty); Assert.That(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(idle, out _, out long firstLocalId), Is.True); var idleSerialized = new SerializedObject(idle); idleSerialized.FindProperty("id").stringValue = "用户重命名待机"; idleSerialized.FindProperty("displayName").stringValue = "自定义显示名"; idleSerialized.FindProperty("speed").floatValue = 1.75f; idleSerialized.ApplyModifiedPropertiesWithoutUndo(); WriteJson(ObjectJson(duration: 180)); var updatePreview = FrameAnimationImportService.PreviewAll(graph); Assert.That(updatePreview.HasErrors, Is.False, JoinIssues(updatePreview)); Assert.That(FrameAnimationImportService.Apply(updatePreview, true, out var updateError), Is.True, updateError); Assert.That(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(idle, out _, out long updatedLocalId), Is.True); Assert.That(updatedLocalId, Is.EqualTo(firstLocalId)); Assert.That(idle.Id, Is.EqualTo("用户重命名待机")); Assert.That(idle.DisplayName, Is.EqualTo("自定义显示名")); Assert.That(idle.Speed, Is.EqualTo(1.75f)); Assert.That(idle.Frames[0].DurationMs, Is.EqualTo(180)); WriteJson(ObjectJson(includeIdle: false)); var missingPreview = FrameAnimationImportService.PreviewAll(graph); Assert.That(FrameAnimationImportService.Apply(missingPreview, true, out var missingError), Is.True, missingError); Assert.That(idle.ImportInfo.IsMissingFromSource, Is.True); WriteJson(ObjectJson(duration: 220)); var restorePreview = FrameAnimationImportService.PreviewAll(graph); Assert.That(FrameAnimationImportService.Apply(restorePreview, true, out var restoreError), Is.True, restoreError); Assert.That(idle.ImportInfo.IsMissingFromSource, Is.False); Assert.That(idle.Frames[0].DurationMs, Is.EqualTo(220)); Assert.That(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(idle, out _, out long restoredLocalId), Is.True); Assert.That(restoredLocalId, Is.EqualTo(firstLocalId)); } [Test] public void ReadOnlyPreview_MatchesExistingSpritesWithoutChangingImporter() { var writableGraph = CreateWritableGraph(ObjectJson()); var writablePreview = FrameAnimationImportService.PreviewAll(writableGraph); Assert.That(FrameAnimationImportService.Apply(writablePreview, true, out var applyError), Is.True, applyError); var importer = (TextureImporter)AssetImporter.GetAtPath(TexturePath); var before = EditorJsonUtility.ToJson(importer); var readOnlyGraph = ScriptableObject.CreateInstance(); readOnlyGraph.Configure("ReadOnly", "Read Only", Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), string.Empty); readOnlyGraph.AddImportSource(new FrameAnimationImportSource( "Read Only", AssetDatabase.LoadAssetAtPath(TexturePath), AssetDatabase.LoadAssetAtPath(JsonPath), new Vector2(0.5f, 0.5f), false)); AssetDatabase.CreateAsset(readOnlyGraph, TestRoot + "/ReadOnlyGraph.asset"); AssetDatabase.SaveAssets(); var preview = FrameAnimationImportService.PreviewAll(readOnlyGraph); Assert.That(preview.HasErrors, Is.False, JoinIssues(preview)); Assert.That(preview.HasSpriteChanges, Is.False); Assert.That(EditorJsonUtility.ToJson(importer), Is.EqualTo(before)); } [Test] public void Preview_BlocksMultipleWritableOwnersWithoutMutation() { var graph = CreateWritableGraph(ObjectJson()); var source = graph.ImportSources.Single(); graph.AddImportSource(new FrameAnimationImportSource( "Second Owner", source.Texture, source.AsepriteJson, source.Pivot, true)); EditorUtility.SetDirty(graph); AssetDatabase.SaveAssets(); var importer = (TextureImporter)AssetImporter.GetAtPath(TexturePath); var before = EditorJsonUtility.ToJson(importer); var preview = FrameAnimationImportService.PreviewAll(graph); Assert.That(preview.HasErrors, Is.True); Assert.That(preview.Sources.SelectMany(item => item.Issues).Any(issue => issue.Code == FrameAnimationImportIssueCode.TextureOwnershipConflict), Is.True); Assert.That(FrameAnimationImportService.Apply(preview, true, out _), Is.False); Assert.That(EditorJsonUtility.ToJson(importer), Is.EqualTo(before)); Assert.That(graph.Clips, Is.Empty); } private static FrameAnimationGraph CreateWritableGraph(string json) { CreateTexture(); WriteJson(json); var graph = ScriptableObject.CreateInstance(); graph.Configure("ImportGraph", "Import Graph", Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), string.Empty); graph.AddImportSource(new FrameAnimationImportSource( "中文来源", AssetDatabase.LoadAssetAtPath(TexturePath), AssetDatabase.LoadAssetAtPath(JsonPath), new Vector2(0.5f, 0.5f), true)); AssetDatabase.CreateAsset(graph, GraphPath); AssetDatabase.SaveAssets(); return graph; } private static void CreateTexture() { var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false); var pixels = Enumerable.Range(0, 8) .Select(index => index < 4 ? Color.red : Color.green) .ToArray(); texture.SetPixels(pixels); texture.Apply(); File.WriteAllBytes(TexturePath, texture.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(texture); AssetDatabase.ImportAsset(TexturePath, ImportAssetOptions.ForceSynchronousImport); } private static void WriteJson(string json) { File.WriteAllText(JsonPath, json, new UTF8Encoding(false)); AssetDatabase.ImportAsset(JsonPath, ImportAssetOptions.ForceSynchronousImport); } private static string ObjectJson(int duration = 120, bool includeIdle = true) { var tags = includeIdle ? "{\"name\":\"待机\",\"from\":0,\"to\":0,\"direction\":\"forward\"}," + "{\"name\":\"眨眼\",\"from\":0,\"to\":1,\"direction\":\"pingpong\"}" : "{\"name\":\"眨眼\",\"from\":0,\"to\":1,\"direction\":\"pingpong\"}"; return "{" + "\"frames\":{" + $"\"第二帧.png\":{{\"frame\":{{\"x\":0,\"y\":0,\"w\":2,\"h\":2}},\"rotated\":false,\"trimmed\":false,\"duration\":{duration},\"spriteSourceSize\":{{\"x\":0,\"y\":0,\"w\":2,\"h\":2}},\"sourceSize\":{{\"w\":2,\"h\":2}}}}," + "\"第一帧.png\":{\"frame\":{\"x\":2,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":80,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}}," + $"\"meta\":{{\"image\":\"Atlas.png\",\"size\":{{\"w\":4,\"h\":2}},\"frameTags\":[{tags}]}}" + "}"; } private static string ArrayJson() { return "{" + "\"frames\":[" + "{\"filename\":\"第二帧.png\",\"frame\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":120,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}," + "{\"filename\":\"第一帧.png\",\"frame\":{\"x\":2,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":80,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}]," + "\"meta\":{\"image\":\"Atlas.png\",\"size\":{\"w\":4,\"h\":2},\"frameTags\":[" + "{\"name\":\"待机\",\"from\":0,\"to\":0,\"direction\":\"forward\"}," + "{\"name\":\"眨眼\",\"from\":0,\"to\":1,\"direction\":\"pingpong\"}]}}"; } private static string JoinIssues(FrameAnimationImportPreview preview) { return string.Join("\n", preview.Sources.SelectMany(source => source.Issues).Select(issue => issue.Message)); } private static void EnsureFolder(string path) { var segments = path.Split('/'); var current = segments[0]; for (var index = 1; index < segments.Length; index++) { var next = current + "/" + segments[index]; if (!AssetDatabase.IsValidFolder(next)) { AssetDatabase.CreateFolder(current, segments[index]); } current = next; } } } }