using System; using System.Linq; using AibisDream.FrameAnimation.Editor; using NUnit.Framework; using UnityEngine; using UnityEngine.UIElements; namespace AibisDream.FrameAnimation.Tests.EditMode { public sealed class FrameAnimationGraphAuthoringTests { private FrameAnimationGraph graph; private FrameClip clip; [SetUp] public void SetUp() { clip = ScriptableObject.CreateInstance(); clip.Configure("Idle", "Idle", new[] { new FrameAnimationFrame(null, 100, string.Empty, -1) }, 1f, FrameClipEndBehavior.HoldLastFrame); graph = ScriptableObject.CreateInstance(); graph.Configure("Graph", "Graph", new[] { clip }, Array.Empty(), Array.Empty(), Array.Empty(), string.Empty); } [TearDown] public void TearDown() { if (graph != null) UnityEngine.Object.DestroyImmediate(graph); if (clip != null) UnityEngine.Object.DestroyImmediate(clip); } [Test] public void CreateNode_AllowsMultipleReferencesToSameClip_AndStoresPosition() { Assert.That(FrameAnimationGraphMutationService.CreateNode( graph, clip, new Vector2(10f, 20f), out var first, out var firstError), Is.True, firstError); Assert.That(FrameAnimationGraphMutationService.CreateNode( graph, clip, new Vector2(40f, 50f), out var second, out var secondError), Is.True, secondError); Assert.That(first, Is.Not.SameAs(second)); Assert.That(first.ClipId, Is.EqualTo(clip.Id)); Assert.That(second.ClipId, Is.EqualTo(clip.Id)); Assert.That(FrameAnimationGraphMutationService.GetNodePosition(graph, first), Is.EqualTo(new Vector2(10f, 20f))); Assert.That(graph.EditorData.NodeEditorData.Count, Is.EqualTo(2)); } [Test] public void Connect_EnforcesSingleSuccessorSelfCycleAndTerminalRules() { var a = AddNode("a"); var b = AddNode("b"); var c = AddNode("c"); Assert.That(FrameAnimationGraphMutationService.TryConnect(graph, a, b, out var first, out var error), Is.True, error); Assert.That(first.Condition, Is.EqualTo(AnimationEdgeCondition.Always)); Assert.That(FrameAnimationGraphMutationService.TryConnect(graph, a, c, out _, out error), Is.False); StringAssert.Contains("一个后继", error); Assert.That(FrameAnimationGraphMutationService.TryConnect(graph, b, a, out _, out error), Is.False); StringAssert.Contains("环路", error); Assert.That(FrameAnimationGraphMutationService.TryConnect(graph, c, c, out _, out error), Is.False); StringAssert.Contains("自身", error); c.SetEndBehaviorOverride(FrameClipEndBehavior.Loop); Assert.That(FrameAnimationGraphMutationService.TryConnect(graph, c, a, out _, out error), Is.False); StringAssert.Contains("结束行为", error); } [Test] public void Topology_FindsSharedReachabilityAndFlowUsage() { var first = AddNode("first"); var second = AddNode("second"); var shared = AddNode("shared"); graph.AddEdge(new AnimationEdge(first.InternalId, shared.InternalId, "edge-a")); graph.AddEdge(new AnimationEdge(second.InternalId, shared.InternalId, "edge-b")); var flowA = new AnimationFlow("FlowA", "Flow A", first.InternalId); var flowB = new AnimationFlow("FlowB", "Flow B", second.InternalId); graph.AddFlow(flowA); graph.AddFlow(flowB); var topology = new FrameAnimationGraphTopology(graph); CollectionAssert.AreEquivalent(new[] { first, shared }, topology.GetReachable(first.InternalId).Nodes); CollectionAssert.AreEquivalent(new[] { flowA, flowB }, topology.FindFlowsUsingNode(shared.InternalId)); CollectionAssert.AreEqual(new[] { flowA }, topology.FindFlowsUsingEdge("edge-a")); } [Test] public void FlowCreation_UsesUniqueNamesAndEnforcesUniqueEntry() { var entry = AddNode("entry", "Idle"); var other = AddNode("other", "Idle"); Assert.That(FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, entry), Is.EqualTo("Idle_Flow")); Assert.That(FrameAnimationGraphMutationService.CreateFlow( graph, entry, "Idle_Flow", "Idle", out var flow, out var error), Is.True, error); Assert.That(FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, other), Is.EqualTo("Idle_Flow2")); Assert.That(FrameAnimationGraphMutationService.CreateFlow( graph, entry, "OtherFlow", "Other", out _, out error), Is.False); StringAssert.Contains("入口", error); Assert.That(FrameAnimationGraphMutationService.SetFlowEntry(graph, flow, other, out error), Is.True, error); Assert.That(flow.EntryNodeId, Is.EqualTo(other.InternalId)); } [Test] public void EndBehaviorAndNodeRemoval_AreAtomicForTopologyAndEditorData() { var first = AddNode("first"); var terminal = AddNode("terminal"); Assert.That(FrameAnimationGraphMutationService.TryConnect( graph, first, terminal, out _, out var error), Is.True, error); Assert.That(FrameAnimationGraphMutationService.SetNodeEndBehavior( graph, first, FrameClipEndBehavior.HoldLastFrame, true, out error), Is.True, error); Assert.That(graph.Edges, Is.Empty); Assert.That(FrameAnimationGraphMutationService.CreateFlow( graph, first, "Flow", "Flow", out _, out error), Is.True, error); Assert.That(FrameAnimationGraphMutationService.RemoveNodes( graph, new[] { first }, false, out error), Is.False); Assert.That(FrameAnimationGraphMutationService.RemoveNodes( graph, new[] { first }, true, out error), Is.True, error); Assert.That(graph.Flows, Is.Empty); Assert.That(graph.EditorData.NodeEditorData.Any(data => data.NodeId == first.InternalId), Is.False); } [Test] public void Layout_IsDeterministicLeftToRight_AndLeavesOutsideScopeUntouched() { var first = AddNode("first"); var second = AddNode("second"); var outside = AddNode("outside"); graph.AddEdge(new AnimationEdge(first.InternalId, second.InternalId)); FrameAnimationGraphMutationService.SetNodePositions(graph, new System.Collections.Generic.Dictionary { [first] = new Vector2(50f, 40f), [second] = new Vector2(500f, 400f), [outside] = new Vector2(900f, 900f) }, "Arrange Test"); var positions = FrameAnimationGraphLayoutService.Calculate(graph, new[] { first, second }); Assert.That(positions[first], Is.EqualTo(new Vector2(50f, 40f))); Assert.That(positions[second], Is.EqualTo(new Vector2(350f, 40f))); Assert.That(positions.ContainsKey(outside), Is.False); Assert.That(FrameAnimationGraphMutationService.GetNodePosition(graph, outside), Is.EqualTo(new Vector2(900f, 900f))); } [Test] public void ComprehensiveValidation_ReportsMissingAndOrphanEditorData() { var node = new AnimationNode(clip.Id, internalId: "node-without-position"); graph.AddNode(node); graph.EditorData.GetOrCreateNodeData("orphan-node", Vector2.zero); var issues = FrameAnimationEditorValidationService.Validate(graph, false, out _); Assert.That(issues.Any(issue => issue.Code == "EditorDataMissing" && ReferenceEquals(issue.Selection.Value, node)), Is.True); Assert.That(issues.Any(issue => issue.Code == "EditorDataOrphan"), Is.True); } [Test] public void GraphViewBind_RebuildsVisualsWithoutMutatingGraphData() { var first = AddNode("first"); var second = AddNode("second"); graph.AddEdge(new AnimationEdge(first.InternalId, second.InternalId, "stable-edge")); var before = JsonUtility.ToJson(graph); var view = new FrameAnimationGraphView(); view.Bind(graph, Array.Empty(), string.Empty); Assert.That(JsonUtility.ToJson(graph), Is.EqualTo(before)); Assert.That(graph.Nodes.Count, Is.EqualTo(2)); Assert.That(graph.Edges.Single().InternalId, Is.EqualTo("stable-edge")); } [Test] public void FlowFocusPolicy_ExitsForOutOfContextSelectionAndExpandsWithTopology() { var entry = AddNode("entry"); var reachable = AddNode("reachable"); var inside = AddNode("inside"); var outside = AddNode("outside"); var edge = new AnimationEdge(entry.InternalId, reachable.InternalId, "focused-edge"); graph.AddEdge(edge); graph.AddEdge(new AnimationEdge(reachable.InternalId, inside.InternalId)); var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId); graph.AddFlow(flow); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, entry)), Is.False); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, reachable)), Is.False); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Edge, edge)), Is.False); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, outside)), Is.True); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Graph, graph)), Is.True); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, flow.Id, new[] { Select(FrameAnimationEditorSelectionKind.Node, entry), Select(FrameAnimationEditorSelectionKind.Node, inside) }), Is.False); Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelectionSet(graph, flow.Id, new[] { Select(FrameAnimationEditorSelectionKind.Node, inside), Select(FrameAnimationEditorSelectionKind.Node, outside) }), Is.True); graph.AddEdge(new AnimationEdge(inside.InternalId, outside.InternalId)); Assert.That(FrameAnimationFlowFocusPolicy.Contains( graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Node, outside)), Is.True); } [Test] public void GraphViewFocus_DimsOutsideElements_AndBindDoesNotEmitSelectionChanges() { var entry = AddNode("entry"); var inside = AddNode("inside"); var outside = AddNode("outside"); var outsideTail = AddNode("outside-tail"); var edge = new AnimationEdge(entry.InternalId, inside.InternalId, "inside-edge"); var outsideEdge = new AnimationEdge(outside.InternalId, outsideTail.InternalId, "outside-edge"); graph.AddEdge(edge); graph.AddEdge(outsideEdge); var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId); graph.AddFlow(flow); var view = new FrameAnimationGraphView(); var selectionNotifications = 0; view.SelectionSetChanged += _ => selectionNotifications++; view.Bind(graph, Array.Empty(), flow.Id); var nodes = view.Query().ToList() .ToDictionary(nodeView => nodeView.Data.InternalId); var edges = view.Query().ToList() .ToDictionary(edgeView => edgeView.Data.InternalId); Assert.That(nodes[entry.InternalId].style.opacity.value, Is.EqualTo(1f)); Assert.That(nodes[inside.InternalId].style.opacity.value, Is.EqualTo(1f)); Assert.That(nodes[outside.InternalId].style.opacity.value, Is.EqualTo(0.45f)); Assert.That(edges[edge.InternalId].style.opacity.value, Is.EqualTo(1f)); Assert.That(edges[outsideEdge.InternalId].style.opacity.value, Is.EqualTo(0.30f)); Assert.That(selectionNotifications, Is.Zero); view.AddToSelection(nodes[entry.InternalId]); selectionNotifications = 0; view.Bind(graph, Array.Empty(), flow.Id); Assert.That(selectionNotifications, Is.Zero); var exitRequests = 0; view.ExitFocusRequested += () => exitRequests++; Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, false), Is.True); Assert.That(exitRequests, Is.EqualTo(1)); Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, true), Is.False); view.SetFocusedFlow(string.Empty); Assert.That(view.TryHandleFocusExitShortcut(KeyCode.Escape, false), Is.False); } [Test] public void GraphView_NodeCreatedEventFiresOnlyAfterSuccessfulCreation() { var view = new FrameAnimationGraphView(); view.Bind(graph, Array.Empty(), string.Empty); var created = 0; view.NodeCreated += _ => created++; view.CreateNode(clip, new Vector2(15f, 25f)); Assert.That(created, Is.EqualTo(1)); var foreignClip = ScriptableObject.CreateInstance(); try { foreignClip.Configure("Foreign", "Foreign", Array.Empty(), 1f, FrameClipEndBehavior.HoldLastFrame); view.CreateNode(foreignClip, Vector2.zero); Assert.That(created, Is.EqualTo(1)); } finally { UnityEngine.Object.DestroyImmediate(foreignClip); } } private static FrameAnimationEditorSelection Select( FrameAnimationEditorSelectionKind kind, object value) { return new FrameAnimationEditorSelection(kind, value); } private AnimationNode AddNode(string id, string displayName = null) { var node = new AnimationNode(clip.Id, displayName ?? id, internalId: id); graph.AddNode(node); graph.EditorData.GetOrCreateNodeData(id, Vector2.zero); return node; } } }