304 lines
15 KiB
C#
304 lines
15 KiB
C#
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<FrameClip>();
|
|
clip.Configure("Idle", "Idle", new[] { new FrameAnimationFrame(null, 100, string.Empty, -1) },
|
|
1f, FrameClipEndBehavior.HoldLastFrame);
|
|
graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
|
graph.Configure("Graph", "Graph", new[] { clip }, Array.Empty<AnimationNode>(),
|
|
Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), 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<AnimationNode, Vector2>
|
|
{
|
|
[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<FrameAnimationEditorIssue>(), 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<FrameAnimationEditorIssue>(), flow.Id);
|
|
var nodes = view.Query<FrameAnimationClipNodeView>().ToList()
|
|
.ToDictionary(nodeView => nodeView.Data.InternalId);
|
|
var edges = view.Query<FrameAnimationEdgeView>().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<FrameAnimationEditorIssue>(), 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<FrameAnimationEditorIssue>(), 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<FrameClip>();
|
|
try
|
|
{
|
|
foreignClip.Configure("Foreign", "Foreign", Array.Empty<FrameAnimationFrame>(), 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;
|
|
}
|
|
}
|
|
}
|