Files
aibis-dream/Assets/Tests/FrameAnimation/EditMode/FrameAnimationGraphAuthoringTests.cs
T
2026-07-22 00:06:59 +08:00

322 lines
16 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_ExitsOnlyWhenSelectionLeavesFocusedFlowContext()
{
var entry = AddNode("entry");
var reachable = AddNode("reachable");
var outside = AddNode("outside");
var edge = new AnimationEdge(entry.InternalId, reachable.InternalId, "focused-edge");
graph.AddEdge(edge);
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.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Clip, clip)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Source, null)), Is.True);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, Select(FrameAnimationEditorSelectionKind.Flow, flow)), Is.False);
Assert.That(FrameAnimationFlowFocusPolicy.ShouldExitForSelection(
graph, flow.Id, default), Is.False);
}
[Test]
public void FlowFocusPolicy_MixedSelectionExits_AndTopologyExpansionBecomesReachable()
{
var entry = AddNode("entry");
var inside = AddNode("inside");
var outside = AddNode("outside");
graph.AddEdge(new AnimationEdge(entry.InternalId, inside.InternalId));
var flow = new AnimationFlow("FocusFlow", "Focus Flow", entry.InternalId);
graph.AddFlow(flow);
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;
}
}
}