using System; using System.Linq; using AibisDream.FrameAnimation.Editor; using NUnit.Framework; using UnityEditor; using UnityEngine; namespace AibisDream.FrameAnimation.Tests.EditMode { public sealed class FrameAnimationEditorWorkspaceTests { private const string TestRoot = "Assets/Tests/FrameAnimation/GeneratedWorkspaceTests"; [SetUp] public void SetUp() { Undo.ClearAll(); AssetDatabase.DeleteAsset(TestRoot); EnsureFolder(TestRoot); } [TearDown] public void TearDown() { Undo.ClearAll(); AssetDatabase.DeleteAsset(TestRoot); AssetDatabase.Refresh(); } [Test] public void ManualClip_CreateAndImportedCopy_UseExpectedOwnershipAndData() { var graph = CreateGraph("Graph.asset"); Assert.That(FrameAnimationAssetOperations.CreateManualClip( graph, "Manual", "Manual", null, out var manual, out var createError), Is.True, createError); Assert.That(AssetDatabase.IsSubAsset(manual), Is.True); Assert.That(manual.IsImported, Is.False); var imported = CreateClip("Imported", imported: true); imported.SetFrames(new[] { new FrameAnimationFrame(null, 175, "来源帧", 3) }); AssetDatabase.AddObjectToAsset(imported, graph); graph.AddClip(imported); AssetDatabase.SaveAssets(); Assert.That(FrameAnimationAssetOperations.CopyToManual( graph, imported, "Copied", "Copied", null, out var copy, out var copyError), Is.True, copyError); Assert.That(copy.IsImported, Is.False); Assert.That(copy.Frames.Count, Is.EqualTo(1)); Assert.That(copy.Frames[0], Is.Not.SameAs(imported.Frames[0])); Assert.That(copy.Frames[0].DurationMs, Is.EqualTo(175)); Assert.That(copy.Frames[0].FrameName, Is.EqualTo("来源帧")); Assert.That(copy.Frames[0].SourceIndex, Is.EqualTo(3)); } [Test] public void RenameClip_UpdatesNodeAndDefault_AndSupportsUndoRedo() { var clip = CreateClip("OldClip"); var node = new AnimationNode("OldClip", internalId: "node-id"); var graph = CreateGraph("Rename.asset", new[] { clip }, new[] { node }, defaultPlayableId: "OldClip"); Assert.That(FrameAnimationAssetOperations.RenameClip(graph, clip, "NewClip", out var error), Is.True, error); Assert.That(clip.Id, Is.EqualTo("NewClip")); Assert.That(node.ClipId, Is.EqualTo("NewClip")); Assert.That(graph.Settings.DefaultPlayableId, Is.EqualTo("NewClip")); Undo.FlushUndoRecordObjects(); Undo.PerformUndo(); Assert.That(clip.Id, Is.EqualTo("OldClip")); Assert.That(graph.Nodes.Single().ClipId, Is.EqualTo("OldClip")); Assert.That(graph.Settings.DefaultPlayableId, Is.EqualTo("OldClip")); Undo.PerformRedo(); Assert.That(clip.Id, Is.EqualTo("NewClip")); Assert.That(graph.Nodes.Single().ClipId, Is.EqualTo("NewClip")); } [Test] public void RenameSharedExternalManualClip_IsBlocked() { var clip = CreateClip("Shared"); AssetDatabase.CreateAsset(clip, TestRoot + "/Shared.asset"); var first = CreateGraph("First.asset", new[] { clip }); var second = CreateGraph("Second.asset", new[] { clip }); AssetDatabase.SaveAssets(); Assert.That(FrameAnimationAssetOperations.RenameClip(first, clip, "Renamed", out var error), Is.False); StringAssert.Contains(first.name, error); StringAssert.Contains(second.name, error); Assert.That(clip.Id, Is.EqualTo("Shared")); } [Test] public void RemoveClip_ProtectsReferences_ThenDeletesOwnedSubAsset() { var clip = CreateClip("Protected"); var node = new AnimationNode("Protected", internalId: "node-id"); var graph = CreateGraph("Remove.asset", new[] { clip }, new[] { node }); Assert.That(FrameAnimationAssetOperations.RemoveClip(graph, clip, out var blocked), Is.False); StringAssert.Contains("AnimationNode", blocked); graph.Configure(graph.Id, graph.DisplayName, graph.Clips, Array.Empty(), graph.Edges, graph.Flows, string.Empty); Assert.That(FrameAnimationAssetOperations.RemoveClip(graph, clip, out var error), Is.True, error); Assert.That(graph.Clips, Is.Empty); Assert.That(clip == null, Is.True); } [Test] public void RemoveImportSource_IsBlockedWhileImportedClipIsAssociated() { var graph = CreateGraph("Source.asset"); var source = new FrameAnimationImportSource("Source", null, null, Vector2.one, false, sourceInternalId: "source-id"); graph.AddImportSource(source); var imported = CreateClip("Tag", imported: true, sourceId: source.InternalId); AssetDatabase.AddObjectToAsset(imported, graph); graph.AddClip(imported); AssetDatabase.SaveAssets(); Assert.That(FrameAnimationAssetOperations.RemoveSource(graph, source, out var error), Is.False); StringAssert.Contains("Tag", error); Assert.That(graph.ImportSources, Contains.Item(source)); } [Test] public void RenameFlow_UpdatesDefaultAndEditorReferenceContract() { var flow = new AnimationFlow("OldFlow", "Old Flow", "entry"); var graph = CreateGraph("Flow.asset", flows: new[] { flow }, defaultPlayableId: "OldFlow"); graph.EditorData.GetOrCreateFlowData("OldFlow", Color.red); Assert.That(FrameAnimationAssetOperations.RenameFlow(graph, flow, "NewFlow", out var error), Is.True, error); Assert.That(flow.Id, Is.EqualTo("NewFlow")); Assert.That(graph.Settings.DefaultPlayableId, Is.EqualTo("NewFlow")); Assert.That(graph.EditorData.FlowEditorData.Single().FlowId, Is.EqualTo("NewFlow")); } [Test] public void ResourceQuery_SearchFilterAndSort_AreStable() { var a = CreateClip("A", displayName: "Alpha"); a.SetFrames(new[] { new FrameAnimationFrame(null, 200, string.Empty, -1) }); var b = CreateClip("B", displayName: "Beta"); b.SetFrames(new[] { new FrameAnimationFrame(null, 100, string.Empty, -1), new FrameAnimationFrame(null, 100, string.Empty, -1) }); var node = new AnimationNode("B", internalId: "node-id"); var graph = CreateGraph("Query.asset", new[] { b, a }, new[] { node }); CollectionAssert.AreEqual(new[] { "A", "B" }, FrameAnimationResourceQuery.QueryClips(graph, string.Empty, FrameAnimationClipFilter.All, FrameAnimationClipSort.Name).Select(clip => clip.Id)); CollectionAssert.AreEqual(new[] { "B" }, FrameAnimationResourceQuery.QueryClips(graph, "Beta", FrameAnimationClipFilter.Referenced, FrameAnimationClipSort.ReferenceCount).Select(clip => clip.Id)); CollectionAssert.AreEqual(new[] { "A" }, FrameAnimationResourceQuery.QueryClips(graph, string.Empty, FrameAnimationClipFilter.Unused, FrameAnimationClipSort.Name).Select(clip => clip.Id)); } [Test] public void ComprehensiveValidation_FindsDuplicateClipObjectReference() { var clip = CreateClip("Duplicate"); var graph = CreateGraph("Validation.asset", new[] { clip, clip }); var issues = FrameAnimationEditorValidationService.Validate(graph, false, out _); Assert.That(issues.Any(issue => issue.Code == "DuplicateClipReference"), Is.True); } private static FrameAnimationGraph CreateGraph( string fileName, FrameClip[] clips = null, AnimationNode[] nodes = null, AnimationFlow[] flows = null, string defaultPlayableId = "") { var graph = ScriptableObject.CreateInstance(); graph.Configure(PathId(fileName), PathId(fileName), clips ?? Array.Empty(), nodes ?? Array.Empty(), Array.Empty(), flows ?? Array.Empty(), defaultPlayableId); AssetDatabase.CreateAsset(graph, TestRoot + "/" + fileName); foreach (var clip in (clips ?? Array.Empty()).Where(clip => clip != null && string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip))).Distinct()) { AssetDatabase.AddObjectToAsset(clip, graph); } AssetDatabase.SaveAssets(); return graph; } private static FrameClip CreateClip( string id, string displayName = null, bool imported = false, string sourceId = "source-id") { var clip = ScriptableObject.CreateInstance(); clip.name = id; clip.Configure(id, displayName ?? id, Array.Empty(), 1f, FrameClipEndBehavior.HoldLastFrame, imported ? new FrameClipImportInfo(sourceId, id, false) : null); return clip; } private static string PathId(string fileName) => System.IO.Path.GetFileNameWithoutExtension(fileName); 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; } } } }