chore: 自动测试重构及规范

This commit is contained in:
2026-08-01 13:57:45 +08:00
parent 24c5169a10
commit ee08eab9b0
58 changed files with 1157 additions and 4971 deletions
+11
View File
@@ -67,6 +67,17 @@ Modular framework with Kit-pattern utilities:
4. Favor composition over inheritance
5. All runtime asset loading via `ResourceSystem` — never `Resources.Load`
### Unity Tests
- Unity Tests are encouraged when they provide useful feedback, but do not add tests mechanically at every layer merely because production code changed.
- Choose the lowest layer that fully protects each behavior. Add a second layer only when it covers a distinct integration risk.
- Stable tests protect accepted contracts and high-risk regressions. Prefer extending an existing domain suite; create a new permanent file or asmdef only for a genuinely new long-lived boundary.
- Tests for unfinished, exploratory, or intermediate behavior must live under `Assets/Tests/_Temporary/<Feature>/`; never mix them into permanent suites.
- Every temporary test folder must state its purpose and deletion or promotion condition. Delete it when the feature is accepted, replaced, abandoned, or adequately covered by permanent contract tests.
- Prefer EditMode when it is sufficient; use PlayMode for behavior that truly requires Unity lifecycle, frames, coroutines, physics, or Timeline runtime integration.
- Remove tests when their feature is removed, their contract is replaced, or they only mirror implementation details.
- Follow [Unity Test Guidelines](Docs/UnityTestingGuidelines.md) for placement, design, cleanup, and review criteria.
### Scene Structure
- Scenes in `Assets/Scenes/`; `Persistence` scene hosts global managers and Persistent Loader
- Fix scenes: `{Character}FixScene` (e.g. `HuoShanFixScene`, `PeipeiFixScene`, `ExpressFixScene`)
@@ -1,260 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using AibisDream.Kit;
using AibisDream.SaveSystem;
using FMOD;
using FMOD.Studio;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AudioGlobalParameterSnapshotTests
{
[Test]
public void SetFromYarn_TracksOnlySuccessfulUniqueNames()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
Assert.That(registry.SetFromYarn("reverb", 0.4f), Is.EqualTo(RESULT.OK));
Assert.That(registry.SetFromYarn("REVERB", 0.7f), Is.EqualTo(RESULT.OK));
Assert.That(
registry.SetFromYarn("missing", 1f),
Is.EqualTo(RESULT.ERR_EVENT_NOTFOUND));
Assert.That(registry.TrackedNames, Has.Count.EqualTo(1));
Assert.That(registry.TrackedNames.Single(), Is.EqualTo("reverb"));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.False);
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.False);
}
[Test]
public void CaptureFinalValues_ReadsFmodFinalValueAndRetainsFailuresForRetry()
{
var api = new FakeFmodGlobalParameterApi();
api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK);
api.Reads["missing"] = new ParameterRead(0f, 0f, RESULT.ERR_EVENT_NOTFOUND);
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("stage", 9f);
registry.SetFromYarn("missing", 1f);
var warnings = new List<string>();
var snapshot = registry.CaptureFinalValues(warnings.Add);
Assert.That(snapshot, Has.Count.EqualTo(1));
Assert.That(snapshot["stage"], Is.EqualTo(2.37f));
Assert.That(snapshot["stage"], Is.Not.EqualTo(9f));
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage", "missing" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void ResetToDefaults_RemovesOnlyParametersThatFmodResetSuccessfully()
{
var goodId = new PARAMETER_ID { data1 = 1, data2 = 2 };
var failedId = new PARAMETER_ID { data1 = 3, data2 = 4 };
var api = new FakeFmodGlobalParameterApi();
api.Descriptions["good"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = goodId, defaultvalue = 0.25f },
RESULT.OK);
api.Descriptions["failed"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = failedId, defaultvalue = 0.75f },
RESULT.OK);
api.SetByIdResults[failedId] = RESULT.ERR_INVALID_PARAM;
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("good", 1f);
registry.SetFromYarn("failed", 1f);
var warnings = new List<string>();
registry.ResetToDefaults(warnings.Add);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "failed" }));
Assert.That(api.SetByIdCalls, Has.Count.EqualTo(2));
var goodReset = api.SetByIdCalls.Single(call => call.Id.Equals(goodId));
Assert.That(goodReset.Value, Is.EqualTo(0.25f));
Assert.That(goodReset.IgnoreSeekSpeed, Is.True);
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("failed", warnings[0]);
}
[Test]
public void RestoreFinalValues_JumpsImmediatelyThenNextYarnTargetUsesSeekSpeed()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
var warnings = new List<string>();
registry.RestoreFinalValues(
new Dictionary<string, float>
{
["stage"] = 2.37f,
["missing"] = 1f
},
warnings.Add);
registry.SetFromYarn("stage", 3f);
Assert.That(api.SetByNameCalls, Has.Count.EqualTo(3));
Assert.That(api.SetByNameCalls[0].Value, Is.EqualTo(2.37f));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[1].Name, Is.EqualTo("missing"));
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[2].Value, Is.EqualTo(3f));
Assert.That(api.SetByNameCalls[2].IgnoreSeekSpeed, Is.False);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void AudioSnapshotDto_RoundTripsFinalValuesWithoutChangingSchema()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto
{
ambState = "Dream",
yarnGlobalParameters = new Dictionary<string, float>
{
["reverb"] = 0.42f,
["hs1LogStage"] = 2.37f
}
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio])
.ToObject<AudioSnapshotDto>();
Assert.That(restored.schemaVersion, Is.EqualTo(2));
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(2));
Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f));
Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f));
}
private readonly struct SetByNameCall
{
internal SetByNameCall(string name, float value, bool ignoreSeekSpeed)
{
Name = name;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal string Name { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct SetByIdCall
{
internal SetByIdCall(PARAMETER_ID id, float value, bool ignoreSeekSpeed)
{
Id = id;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal PARAMETER_ID Id { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct ParameterRead
{
internal ParameterRead(float value, float finalValue, RESULT result)
{
Value = value;
FinalValue = finalValue;
Result = result;
}
internal float Value { get; }
internal float FinalValue { get; }
internal RESULT Result { get; }
}
private readonly struct ParameterDescriptionRead
{
internal ParameterDescriptionRead(
PARAMETER_DESCRIPTION description,
RESULT result)
{
Description = description;
Result = result;
}
internal PARAMETER_DESCRIPTION Description { get; }
internal RESULT Result { get; }
}
private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi
{
internal readonly Dictionary<string, RESULT> SetByNameResults =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterRead> Reads =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterDescriptionRead> Descriptions =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<PARAMETER_ID, RESULT> SetByIdResults = new();
internal readonly List<SetByNameCall> SetByNameCalls = new();
internal readonly List<SetByIdCall> SetByIdCalls = new();
public RESULT SetParameterByName(
string name,
float value,
bool ignoreSeekSpeed)
{
SetByNameCalls.Add(new SetByNameCall(name, value, ignoreSeekSpeed));
return SetByNameResults.TryGetValue(name, out var result)
? result
: RESULT.OK;
}
public RESULT GetParameterByName(
string name,
out float value,
out float finalValue)
{
if (Reads.TryGetValue(name, out var read))
{
value = read.Value;
finalValue = read.FinalValue;
return read.Result;
}
value = 0f;
finalValue = 0f;
return RESULT.OK;
}
public RESULT GetParameterDescriptionByName(
string name,
out PARAMETER_DESCRIPTION description)
{
if (Descriptions.TryGetValue(name, out var read))
{
description = read.Description;
return read.Result;
}
description = default;
return RESULT.ERR_EVENT_NOTFOUND;
}
public RESULT SetParameterById(
PARAMETER_ID id,
float value,
bool ignoreSeekSpeed)
{
SetByIdCalls.Add(new SetByIdCall(id, value, ignoreSeekSpeed));
return SetByIdResults.TryGetValue(id, out var result)
? result
: RESULT.OK;
}
}
}
}
@@ -1,110 +0,0 @@
#if UNITY_EDITOR
using System.Linq;
using AibisDream.Kit;
using AibisDream.UI;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace AibisDream.DeveloperMode.Editor
{
public sealed class DeveloperModeCoreTests
{
[Test]
public void RuntimeLogBufferEvictsOldestAndPreservesOrder()
{
var buffer = new RuntimeLogBuffer(3);
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, "one"));
buffer.Add(LogEntry.System(LogLevel.Warning, LogCategory.Save, "two"));
buffer.Add(LogEntry.System(LogLevel.Error, LogCategory.Yarn, "three"));
buffer.Add(LogEntry.System(LogLevel.Fatal, LogCategory.Scene, "four"));
var snapshot = buffer.Snapshot();
Assert.That(snapshot.Select(item => item.Message), Is.EqualTo(new[] { "two", "three", "four" }));
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
Assert.That(snapshot.Length, Is.EqualTo(buffer.Capacity));
}
[Test]
public void RuntimeLogBufferAcceptsConcurrentWritersAndClears()
{
var buffer = new RuntimeLogBuffer(128);
System.Threading.Tasks.Parallel.For(0, 1000, index =>
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, index.ToString())));
var snapshot = buffer.Snapshot();
Assert.That(snapshot.Length, Is.EqualTo(128));
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
buffer.Clear();
Assert.That(buffer.Snapshot(), Is.Empty);
}
[Test]
public void YarnStorageTypedSettersAndDebugSnapshotAreReadOnlyAndConsistent()
{
var go = new GameObject("YarnVariableStorage Test");
try
{
var storage = go.AddComponent<YarnVariableStorage>();
storage.SetValue("$score", 12.5f);
storage.SetValue("$global_flag", true);
storage.SetValue("$name", "AIBIS");
var snapshot = DeveloperVariableSnapshot.Capture(storage, null);
Assert.That(snapshot.Select(item => item.Name),
Is.EquivalentTo(new[] { "$score", "$global_flag", "$name" }));
Assert.That(snapshot.Single(item => item.Name == "$score").Value, Is.EqualTo("12.5"));
Assert.That(snapshot.Single(item => item.Name == "$global_flag").IsGlobal, Is.True);
Assert.That(snapshot.All(item => item.HasRuntimeOverride), Is.True);
storage.ClearLocal();
var remaining = DeveloperVariableSnapshot.Capture(storage, null);
Assert.That(remaining.Select(item => item.Name), Is.EqualTo(new[] { "$global_flag" }));
storage.Clear();
Assert.That(DeveloperVariableSnapshot.Capture(storage, null), Is.Empty);
}
finally
{
Object.DestroyImmediate(go);
}
}
[Test]
public void PrefabIsConfiguredAndPersistenceReferencesIt()
{
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
const string scenePath = "Assets/Scenes/Persistence.unity";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.activeSelf, Is.False);
var panel = prefab.GetComponent<DeveloperModePanel>();
Assert.That(panel, Is.Not.Null);
Assert.That(panel.IsConfigured, Is.True);
Assert.That(AssetDatabase.GetDependencies(scenePath), Does.Contain(prefabPath));
}
[Test]
public void PanelToggleIsNonModalAndEscapeClosesIt()
{
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
var instance = Object.Instantiate(prefab);
try
{
var panel = instance.GetComponent<DeveloperModePanel>();
var originalScale = Time.timeScale;
panel.TogglePanel();
Assert.That(panel.IsOpen, Is.True);
Assert.That(Time.timeScale, Is.EqualTo(originalScale));
Assert.That(panel.HandleEscape(), Is.True);
Assert.That(panel.IsOpen, Is.False);
}
finally
{
Object.DestroyImmediate(instance);
}
}
}
}
#endif
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: b95ba9d1e80e3dc4e83bc408569c4955
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,476 +0,0 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace AibisDream.DeveloperMode.Editor.Tests
{
public sealed class TestSaveSystemTests
{
private string _root;
private TestSaveRepository _repository;
[SetUp]
public void SetUp()
{
_root = Path.Combine(
Path.GetTempPath(),
"AibisDream-TestSaveTests",
Guid.NewGuid().ToString("N"));
_repository = new TestSaveRepository(_root);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, true);
}
}
[Test]
public void Record_SameAnchor_ReplacesSnapshotAndKeepsFirstSeenOrder()
{
var first = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneOne"));
var second = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneTwo"));
var scan = _repository.Scan("1.0");
Assert.That(scan.Entries.Count, Is.EqualTo(1));
Assert.That(second.Meta.firstSeenOrder, Is.EqualTo(first.Meta.firstSeenOrder));
Assert.That(scan.Entries[0].Meta.sceneName, Is.EqualTo("SceneTwo"));
Assert.That(_repository.TryLoad(scan.Entries[0], out var snapshot, out var error), Is.True, error);
Assert.That(snapshot.scene.sceneName, Is.EqualTo("SceneTwo"));
}
[Test]
public void Record_ReplacingEarlierEntry_DoesNotMoveItsOrder()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA2"));
var entries = _repository.Scan().Entries.ToArray();
Assert.That(entries.Select(item => item.NodeName), Is.EqualTo(new[] { "NodeA", "NodeB" }));
Assert.That(entries[0].Meta.firstSeenOrder, Is.LessThan(entries[1].Meta.firstSeenOrder));
}
[Test]
public void Scan_RetainsMissingSnapshotAsInvalid()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.Delete(entry.SnapshotPath);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.Zero);
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.MissingSnapshot));
}
[Test]
public void Scan_RetainsOrphanSnapshotWithBrokenMeta()
{
var directory = Path.Combine(_root, "chapter", "broken");
Directory.CreateDirectory(directory);
File.WriteAllText(
Path.Combine(directory, TestSaveRepository.SnapshotFileName),
"{}",
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptMeta));
}
[Test]
public void Validate_CorruptSnapshot_IsRetainedAndDisabledOnNextScan()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.WriteAllText(entry.SnapshotPath, "{broken", Encoding.UTF8);
Assert.That(_repository.Validate(entry, out var error), Is.False);
var rescanned = _repository.Scan();
Assert.That(error, Does.Contain("无法解析"));
Assert.That(rescanned.InvalidCount, Is.EqualTo(1));
Assert.That(rescanned.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptSnapshot));
}
[Test]
public void Scan_RecoversCompleteStagingDirectory()
{
var final = Path.Combine(_root, "chapter", "node");
var staging = final + ".__staging";
Directory.CreateDirectory(staging);
var request = CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA");
var meta = CreateMeta(request, 1);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.MetaFileName),
JsonConvert.SerializeObject(meta),
Encoding.UTF8);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.SnapshotFileName),
SnapshotPersistence.Serialize(request.Snapshot),
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(Directory.Exists(final), Is.True);
Assert.That(Directory.Exists(staging), Is.False);
Assert.That(scan.ValidCount, Is.EqualTo(1));
}
[Test]
public void Record_ConcurrentRequests_ProducesCompleteUniqueEntries()
{
var tasks = Enumerable.Range(0, 12)
.Select(index => Task.Run(() =>
_repository.Record(CreateRequest(
"ChapterA",
"ProjectA",
$"Node{index:00}",
$"Scene{index:00}"))))
.ToArray();
Task.WaitAll(tasks);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.EqualTo(12));
Assert.That(scan.InvalidCount, Is.Zero);
Assert.That(scan.Entries.Select(item => item.Meta.firstSeenOrder).Distinct().Count(), Is.EqualTo(12));
}
[Test]
public void DeleteInvalid_RemovesOnlyInvalidEntries()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
var invalid = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
File.Delete(invalid.SnapshotPath);
var removed = _repository.DeleteInvalid(out var error);
var scan = _repository.Scan();
Assert.That(error, Is.Null);
Assert.That(removed, Is.EqualTo(1));
Assert.That(scan.ValidCount, Is.EqualTo(1));
Assert.That(scan.InvalidCount, Is.Zero);
}
[TestCase(new[] { "hub" }, true)]
[TestCase(new[] { "linear" }, true)]
[TestCase(new[] { "content" }, true)]
[TestCase(new[] { "event" }, false)]
[TestCase(new[] { "hub", "no_save" }, false)]
[TestCase(new string[0], true)]
public void SilentCoverageEvaluator_MatchesSavePointTagSemantics(string[] tags, bool expected)
{
var actual = SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(
"Node",
tags,
out _);
Assert.That(actual, Is.EqualTo(expected));
}
[TestCase(new[] { "content" }, NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode)]
[TestCase(new[] { "interaction" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "content", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "event", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "interaction", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "interaction", "no_save" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
[TestCase(new[] { "content", "event" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
public void ResolveNodePolicy_MapsNodeTypeAndModifiers(
string[] tags,
NodeSaveTiming expectedTiming,
SaveResumeMode expectedResumeMode)
{
var policy = SavePointEvaluator.ResolveNodePolicy(
"Node",
tags,
out _,
logWarnings: false);
Assert.That(policy.Timing, Is.EqualTo(expectedTiming));
Assert.That(policy.ResumeMode, Is.EqualTo(expectedResumeMode));
}
[Test]
public void Repository_StateOnlySnapshot_PreservesAnchorNode()
{
var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA");
request.Snapshot.anchor.startDialogueOnRestore = false;
request.SaveTrigger = "DialogueExit";
request.ResumeMode = nameof(SaveResumeMode.StateOnly);
request.StartDialogueOnRestore = false;
request.DedupeKey = TestSaveRecorder.BuildDedupeKey(
request.SceneSoName,
request.YarnProjectId,
request.NodeName,
request.ResumeMode);
request.EntryId = TestSaveRepository.StableHash(request.DedupeKey);
var entry = _repository.Record(request);
var loaded = _repository.TryLoad(entry, out var snapshot, out var error);
Assert.That(loaded, Is.True, error);
Assert.That(entry.NodeName, Is.EqualTo("InteractionNode"));
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.StateOnly)));
Assert.That(snapshot.anchor.nodeName, Is.EqualTo("InteractionNode"));
Assert.That(snapshot.anchor.startDialogueOnRestore, Is.False);
}
[Test]
public void SnapshotPersistence_MissingRestoreFlag_IsRejected()
{
const string json =
"{\"schemaVersion\":2,\"anchor\":{\"sceneSoName\":\"ChapterA\"," +
"\"yarnProjectId\":\"ProjectA\",\"nodeName\":\"NodeA\"}}";
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
() => SnapshotPersistence.Deserialize(json));
}
[Test]
public void SerialTaskQueue_DoesNotDropOrReorderRequests()
{
var queue = new SerialTaskQueue();
var releaseFirst = new TaskCompletionSource<bool>();
var firstStarted = new TaskCompletionSource<bool>();
var order = new List<int>();
var first = queue.Enqueue(async () =>
{
order.Add(1);
firstStarted.SetResult(true);
await releaseFirst.Task;
order.Add(2);
});
var second = queue.Enqueue(() =>
{
order.Add(3);
return Task.CompletedTask;
});
Assert.That(firstStarted.Task.Wait(TimeSpan.FromSeconds(2)), Is.True);
Assert.That(order, Is.EqualTo(new[] { 1 }));
Assert.That(queue.PendingCount, Is.EqualTo(2));
releaseFirst.SetResult(true);
Assert.That(
Task.WaitAll(
new[] { first.Completion, second.Completion },
TimeSpan.FromSeconds(2)),
Is.True);
Assert.That(first.Sequence, Is.LessThan(second.Sequence));
Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 }));
}
[Test]
public void SaveRequest_MapsResumeModeToExplicitAnchorFlag()
{
var enter = SaveRequest.NodeEnter("NodeA", "ProjectA", "ChapterA", 3, 7);
var exit = SaveRequest.DialogueExit("NodeB", "ProjectA", "ChapterA", 3, 9);
Assert.That(enter.AnchorSpec.NodeName, Is.EqualTo("NodeA"));
Assert.That(enter.AnchorSpec.StartDialogueOnRestore, Is.True);
Assert.That(exit.AnchorSpec.NodeName, Is.EqualTo("NodeB"));
Assert.That(exit.AnchorSpec.StartDialogueOnRestore, Is.False);
}
[TestCase(false, "DetourNode", true)]
[TestCase(false, "NoSaveNode", false)]
[TestCase(true, "AnotherNode", true)]
public void ValidateDialogueTiming_NonExitRequestIgnoresLaterDialogueState(
bool explicitCommand,
string currentNodeName,
bool isDialogueRunning)
{
var request = explicitCommand
? SaveRequest.Explicit("SourceNode", "ProjectA", "ChapterA", 3, 7)
: SaveRequest.NodeEnter("SourceNode", "ProjectA", "ChapterA", 3, 7);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId: 99,
currentFlowRevision: 101,
currentNodeName: currentNodeName,
isDialogueRunning: isDialogueRunning,
out var reason);
Assert.That(valid, Is.True, reason);
Assert.That(request.AnchorSpec.NodeName, Is.EqualTo("SourceNode"));
}
[Test]
public void ValidateDialogueTiming_ConsecutiveNodeEnterRequestsRemainEligible()
{
var first = SaveRequest.NodeEnter("FirstNode", "ProjectA", "ChapterA", 3, 7);
var second = SaveRequest.NodeEnter("SecondNode", "ProjectA", "ChapterA", 3, 8);
var firstValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
first,
currentDialogueRunId: 3,
currentFlowRevision: 8,
currentNodeName: "SecondNode",
isDialogueRunning: true,
out var firstReason);
var secondValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
second,
currentDialogueRunId: 3,
currentFlowRevision: 8,
currentNodeName: "SecondNode",
isDialogueRunning: true,
out var secondReason);
Assert.That(firstValid, Is.True, firstReason);
Assert.That(secondValid, Is.True, secondReason);
Assert.That(first.AnchorSpec.NodeName, Is.EqualTo("FirstNode"));
Assert.That(second.AnchorSpec.NodeName, Is.EqualTo("SecondNode"));
}
[Test]
public void ValidateDialogueTiming_DialogueExitAcceptsStableCompletedFlow()
{
var request = SaveRequest.DialogueExit(
"InteractionNode",
"ProjectA",
"ChapterA",
3,
9);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId: 3,
currentFlowRevision: 9,
currentNodeName: null,
isDialogueRunning: false,
out var reason);
Assert.That(valid, Is.True, reason);
}
[TestCase(4, 9, null, false, "dialogue run changed")]
[TestCase(3, 10, null, false, "flow revision changed")]
[TestCase(3, 9, "DetourNode", false, "current Yarn node")]
[TestCase(3, 9, "NoSaveNode", false, "current Yarn node")]
[TestCase(3, 9, "JumpTarget", true, "still running")]
public void ValidateDialogueTiming_DialogueExitRejectsChangedOrRunningFlow(
long currentDialogueRunId,
long currentFlowRevision,
string currentNodeName,
bool isDialogueRunning,
string expectedReason)
{
var request = SaveRequest.DialogueExit(
"InteractionNode",
"ProjectA",
"ChapterA",
3,
9);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId,
currentFlowRevision,
currentNodeName,
isDialogueRunning,
out var reason);
Assert.That(valid, Is.False);
Assert.That(reason, Does.Contain(expectedReason));
}
[Test]
public void InteractionLock_ComposesWithDialogueLock()
{
var gameObject = new GameObject("EventSystemEx-Test");
try
{
var eventSystem = gameObject.AddComponent<EventSystemEx>();
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
var checkpointLock = eventSystem.AcquireInteractionLock("test");
EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd);
Assert.That(eventSystem.isLocked, Is.True);
checkpointLock.Dispose();
Assert.That(eventSystem.isLocked, Is.False);
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
private static TestSaveRecordRequest CreateRequest(
string sceneSoName,
string yarnProject,
string nodeName,
string sceneName)
{
var key = TestSaveRecorder.BuildDedupeKey(sceneSoName, yarnProject, nodeName);
return new TestSaveRecordRequest
{
Snapshot = new SaveSnapshot
{
gameVersion = "1.0",
savedAt = "2026-01-01 00:00:00",
scene = new SceneSnapshotDto { sceneName = sceneName },
anchor = new AnchorSnapshot
{
sceneSoName = sceneSoName,
yarnProjectId = yarnProject,
nodeName = nodeName,
startDialogueOnRestore = true
}
},
DedupeKey = key,
EntryId = TestSaveRepository.StableHash(key),
ChapterId = sceneSoName,
ChapterTitle = sceneSoName,
SceneSoName = sceneSoName,
YarnProjectId = yarnProject,
NodeName = nodeName,
SaveTrigger = "NodeEnter",
ResumeMode = nameof(SaveResumeMode.RestartNode),
StartDialogueOnRestore = true,
SceneName = sceneName,
GameVersion = "1.0"
};
}
private static TestSaveMeta CreateMeta(TestSaveRecordRequest request, long order)
{
return new TestSaveMeta
{
entryId = request.EntryId,
dedupeKey = request.DedupeKey,
chapterId = request.ChapterId,
chapterTitle = request.ChapterTitle,
sceneSoName = request.SceneSoName,
yarnProjectId = request.YarnProjectId,
nodeName = request.NodeName,
saveTrigger = request.SaveTrigger,
resumeMode = request.ResumeMode,
startDialogueOnRestore = request.StartDialogueOnRestore,
sceneName = request.SceneName,
firstSeenOrder = order,
firstRecordedAt = "2026-01-01 00:00:00",
lastRecordedAt = "2026-01-01 00:00:00",
snapshotSchemaVersion = SaveSnapshotSchema.CurrentVersion,
gameVersion = request.GameVersion
};
}
}
}
#endif
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: cbbba0c3621cf204483f453bad2d75a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,74 +0,0 @@
using AibisDream.FrameAnimation;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEngine;
namespace AibisDream.SystemEditor.Tests
{
public sealed class FrameAnimationActorIntegrationTests
{
private const string PrefabPath =
"Assets/Prefabs/FixSystemPrefabs/FrameAnimationActor.prefab";
[Test]
public void ActorType_IncludesFrameAnimationMember()
{
Assert.That(System.Enum.IsDefined(typeof(ActorType), ActorType.FrameAnimation), Is.True);
Assert.That(
System.Enum.TryParse("FrameAnimation", out ActorType parsed),
Is.True);
Assert.That(parsed, Is.EqualTo(ActorType.FrameAnimation));
}
[Test]
public void Prefab_HasRequiredComponentsAndPersistentAddress()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.GetComponent<SpriteRenderer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationActor>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().PlayOnEnable, Is.False);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().Graph, Is.Null);
var guid = AssetDatabase.AssetPathToGUID(PrefabPath);
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(guid);
Assert.That(entry, Is.Not.Null);
Assert.That(entry.address, Is.EqualTo(ConstRef.FrameAnimationActorPrefabName));
}
[Test]
public void CaptureEntry_ReusesActorSnapshotFields()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
var actorObject = Object.Instantiate(prefab);
var slotObject = new GameObject("Slot");
try
{
var slot = slotObject.AddComponent<ActorSlot>();
slot.slotName = "test-slot";
slot.sortingLayer = 0;
slot.sortingOrder = 17;
var actor = actorObject.GetComponent<FrameAnimationActor>();
actor.Init("TestActor", slot, ActorType.FrameAnimation);
actor.Show();
ActorEntrySnapshotDto entry = actor.CaptureEntry();
Assert.That(entry.actorName, Is.EqualTo("TestActor"));
Assert.That(entry.slotName, Is.EqualTo("test-slot"));
Assert.That(entry.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
Assert.That(entry.alpha, Is.EqualTo(1f));
Assert.That(entry.stateName, Is.Empty);
}
finally
{
Object.DestroyImmediate(actorObject);
Object.DestroyImmediate(slotObject);
}
}
}
}
@@ -1,534 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.MiniGame.Language;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.Localization;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
using UnityEngine.Localization.Tables;
using UnityEngine.TestTools;
namespace AibisDream.EditorTests.Huoshan
{
public sealed class ExpressionLocalizationTests
{
private const string CatalogPath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionContentCatalog.asset";
private const string ParticleProfilePath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset";
[TestCase(null, false)]
[TestCase("", false)]
[TestCase("plain", false)]
[TestCase("L10N.key", false)]
[TestCase("l10n.key", true)]
public void LocalizedParamPrefix_IsStrict(string value, bool expected)
{
Assert.That(LocalizationKit.IsLocalizedParam(value), Is.EqualTo(expected));
}
[Test]
public void LocalizedParamKey_StripsOnlyKnownPrefix()
{
Assert.That(LocalizationKit.GetL10NParamKey("l10n.hs.exp.log1.target"),
Is.EqualTo("hs.exp.log1.target"));
Assert.That(LocalizationKit.GetL10NParamKey("raw"), Is.EqualTo("raw"));
}
[Test]
public void MissingParamResult_RequiresMatchingLocalizedReference()
{
Assert.That(
LocalizationKit.IsMissingParamResult(
"l10n.hs.exp.pool",
"⟦hs.exp.pool⟧"),
Is.True);
Assert.That(
LocalizationKit.IsMissingParamResult(
"l10n.hs.exp.pool",
"⟦another.key⟧"),
Is.False);
Assert.That(
LocalizationKit.IsMissingParamResult(
"raw",
"⟦raw⟧"),
Is.False);
}
[UnityTest]
public IEnumerator LocalizeParamAsync_UsesExplicitLocaleAndDoesNotFallback()
{
Locale chinese = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
Locale english = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("en"));
Assert.That(chinese, Is.Not.Null);
Assert.That(english, Is.Not.Null);
Locale previous = LocalizationSettings.SelectedLocale;
LocalizationSettings.SelectedLocale = english;
Task<string> chineseTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.log1.target",
chinese);
Task<string> englishTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.log1.target",
english);
while (!chineseTask.IsCompleted || !englishTask.IsCompleted)
yield return null;
LocalizationSettings.SelectedLocale = previous;
Assert.That(chineseTask.Result, Is.Not.Empty);
Assert.That(englishTask.Result, Is.Not.Empty);
Assert.That(chineseTask.Result, Is.Not.EqualTo(englishTask.Result));
}
[UnityTest]
public IEnumerator LocalizeParamAsync_ReturnsRawTextAndMissingKeyMarker()
{
Task<string> rawTask = LocalizationKit.LocalizeParamAsync("raw text");
while (!rawTask.IsCompleted)
yield return null;
Assert.That(rawTask.Result, Is.EqualTo("raw text"));
Locale chinese = LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
LogAssert.Expect(
LogType.Error,
new Regex(@"Params 缺少条目.*Key=hs\.exp\.missing.*Locale=zh-Hans"));
Task<string> missingTask = LocalizationKit.LocalizeParamAsync(
"l10n.hs.exp.missing",
chinese);
while (!missingTask.IsCompleted)
yield return null;
Assert.That(missingTask.Result, Is.EqualTo("⟦hs.exp.missing⟧"));
}
[Test]
public void LegacyCommonParams_ArePresentInUnityParams()
{
StringTableCollection collection =
LocalizationEditorSettings.GetStringTableCollection(ConstRef.ParamsTable);
Assert.That(collection, Is.Not.Null);
string[] legacyKeys = { "天空", "海报", "植物", "朋友", "情绪", "记忆", "逻辑" };
foreach (string key in legacyKeys)
Assert.That(collection.SharedData.GetEntry(key), Is.Not.Null, key);
Assert.That(AssetDatabase.LoadAssetAtPath<TextAsset>(
"Assets/StreamingAssets/Config/params.csv"), Is.Null);
}
[Test]
public void ExpressionCatalog_IsValidAndCaseSensitive()
{
ExpressionContentCatalog catalog =
AssetDatabase.LoadAssetAtPath<ExpressionContentCatalog>(CatalogPath);
Assert.That(catalog, Is.Not.Null);
Assert.That(catalog.GetValidationErrors(), Is.Empty);
Assert.That(catalog.Rounds, Is.Not.Empty);
string firstRoundId = catalog.Rounds[0].Id;
Assert.That(catalog.TryGetRound(firstRoundId, out _), Is.True);
Assert.That(
catalog.TryGetRound(firstRoundId.ToUpperInvariant(), out _),
Is.False);
Assert.That(catalog.TryGetRound("missing", out _), Is.False);
}
[Test]
public void ExpressionCatalog_RejectsDuplicateIdsAndNonLocalizedReferences()
{
ExpressionContentCatalog catalog =
ScriptableObject.CreateInstance<ExpressionContentCatalog>();
try
{
var first = new ExpressionRoundDefinition();
var second = new ExpressionRoundDefinition();
ConfigureRound(first, "log1", "l10n.target", "l10n.tokens", 1);
ConfigureRound(second, "log1", "raw", "l10n.tokens", 1);
SetPrivateField(
catalog,
"rounds",
new List<ExpressionRoundDefinition> { first, second });
string errors = string.Join("\n", catalog.GetValidationErrors());
Assert.That(errors, Does.Contain("重复"));
Assert.That(errors, Does.Contain("l10n.*"));
}
finally
{
Object.DestroyImmediate(catalog);
}
}
[Test]
public void TokenParser_TrimsDropsEmptyAndRejectsFullwidthSeparator()
{
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens(
" 哈哈 | | 嘿嘿|呵呵 ",
out var tokens),
Is.True);
Assert.That(tokens, Is.EqualTo(new[] { "哈哈", "嘿嘿", "呵呵" }));
LogAssert.Expect(LogType.Error, new Regex("全角分隔符"));
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens("哈哈|嘿嘿", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionTokens(" | | ", out _),
Is.False);
}
[Test]
public void CharacterPoolParser_PreservesUnicodeDuplicatesAndRejectsSeparators()
{
string combining = "e\u0301";
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
$" 中 中 あ {combining} 😀。 ",
out List<string> pool),
Is.True);
Assert.That(
pool,
Is.EqualTo(new[] { "中", "中", "あ", combining, "😀", "。" }));
Assert.That(
LanguageYarnCommand.TryParseExpressionPool("中|あ", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool("中|あ", out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(" \t ", out _),
Is.False);
}
[Test]
public void ParticleLanguageProfile_ResolvesExactPrefixAndFallbackModes()
{
ExpressionParticleLanguageProfile profile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
Assert.That(profile, Is.Not.Null);
Assert.That(profile.GetValidationErrors(), Is.Empty);
Assert.That(
profile.Resolve(new LocaleIdentifier("zh-Hans")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
Assert.That(
profile.Resolve(new LocaleIdentifier("ja-JP")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
foreach (string localeCode in new[] { "en", "es", "ru", "pt-BR" })
{
Assert.That(
profile.Resolve(new LocaleIdentifier(localeCode)).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Word),
localeCode);
}
Assert.That(
profile.Resolve(new LocaleIdentifier("en-US")).LocaleCode,
Is.EqualTo("en"));
LogAssert.Expect(
LogType.Warning,
new Regex(@"Locale 'zz-ZZ'.*Grapheme"));
Assert.That(
profile.Resolve(new LocaleIdentifier("zz-ZZ")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
}
[Test]
public void WordTokenizer_UsesWhitespaceBoundariesAndKeepsPunctuation()
{
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"I am not a joke",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[] { "I", "am", "not", "a", "joke" }));
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"don't self-doubt ¿Por qué? веришь?",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[]
{
"don't",
"self-doubt",
"¿Por",
"qué?",
"веришь?"
}));
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"one \t\r\n two",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[] { "one", "two" }));
}
[Test]
public void GraphemeTokenizer_PreservesCjkKanaAndCombiningCharacters()
{
const string combining = "e\u0301";
Assert.That(
ExpressionTextTokenizer.TokenizeText(
$"汉 あ {combining}",
ExpressionParticleUnitMode.Grapheme),
Is.EqualTo(new[] { "汉", "あ", combining }));
}
[Test]
public void WordPool_PreservesDuplicateWeightsAndRejectsPipeSeparators()
{
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noise doubt noise",
ExpressionParticleUnitMode.Word,
out List<string> pool),
Is.True);
Assert.That(pool, Is.EqualTo(new[] { "noise", "doubt", "noise" }));
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noise|doubt",
ExpressionParticleUnitMode.Word,
out _),
Is.False);
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
"noisedoubt",
ExpressionParticleUnitMode.Word,
out _),
Is.False);
}
[Test]
public void RoundSnapshot_IsParsedOnceAndKeepsItsLocaleAndWordUnits()
{
ExpressionParticleLanguageProfile languageProfile =
AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
ExpressionParticleLocaleSettings english =
languageProfile.Resolve(new LocaleIdentifier("en"));
Assert.That(
ExpressionRoundTextSnapshot.TryCreate(
new LocaleIdentifier("en"),
english,
"I am not a joke",
new[] { "false alarm", "false alarm" },
"noise doubt noise",
out ExpressionRoundTextSnapshot snapshot,
out string error),
Is.True,
error);
Locale previous = LocalizationSettings.SelectedLocale;
try
{
LocalizationSettings.SelectedLocale =
LocalizationEditorSettings.GetLocale(
new LocaleIdentifier("zh-Hans"));
Assert.That(snapshot.Locale.Code, Is.EqualTo("en"));
Assert.That(
snapshot.TargetUnits,
Is.EqualTo(new[] { "I", "am", "not", "a", "joke" }));
Assert.That(
snapshot.InterferencePoolUnits,
Is.EqualTo(new[] { "false", "alarm", "false", "alarm" }));
Assert.That(
snapshot.DefaultPoolUnits,
Is.EqualTo(new[] { "noise", "doubt", "noise" }));
}
finally
{
LocalizationSettings.SelectedLocale = previous;
}
}
[Test]
public void WordProfile_ScalesNonTargetCountByConfiguredScale()
{
var settings = ExpressionParticleLocaleSettings.CreateFallback();
SetPrivateField(settings, "nonTargetCountScale", 0.625f);
Assert.That(settings.ScaleNonTargetCount(16), Is.EqualTo(10));
Assert.That(settings.ScaleNonTargetCount(-1), Is.EqualTo(-1));
Assert.That(settings.ScaleNonTargetCount(0), Is.EqualTo(0));
}
[Test]
public void ParticleGeometry_UsesVisualEdgeDistanceAndShortestSeparationAxis()
{
var left = new Bounds(Vector3.zero, new Vector3(4f, 1f, 0.01f));
var right = new Bounds(
new Vector3(4.15f, 0f, 0f),
new Vector3(4f, 1f, 0.01f));
Assert.That(
ExpressionParticleGeometry.BoundsDistance(left, right),
Is.EqualTo(0.15f).Within(0.0001f));
right.center = new Vector3(3.8f, 0f, 0f);
Assert.That(
ExpressionParticleGeometry.TryGetSeparation(
left,
right,
0.1f,
out Vector2 direction,
out float overlap),
Is.True);
Assert.That(direction, Is.EqualTo(Vector2.left));
Assert.That(overlap, Is.EqualTo(0.3f).Within(0.0001f));
}
[Test]
public void TextParticle_VisualBoundsIncludesTheWholeWordForPointerDistance()
{
var gameObject = new GameObject("TextParticleBoundsTest");
try
{
CandidateParticle particle =
gameObject.AddComponent<CandidateParticle>();
particle.ForceSetUnitText("self-doubt");
Bounds bounds = particle.GetVisualWorldBounds();
Assert.That(bounds.size.x, Is.GreaterThan(0f));
Assert.That(
particle.DistanceToVisualBounds(
new Vector2(bounds.min.x, bounds.center.y)),
Is.EqualTo(0f).Within(0.0001f));
Assert.That(
particle.DistanceToVisualBounds(
new Vector2(bounds.max.x, bounds.center.y)),
Is.EqualTo(0f).Within(0.0001f));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void TextParticle_UsesConfiguredRoleAndOverridePoolsWithoutFirstFrameFallback()
{
var gameObject = new GameObject("TextParticlePoolTest");
try
{
CandidateParticle particle = gameObject.AddComponent<CandidateParticle>();
Assert.That(particle.CurrentUnitText, Is.Null.Or.Empty);
string combining = "e\u0301";
particle.SetUnitPools(
new[] { "😀" },
new[] { combining });
particle.originalIsRed = true;
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo("😀"));
particle.originalIsRed = false;
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo(combining));
particle.SetOverrideUnitPool(new[] { "。" });
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo("。"));
particle.SetOverrideUnitPool((IReadOnlyList<string>)null);
particle.InitializeRandomUnit();
Assert.That(particle.CurrentUnitText, Is.EqualTo(combining));
}
finally
{
Object.DestroyImmediate(gameObject);
}
}
[Test]
public void UnicodeTokenizer_PreservesTextElementsAndDropsWhitespace()
{
string combining = "e\u0301";
var elements =
ExpressionTextTokenizer.GetVisibleElements($"中 A あ {combining} 😀。");
Assert.That(
elements,
Is.EqualTo(new[] { "中", "A", "あ", combining, "😀", "。" }));
}
[Test]
public void UnicodeLayout_PreservesWordGapWithoutWhitespaceParticle()
{
ExpressionTextTokenizer.Layout layout =
ExpressionTextTokenizer.BuildLayout("A B", 1f, 0.6f);
Assert.That(layout.VisibleElements, Is.EqualTo(new[] { "A", "B" }));
Assert.That(layout.Offsets[0], Is.EqualTo(-0.8f).Within(0.0001f));
Assert.That(layout.Offsets[1], Is.EqualTo(0.8f).Within(0.0001f));
}
[Test]
public void UnicodeSequenceMatch_UsesWholeTextElements()
{
var source = ExpressionTextTokenizer.GetVisibleElements("A😀e\u0301。");
var fragment = ExpressionTextTokenizer.GetVisibleElements("😀e\u0301");
Assert.That(ExpressionTextTokenizer.FindVisibleSequence(source, fragment),
Is.EqualTo(1));
Assert.That(
ExpressionTextTokenizer.FindVisibleSequence(
source,
ExpressionTextTokenizer.GetVisibleElements("😀x")),
Is.EqualTo(-1));
}
[Test]
public void ExpressContentValidation_HasNoBlockingErrors()
{
var issues = ExpressionLocalizationValidator.Validate();
string errors = string.Join(
"\n",
issues
.Where(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Error)
.Select(issue => issue.Message));
Assert.That(errors, Is.Empty);
Assert.That(
issues.Any(issue =>
issue.Severity == ExpressionLocalizationIssueSeverity.Warning),
Is.True,
"未翻译 Locale 的空条目应作为待翻译警告保留。");
}
private static void ConfigureRound(
ExpressionRoundDefinition round,
string id,
string target,
string tokens,
int nonTargetCount)
{
SetPrivateField(round, "id", id);
SetPrivateField(round, "targetReference", target);
SetPrivateField(round, "tokenReference", tokens);
SetPrivateField(round, "nonTargetParticleCount", nonTargetCount);
}
private static void SetPrivateField(object target, string fieldName, object value)
{
FieldInfo field = target.GetType().GetField(
fieldName,
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null, fieldName);
field.SetValue(target, value);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: d6b8797332ac4876b66e381ea3c98723
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,192 +0,0 @@
using System;
using System.Collections;
using AibisDream.MiniGame.Language;
using NUnit.Framework;
using UnityEngine;
namespace AibisDream.EditorTests.Huoshan
{
public sealed class LogReleasePresentationTests
{
[Test]
public void LineClip_RejectsOutsideSegment()
{
Bounds bounds = new Bounds(Vector3.zero, new Vector3(2f, 2f, 0f));
bool visible = ConnectionRenderer.TryClipLineToBounds(
new Vector3(-3f, 2f),
new Vector3(3f, 2f),
bounds,
out _,
out _);
Assert.That(visible, Is.False);
}
[TestCase(-0.5f, 0f, 0.5f, 0f, -0.5f, 0f, 0.5f, 0f)]
[TestCase(-2f, 0f, 2f, 0f, -1f, 0f, 1f, 0f)]
[TestCase(0f, -2f, 0f, 0.5f, 0f, -1f, 0f, 0.5f)]
[TestCase(-2f, -2f, 2f, 2f, -1f, -1f, 1f, 1f)]
public void LineClip_ReturnsExpectedInteriorSegment(
float ax,
float ay,
float bx,
float by,
float expectedAx,
float expectedAy,
float expectedBx,
float expectedBy)
{
Bounds bounds = new Bounds(Vector3.zero, new Vector3(2f, 2f, 0f));
bool visible = ConnectionRenderer.TryClipLineToBounds(
new Vector3(ax, ay),
new Vector3(bx, by),
bounds,
out Vector3 clippedA,
out Vector3 clippedB);
Assert.That(visible, Is.True);
Assert.That(clippedA.x, Is.EqualTo(expectedAx).Within(0.0001f));
Assert.That(clippedA.y, Is.EqualTo(expectedAy).Within(0.0001f));
Assert.That(clippedB.x, Is.EqualTo(expectedBx).Within(0.0001f));
Assert.That(clippedB.y, Is.EqualTo(expectedBy).Within(0.0001f));
}
[TestCase(CompletionPhase.None, false)]
[TestCase(CompletionPhase.Completed, false)]
[TestCase(CompletionPhase.Focusing, true)]
[TestCase(CompletionPhase.FocusHolding, true)]
[TestCase(CompletionPhase.Finished, true)]
public void ConnectionClipping_OnlyStartsAfterOutput(
CompletionPhase phase,
bool expected)
{
Assert.That(LanguageParticleManager.ShouldClipConnections(phase), Is.EqualTo(expected));
}
[Test]
public void LieEdgePosition_LandsOnScreenPerimeterAndStaysInside()
{
Bounds bounds = new Bounds(Vector3.zero, new Vector3(6f, 4f, 0f));
const float inset = 0.1f;
for (int i = 0; i < 8; i++)
{
Vector3 position = LanguageParticleManager.CalculateScreenEdgePosition(
bounds,
i,
8,
inset);
Assert.That(position.x, Is.InRange(bounds.min.x, bounds.max.x));
Assert.That(position.y, Is.InRange(bounds.min.y, bounds.max.y));
bool touchesVerticalEdge =
Mathf.Abs(Mathf.Abs(position.x) - (bounds.extents.x - inset)) < 0.0001f;
bool touchesHorizontalEdge =
Mathf.Abs(Mathf.Abs(position.y) - (bounds.extents.y - inset)) < 0.0001f;
Assert.That(touchesVerticalEdge || touchesHorizontalEdge, Is.True);
}
}
[TestCase(6, 0.70f)]
[TestCase(6, 0.90f)]
[TestCase(6, 1.10f)]
public void ResolveTiming_LastCharacterCompletesWithinPreset(int characterCount, float totalDuration)
{
LanguageParticleManager.CalculateResolveTiming(
characterCount,
totalDuration,
3.5f,
0.4f,
out float characterDuration,
out float stagger);
float lastCompletion = characterDuration + stagger * (characterCount - 1);
Assert.That(lastCompletion, Is.LessThanOrEqualTo(totalDuration + 0.0001f));
Assert.That(lastCompletion, Is.EqualTo(totalDuration).Within(0.0001f));
}
[Test]
public void TmpClipRect_ConvertsWorldCornersIntoTextLocalSpace()
{
GameObject rectObject = new GameObject("ScreenRect", typeof(RectTransform));
GameObject textObject = new GameObject("TextTransform");
try
{
RectTransform rect = rectObject.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(4f, 2f);
rect.position = new Vector3(2f, -1f, 0f);
rect.rotation = Quaternion.Euler(0f, 0f, 17f);
rect.localScale = new Vector3(1.2f, 0.8f, 1f);
textObject.transform.position = new Vector3(-0.5f, 0.75f, 0f);
textObject.transform.rotation = Quaternion.Euler(0f, 0f, -11f);
textObject.transform.localScale = new Vector3(1.4f, 0.65f, 1f);
Vector4 actual = TMPRectClipper.CalculateLocalClipRect(textObject.transform, rect);
Vector3[] corners = new Vector3[4];
rect.GetWorldCorners(corners);
Vector3 first = textObject.transform.InverseTransformPoint(corners[0]);
float minX = first.x;
float minY = first.y;
float maxX = first.x;
float maxY = first.y;
for (int i = 1; i < corners.Length; i++)
{
Vector3 local = textObject.transform.InverseTransformPoint(corners[i]);
minX = Mathf.Min(minX, local.x);
minY = Mathf.Min(minY, local.y);
maxX = Mathf.Max(maxX, local.x);
maxY = Mathf.Max(maxY, local.y);
}
Assert.That(actual.x, Is.EqualTo(minX).Within(0.0001f));
Assert.That(actual.y, Is.EqualTo(minY).Within(0.0001f));
Assert.That(actual.z, Is.EqualTo(maxX).Within(0.0001f));
Assert.That(actual.w, Is.EqualTo(maxY).Within(0.0001f));
}
finally
{
UnityEngine.Object.DestroyImmediate(rectObject);
UnityEngine.Object.DestroyImmediate(textObject);
}
}
[TestCase(null, null)]
[TestCase("", null)]
[TestCase(" | ", null)]
[TestCase("没问题|冇", "没问题冇")]
[TestCase("没 问 题", "没问题")]
public void SlotMachinePool_StripsSeparatorsAndWhitespace(string input, string expected)
{
Assert.That(LanguageParticleManager.SanitizeSlotMachinePool(input), Is.EqualTo(expected));
}
[Test]
public void ActorPhraseList_PreservesWordsAndDropsEmptyEntries()
{
string[] phrases = LogReleasePresentationController.ParseActorPhrases(
" 没问题 | | 冇问题|帽问题 ");
Assert.That(phrases, Is.EqualTo(new[] { "没问题", "冇问题", "帽问题" }));
}
[Test]
public void ActorFlash_OutsideMemoryState_CompletesImmediatelyWithoutStateChange()
{
GameObject host = new GameObject("PresentationHost");
try
{
var controller = host.AddComponent<LogReleasePresentationController>();
IEnumerator routine = controller.FlashActorLie("没问题", 0.6f);
Assert.That(routine.MoveNext(), Is.False);
Assert.That(
controller.State,
Is.EqualTo(LogReleasePresentationController.PresentationState.Idle));
}
finally
{
UnityEngine.Object.DestroyImmediate(host);
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5f75b14634314395a3a67721065f86bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-152
View File
@@ -1,152 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using AibisDream.Framework;
using AibisDream.Kit;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using YarnProjectDefinition = Yarn.Compiler.Project;
namespace AibisDream.SystemEditor.Tests
{
public sealed class LocalizationExpansionTests
{
private const string FirstRuntimeChapterPath =
"Assets/ScriptableObjects/SceneSO/Demo/Day0_Prologue.asset";
private static readonly string[] AddedLocaleCodes = { "ru", "es", "pt-BR" };
private static readonly MethodInfo GetLocalizedNameMethod =
typeof(CharacterVo).GetMethod(
"GetLocalizedNameByKind",
BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly MethodInfo GetCharacterVoByKeyMethod =
typeof(YarnUtil).GetMethod(
"GetCharacterVoByKey",
BindingFlags.Static | BindingFlags.NonPublic);
[TestCase("ru", LocaleKind.Ru)]
[TestCase("ru-RU", LocaleKind.Ru)]
[TestCase("es", LocaleKind.Es)]
[TestCase("es-ES", LocaleKind.Es)]
[TestCase("pt", LocaleKind.PtBr)]
[TestCase("pt-BR", LocaleKind.PtBr)]
public void GetLocaleKind_AddedLocaleCode_ReturnsExpectedKind(
string localeCode,
LocaleKind expected)
{
Assert.That(LocalizationKit.GetLocaleKind(localeCode), Is.EqualTo(expected));
}
[TestCase(SystemLanguage.Russian, "ru")]
[TestCase(SystemLanguage.Spanish, "es")]
[TestCase(SystemLanguage.Portuguese, "pt-BR")]
public void MapSystemLanguage_AddedSystemLanguage_ReturnsSupportedLocale(
SystemLanguage systemLanguage,
string expected)
{
Assert.That(LocalizationKit.MapSystemLanguage(systemLanguage), Is.EqualTo(expected));
}
[Test]
public void CharacterVo_LocalizedName_ReturnsOnlySelectedLocaleContent()
{
Assert.That(GetLocalizedNameMethod, Is.Not.Null);
var translated = new CharacterVo
{
key = "actor_key",
ru = "Русское имя",
es = "Nombre español",
ptBr = "Nome português",
};
Assert.That(GetLocalizedName(translated, LocaleKind.Ru), Is.EqualTo(translated.ru));
Assert.That(GetLocalizedName(translated, LocaleKind.Es), Is.EqualTo(translated.es));
Assert.That(GetLocalizedName(translated, LocaleKind.PtBr), Is.EqualTo(translated.ptBr));
var untranslated = new CharacterVo { key = "actor_key" };
Assert.That(GetLocalizedName(untranslated, LocaleKind.Cn), Is.Null);
Assert.That(GetLocalizedName(untranslated, LocaleKind.En), Is.Null);
Assert.That(GetLocalizedName(untranslated, LocaleKind.Ja), Is.Null);
Assert.That(GetLocalizedName(untranslated, LocaleKind.Ru), Is.Null);
Assert.That(GetLocalizedName(untranslated, LocaleKind.Es), Is.Null);
Assert.That(GetLocalizedName(untranslated, LocaleKind.PtBr), Is.Null);
}
[Test]
public void CharacterCsv_AddedLocaleColumns_ParseSuccessfully()
{
string csvPath = Path.Combine(
Application.streamingAssetsPath,
"Config",
"character.csv");
List<Character> characters = CsvUtil.ReadAsBean<Character>(csvPath);
Assert.That(characters, Is.Not.Empty);
Character peipei = characters.Single(character => character.Key == "peipei");
Assert.That(peipei.Ru, Is.Empty);
Assert.That(peipei.Es, Is.Empty);
Assert.That(peipei.PtBr, Is.Empty);
}
[Test]
public void RuntimeYarnProjects_DeclareAddedLocales()
{
TalkSceneSO firstChapter =
AssetDatabase.LoadAssetAtPath<TalkSceneSO>(FirstRuntimeChapterPath);
Assert.That(firstChapter, Is.Not.Null);
var pending = new Queue<TalkSceneSO>();
var visited = new HashSet<TalkSceneSO>();
pending.Enqueue(firstChapter);
while (pending.Count > 0)
{
TalkSceneSO chapter = pending.Dequeue();
if (chapter == null || !visited.Add(chapter))
continue;
Assert.That(chapter.yarnProject, Is.Not.Null, chapter.name);
AssertYarnProjectDeclaresAddedLocales(chapter);
if (chapter.exits == null)
continue;
foreach (SceneExit sceneExit in chapter.exits)
{
if (sceneExit?.targetScene != null)
pending.Enqueue(sceneExit.targetScene);
}
}
Assert.That(visited, Is.Not.Empty);
}
private static string GetLocalizedName(CharacterVo character, LocaleKind localeKind)
{
return (string)GetLocalizedNameMethod.Invoke(character, new object[] { localeKind });
}
private static void AssertYarnProjectDeclaresAddedLocales(TalkSceneSO chapter)
{
string assetPath = AssetDatabase.GetAssetPath(chapter.yarnProject);
string projectRoot = Directory.GetParent(Application.dataPath).FullName;
string absolutePath = Path.GetFullPath(Path.Combine(projectRoot, assetPath));
YarnProjectDefinition project = YarnProjectDefinition.LoadFromFile(absolutePath);
foreach (string localeCode in AddedLocaleCodes)
{
Assert.That(
project.Localisation.ContainsKey(localeCode),
Is.True,
$"{chapter.name} ({assetPath}) does not declare Locale {localeCode}.");
}
}
}
}
@@ -1,98 +0,0 @@
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class PresentationSnapshotContractTests
{
[Test]
public void PresentationProviders_UseStableIdsAndRestoreInDependencyOrder()
{
var showcase = new ShowcaseSnapshotProvider();
var day2Sleep = new Day2SleepPresentationSnapshotProvider();
var playTool = new PlayToolSnapshotProvider();
var screen = new ScreenSnapshotProvider();
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
Assert.That(day2Sleep.SaveId, Is.EqualTo("day2SleepPresentation"));
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
Assert.That(showcase.RestoreOrder, Is.LessThan(day2Sleep.RestoreOrder));
Assert.That(day2Sleep.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
}
[Test]
public void PlayToolSection_RoundTripsThroughSnapshotJson()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
{
isObjVisible = true,
objPicName = "证物",
isFullScreenVisible = true,
fullScreenPicName = "教室"
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
.ToObject<PlayToolSnapshotDto>();
Assert.That(playTool.isObjVisible, Is.True);
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
Assert.That(playTool.isFullScreenVisible, Is.True);
Assert.That(playTool.fullScreenPicName, Is.EqualTo("教室"));
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
}
[Test]
public void ShowcaseAndDay2SleepSections_RoundTripSemanticState()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
{
displayMode = ShowcaseDisplayMode.Large.ToString(),
picName = "D2S星图",
isBackgroundVisible = true
};
source.sections[SnapshotProviderIds.Day2SleepPresentation] =
new Day2SleepPresentationSnapshotDto
{
mode = Day2SleepPresentationMode.LargeEffects.ToString(),
largeBlurAmount = 0.72f,
largeBlurSize = 0.015f,
largeScaleMultiplier = 1.1f,
largeAlpha = 0.4f
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
.ToObject<ShowcaseSnapshotDto>();
var day2Sleep = ((JObject)restored.sections[SnapshotProviderIds.Day2SleepPresentation])
.ToObject<Day2SleepPresentationSnapshotDto>();
Assert.That(showcase.displayMode, Is.EqualTo("Large"));
Assert.That(showcase.picName, Is.EqualTo("D2S星图"));
Assert.That(showcase.isBackgroundVisible, Is.True);
Assert.That(day2Sleep.mode, Is.EqualTo("LargeEffects"));
Assert.That(day2Sleep.largeBlurAmount, Is.EqualTo(0.72f));
Assert.That(day2Sleep.largeScaleMultiplier, Is.EqualTo(1.1f));
Assert.That(day2Sleep.largeAlpha, Is.EqualTo(0.4f));
}
[Test]
public void OldSnapshotWithoutPresentationSections_DeserializesWithoutSynthesizingSections()
{
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
"{\"schemaVersion\":1,\"sections\":{}}");
Assert.That(restored.schemaVersion, Is.EqualTo(1));
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Day2SleepPresentation), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d6e75cb49ad84a8ebe0520a529e7a67e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,50 @@
using AibisDream.FrameAnimation;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEngine;
namespace AibisDream.SystemEditor.Tests
{
public sealed class ActorPrefabContractTests
{
private const string PrefabPath =
"Assets/Prefabs/FixSystemPrefabs/FrameAnimationActor.prefab";
[Test]
public void FrameAnimationActor_PrefabAndSnapshotContractAreConsistent()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.GetComponent<SpriteRenderer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationActor>(), Is.Not.Null);
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(
AssetDatabase.AssetPathToGUID(PrefabPath));
Assert.That(entry, Is.Not.Null);
Assert.That(entry.address, Is.EqualTo(ConstRef.FrameAnimationActorPrefabName));
var actorObject = Object.Instantiate(prefab);
var slotObject = new GameObject("Actor Slot");
try
{
var slot = slotObject.AddComponent<ActorSlot>();
slot.slotName = "test-slot";
var actor = actorObject.GetComponent<FrameAnimationActor>();
actor.Init("TestActor", slot, ActorType.FrameAnimation);
var snapshot = actor.CaptureEntry();
Assert.That(snapshot.actorName, Is.EqualTo("TestActor"));
Assert.That(snapshot.slotName, Is.EqualTo("test-slot"));
Assert.That(snapshot.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
}
finally
{
Object.DestroyImmediate(actorObject);
Object.DestroyImmediate(slotObject);
}
}
}
}
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Linq;
using AibisDream.Kit;
using AibisDream.SaveSystem;
using FMOD;
using FMOD.Studio;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AudioSnapshotContractTests
{
[Test]
public void Registry_CapturesFinalValuesOnlyForSuccessfulParameters()
{
var api = new FakeFmodGlobalParameterApi();
api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK);
api.SetResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("stage", 9f);
registry.SetFromYarn("STAGE", 10f);
registry.SetFromYarn("missing", 1f);
var snapshot = registry.CaptureFinalValues(_ => { });
Assert.That(registry.TrackedNames, Is.EqualTo(new[] { "stage" }));
Assert.That(snapshot["stage"], Is.EqualTo(2.37f));
Assert.That(api.SetCalls.All(call => !call.IgnoreSeekSpeed), Is.True);
}
[Test]
public void Registry_RestoreJumpsImmediatelyThenYarnUsesSeekSpeed()
{
var api = new FakeFmodGlobalParameterApi();
var registry = new YarnGlobalParameterRegistry(api);
registry.RestoreFinalValues(
new Dictionary<string, float> { ["stage"] = 2.37f },
_ => { });
registry.SetFromYarn("stage", 3f);
Assert.That(api.SetCalls, Has.Count.EqualTo(2));
Assert.That(api.SetCalls[0].Value, Is.EqualTo(2.37f));
Assert.That(api.SetCalls[0].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetCalls[1].Value, Is.EqualTo(3f));
Assert.That(api.SetCalls[1].IgnoreSeekSpeed, Is.False);
}
[Test]
public void AudioSnapshot_RoundTripsWithoutChangingSchema()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto
{
ambState = "Dream",
yarnGlobalParameters = new Dictionary<string, float>
{
["reverb"] = 0.42f,
["hs1LogStage"] = 2.37f
}
};
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
JsonConvert.SerializeObject(source));
var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio])
.ToObject<AudioSnapshotDto>();
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f));
Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f));
}
private readonly struct SetCall
{
internal SetCall(float value, bool ignoreSeekSpeed)
{
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct ParameterRead
{
internal ParameterRead(float value, float finalValue, RESULT result)
{
Value = value;
FinalValue = finalValue;
Result = result;
}
internal float Value { get; }
internal float FinalValue { get; }
internal RESULT Result { get; }
}
private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi
{
internal readonly Dictionary<string, RESULT> SetResults =
new Dictionary<string, RESULT>(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterRead> Reads =
new Dictionary<string, ParameterRead>(System.StringComparer.OrdinalIgnoreCase);
internal readonly List<SetCall> SetCalls = new List<SetCall>();
public RESULT SetParameterByName(
string name,
float value,
bool ignoreSeekSpeed)
{
SetCalls.Add(new SetCall(value, ignoreSeekSpeed));
return SetResults.TryGetValue(name, out var result) ? result : RESULT.OK;
}
public RESULT GetParameterByName(
string name,
out float value,
out float finalValue)
{
if (Reads.TryGetValue(name, out var read))
{
value = read.Value;
finalValue = read.FinalValue;
return read.Result;
}
value = 0f;
finalValue = 0f;
return RESULT.ERR_EVENT_NOTFOUND;
}
public RESULT GetParameterDescriptionByName(
string name,
out PARAMETER_DESCRIPTION description)
{
description = default;
return RESULT.ERR_EVENT_NOTFOUND;
}
public RESULT SetParameterById(
PARAMETER_ID id,
float value,
bool ignoreSeekSpeed)
{
return RESULT.OK;
}
}
}
}
@@ -0,0 +1,131 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using AibisDream.Framework;
using AibisDream.Kit;
using AibisDream.MiniGame.Language;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.Localization;
namespace AibisDream.SystemEditor.Tests
{
public sealed class LocalizationContractTests
{
private const string ParticleProfilePath =
"Assets/GameContent/Feature_Huoshan/Expression/ExpressionParticleLanguageProfile.asset";
[Test]
public void LocaleMappings_ResolveSupportedCodesAndSystemLanguages()
{
var localeCases = new Dictionary<string, LocaleKind>
{
["ru"] = LocaleKind.Ru,
["ru-RU"] = LocaleKind.Ru,
["es"] = LocaleKind.Es,
["es-ES"] = LocaleKind.Es,
["pt"] = LocaleKind.PtBr,
["pt-BR"] = LocaleKind.PtBr
};
foreach (var pair in localeCases)
Assert.That(LocalizationKit.GetLocaleKind(pair.Key), Is.EqualTo(pair.Value), pair.Key);
Assert.That(LocalizationKit.MapSystemLanguage(SystemLanguage.Russian), Is.EqualTo("ru"));
Assert.That(LocalizationKit.MapSystemLanguage(SystemLanguage.Spanish), Is.EqualTo("es"));
Assert.That(LocalizationKit.MapSystemLanguage(SystemLanguage.Portuguese), Is.EqualTo("pt-BR"));
}
[Test]
public void UnknownCharacter_UsesItsKeyAsEveryLocalizedName()
{
var method = typeof(YarnUtil).GetMethod(
"GetCharacterVoByKey",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
const string missingKey = "missing_character_contract_test";
var character = (CharacterVo)method.Invoke(null, new object[] { missingKey });
Assert.That(character.key, Is.EqualTo(missingKey));
Assert.That(
new[]
{
character.cn,
character.en,
character.ja,
character.ru,
character.es,
character.ptBr
},
Has.All.EqualTo(missingKey));
}
[Test]
public void CharacterCsv_ParsesExpandedLocaleColumns()
{
var characters = CsvUtil.ReadAsBean<Character>(Path.Combine(
Application.streamingAssetsPath,
"Config",
"character.csv"));
Assert.That(characters, Is.Not.Empty);
var peipei = characters.Single(character => character.Key == "peipei");
Assert.That(peipei.Ru, Is.Not.Null);
Assert.That(peipei.Es, Is.Not.Null);
Assert.That(peipei.PtBr, Is.Not.Null);
}
[Test]
public void ExpressionParsing_PreservesUnicodeAndWordBoundaries()
{
const string combining = "e\u0301";
Assert.That(
LanguageYarnCommand.TryParseExpressionPool(
$"中 中 あ {combining} 😀。",
out List<string> graphemePool),
Is.True);
Assert.That(
graphemePool,
Is.EqualTo(new[] { "中", "中", "あ", combining, "😀", "。" }));
Assert.That(
ExpressionTextTokenizer.TokenizeText(
"don't self-doubt ¿Por qué? веришь?",
ExpressionParticleUnitMode.Word),
Is.EqualTo(new[]
{
"don't",
"self-doubt",
"¿Por",
"qué?",
"веришь?"
}));
}
[Test]
public void ExpressionParticleProfile_MapsLanguageFamilies()
{
var profile = AssetDatabase.LoadAssetAtPath<ExpressionParticleLanguageProfile>(
ParticleProfilePath);
Assert.That(profile, Is.Not.Null);
Assert.That(profile.GetValidationErrors(), Is.Empty);
Assert.That(
profile.Resolve(new LocaleIdentifier("zh-Hans")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
Assert.That(
profile.Resolve(new LocaleIdentifier("ja-JP")).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Grapheme));
foreach (var localeCode in new[] { "en", "es", "ru", "pt-BR" })
{
Assert.That(
profile.Resolve(new LocaleIdentifier(localeCode)).UnitMode,
Is.EqualTo(ExpressionParticleUnitMode.Word),
localeCode);
}
}
}
}
@@ -0,0 +1,60 @@
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class SaveSnapshotContractTests
{
[Test]
public void PresentationProviders_KeepStableIdsAndRestoreOrder()
{
var showcase = new ShowcaseSnapshotProvider();
var day2Sleep = new Day2SleepPresentationSnapshotProvider();
var playTool = new PlayToolSnapshotProvider();
var screen = new ScreenSnapshotProvider();
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
Assert.That(day2Sleep.SaveId, Is.EqualTo("day2SleepPresentation"));
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
Assert.That(showcase.RestoreOrder, Is.LessThan(day2Sleep.RestoreOrder));
Assert.That(day2Sleep.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
}
[Test]
public void PresentationSections_RoundTripAndLegacySnapshotsRemainReadable()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
{
displayMode = ShowcaseDisplayMode.Large.ToString(),
picName = "D2S星图",
isBackgroundVisible = true
};
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
{
isObjVisible = true,
objPicName = "证物"
};
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
JsonConvert.SerializeObject(source));
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
.ToObject<ShowcaseSnapshotDto>();
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
.ToObject<PlayToolSnapshotDto>();
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
Assert.That(showcase.displayMode, Is.EqualTo("Large"));
Assert.That(showcase.picName, Is.EqualTo("D2S星图"));
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
var legacy = JsonConvert.DeserializeObject<SaveSnapshot>(
"{\"schemaVersion\":1,\"sections\":{}}");
Assert.That(legacy.schemaVersion, Is.EqualTo(1));
Assert.That(legacy.sections, Is.Empty);
}
}
}
@@ -0,0 +1,94 @@
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class TextSpeedSettingsTests
{
[SetUp]
public void SetUp()
{
TextSpeedSettings.Reset();
}
[TearDown]
public void TearDown()
{
TextSpeedSettings.Reset();
}
[Test]
public void Normalize_MapsEverySupportedValueToCanonicalForm()
{
AssertNormalized(
"0.5",
TextSpeedSettings.SlowMultiplier,
TextSpeedSettings.SlowValue);
AssertNormalized(
"1.0",
TextSpeedSettings.DefaultMultiplier,
TextSpeedSettings.DefaultValue);
AssertNormalized(
"3",
TextSpeedSettings.FastMultiplier,
TextSpeedSettings.FastValue);
}
[Test]
public void Normalize_RejectsInvalidAndUnsupportedValues()
{
foreach (var rawValue in new[]
{
null,
string.Empty,
"invalid",
"NaN",
"Infinity",
"0",
"2",
"4"
})
{
Assert.That(
TextSpeedSettings.TryNormalize(
rawValue,
out var multiplier,
out var canonicalValue),
Is.False,
rawValue);
Assert.That(multiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
Assert.That(canonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue));
}
}
[Test]
public void Reset_RestoresDefaultAfterRuntimeChange()
{
Assert.That(TextSpeedSettings.ApplyStoredValue("3"), Is.True);
TextSpeedSettings.Reset();
Assert.That(
TextSpeedSettings.CurrentMultiplier,
Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
Assert.That(
TextSpeedSettings.CurrentCanonicalValue,
Is.EqualTo(TextSpeedSettings.DefaultValue));
}
private static void AssertNormalized(
string rawValue,
float expectedMultiplier,
string expectedCanonicalValue)
{
Assert.That(
TextSpeedSettings.TryNormalize(
rawValue,
out var multiplier,
out var canonicalValue),
Is.True,
rawValue);
Assert.That(multiplier, Is.EqualTo(expectedMultiplier));
Assert.That(canonicalValue, Is.EqualTo(expectedCanonicalValue));
}
}
}
@@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Yarn.Markup;
using Yarn.Unity;
namespace AibisDream.SystemEditor.Tests
{
public sealed class YarnLineMetadataTests
{
[Test]
public void AutoNext_UsesDefaultOrInvariantParameterizedDelay()
{
var plain = LineInfo.Generate(CreateLine("auto_next"));
Assert.That(plain.isAutoSkip, Is.True);
Assert.That(plain.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var parameterized = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(parameterized.isAutoSkip, Is.True);
Assert.That(parameterized.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(parameterized.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
[Test]
public void AutoNext_InvalidValueFallsBackAndSimilarTagIsIgnored()
{
LogAssert.Expect(LogType.Warning, new Regex("auto_next 参数无效"));
var invalid = LineInfo.Generate(CreateLine("auto_next:abc"));
Assert.That(invalid.isAutoSkip, Is.True);
Assert.That(invalid.autoNextDelaySeconds, Is.Null);
Assert.That(invalid.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
var similar = CreateLine("AUTO_NEXT", "auto_next_extra:4.5");
Assert.That(similar.IsAutoSkipLine(), Is.False);
Assert.That(similar.TryGetAutoNextDelaySeconds(out _), Is.False);
}
[Test]
public void OptionPrompt_RequiresExactMetadataTag()
{
Assert.That(CreateLine(YarnUtil.OptionPrompt).IsOptionPromptLine(), Is.True);
Assert.That(CreateLine("OPTION_PROMPT").IsOptionPromptLine(), Is.False);
Assert.That(CreateLine("option_prompts").IsOptionPromptLine(), Is.False);
Assert.That(
new LocalizedLine { Metadata = null }.IsOptionPromptLine(),
Is.False);
}
private static LocalizedLine CreateLine(params string[] metadata)
{
return new LocalizedLine
{
TextID = "line:yarn-metadata-contract",
Metadata = metadata,
Text = new MarkupParseResult(
"Test line",
new List<MarkupAttribute>())
};
}
}
}
-75
View File
@@ -1,75 +0,0 @@
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class TextSpeedSettingsTests
{
[SetUp]
public void SetUp()
{
TextSpeedSettings.Reset();
}
[TearDown]
public void TearDown()
{
TextSpeedSettings.Reset();
}
[TestCase("0.5", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)]
[TestCase("0.50", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)]
[TestCase("1", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)]
[TestCase("1.0", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)]
[TestCase("3", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)]
[TestCase("3.0", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)]
public void TryNormalize_SupportedNumericValue_ReturnsCanonicalValue(
string rawValue,
float expectedMultiplier,
string expectedCanonicalValue)
{
var result = TextSpeedSettings.TryNormalize(
rawValue,
out var multiplier,
out var canonicalValue);
Assert.That(result, Is.True);
Assert.That(multiplier, Is.EqualTo(expectedMultiplier));
Assert.That(canonicalValue, Is.EqualTo(expectedCanonicalValue));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("invalid")]
[TestCase("NaN")]
[TestCase("Infinity")]
[TestCase("-1")]
[TestCase("0")]
[TestCase("2")]
[TestCase("4")]
public void TryNormalize_InvalidOrUnsupportedValue_ReturnsDefault(
string rawValue)
{
var result = TextSpeedSettings.TryNormalize(
rawValue,
out var multiplier,
out var canonicalValue);
Assert.That(result, Is.False);
Assert.That(multiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
Assert.That(canonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue));
}
[Test]
public void Reset_AfterApplyingFastValue_RestoresDefault()
{
Assert.That(TextSpeedSettings.ApplyStoredValue("3"), Is.True);
Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.FastMultiplier));
TextSpeedSettings.Reset();
Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
Assert.That(TextSpeedSettings.CurrentCanonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue));
}
}
}
-158
View File
@@ -1,158 +0,0 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Yarn.Markup;
using Yarn.Unity;
namespace AibisDream.SystemEditor.Tests
{
public sealed class YarnLineMetadataTests
{
[Test]
public void AutoNextWithoutParameter_UsesExistingFixedDelay()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[Test]
public void AutoNextWithParameter_UsesSpecifiedSeconds()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
[Test]
public void AutoNextWithZeroDelay_IsValid()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:0"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(0f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.Zero);
}
[Test]
public void AutoNextParameter_UsesInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
[TestCase("auto_next:")]
[TestCase("auto_next:abc")]
[TestCase("auto_next:-1")]
[TestCase("auto_next:NaN")]
[TestCase("auto_next:Infinity")]
[TestCase("auto_next:2147484")]
public void AutoNextWithInvalidParameter_FallsBackToExistingFixedDelay(string metadata)
{
LogAssert.Expect(LogType.Warning, new Regex("auto_next 参数无效"));
var lineInfo = LineInfo.Generate(CreateLine(metadata));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[TestCase("auto_next_extra")]
[TestCase("auto_next_extra:4.5")]
[TestCase("AUTO_NEXT")]
[TestCase("AUTO_NEXT:4.5")]
public void SimilarAutoNextMetadata_IsNotRecognized(string metadata)
{
var line = CreateLine(metadata);
Assert.That(line.IsAutoSkipLine(), Is.False);
Assert.That(line.TryGetAutoNextDelaySeconds(out _), Is.False);
}
[Test]
public void ParameterizedAutoNextTag_TakesPrecedenceOverPlainTag()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next", "auto_next:2.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
[Test]
public void MultipleParameterizedAutoNextTags_UseFirstAndWarn()
{
LogAssert.Expect(LogType.Warning, new Regex("存在多个 auto_next 参数标签"));
var lineInfo = LineInfo.Generate(CreateLine("auto_next:2.5", "auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
[Test]
public void IsOptionPromptLine_WithExactMetadata_ReturnsTrue()
{
var line = new LocalizedLine
{
Metadata = new[] { "line:0123456", YarnUtil.OptionPrompt }
};
Assert.That(line.IsOptionPromptLine(), Is.True);
}
[TestCase("option_prompts")]
[TestCase("dream_option_prompt")]
[TestCase("OPTION_PROMPT")]
public void IsOptionPromptLine_WithSimilarMetadata_ReturnsFalse(string metadata)
{
var line = new LocalizedLine
{
Metadata = new[] { metadata }
};
Assert.That(line.IsOptionPromptLine(), Is.False);
}
[Test]
public void IsOptionPromptLine_WithoutMetadata_ReturnsFalse()
{
var line = new LocalizedLine
{
Metadata = null
};
Assert.That(line.IsOptionPromptLine(), Is.False);
}
private static LocalizedLine CreateLine(params string[] metadata)
{
return new LocalizedLine
{
TextID = "line:yarn-metadata-test",
Metadata = metadata,
Text = new MarkupParseResult("Test line", new List<MarkupAttribute>())
};
}
}
}
@@ -7,87 +7,77 @@ using UnityEngine;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
/// <summary>
/// 帧动画运行时模型的稳定契约。编辑器窗口与具体布局不在此处测试。
/// </summary>
public sealed class FrameAnimationCoreTests
{
private readonly List<UnityEngine.Object> createdObjects = new List<UnityEngine.Object>();
private readonly List<UnityEngine.Object> createdObjects =
new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
foreach (var createdObject in createdObjects.Where(item => item != null))
{
UnityEngine.Object.DestroyImmediate(createdObject);
}
createdObjects.Clear();
}
[Test]
public void DataModel_SerializesOptionalOverridesAndKeepsInternalIds()
public void GraphSerialization_PreservesRuntimeIdentityAndOverrides()
{
var clip = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100, 100);
var clip = CreateClip("Idle", FrameClipEndBehavior.Loop, 1f, 100);
var node = new AnimationNode(
clip.Id,
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
speedOverride: 2f);
var flow = new AnimationFlow("Flow", "Flow", node.InternalId, FrameClipEndBehavior.Clear);
var graph = CreateGraph("Graph", new[] { clip }, new[] { node }, Array.Empty<AnimationEdge>(), new[] { flow }, flow.Id);
var clone = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
var json = EditorJsonUtility.ToJson(graph);
EditorJsonUtility.FromJsonOverwrite(json, clone);
Assert.That(clone.Nodes[0].InternalId, Is.EqualTo(node.InternalId));
Assert.That(clone.Nodes[0].EndBehaviorOverride, Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(clone.Nodes[0].SpeedOverride, Is.EqualTo(2f));
Assert.That(clone.Flows[0].EndBehaviorOverride, Is.EqualTo(FrameClipEndBehavior.Clear));
}
[Test]
public void Validator_ReportsDuplicatePlayableAndInvalidGraphStructure()
{
var first = CreateClip("Same", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var second = CreateClip("Same", FrameClipEndBehavior.HoldLastFrame, 1f);
var node = new AnimationNode("MissingClip", internalId: "invalid-id");
var flow = new AnimationFlow("Same", "Same Flow", node.InternalId);
var flow = new AnimationFlow(
"Flow",
"Flow",
node.InternalId,
FrameClipEndBehavior.Clear);
var graph = CreateGraph(
"Invalid",
new[] { first, second },
new[] { clip },
new[] { node },
Array.Empty<AnimationEdge>(),
new[] { flow },
"Missing");
flow.Id);
var clone = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
var report = FrameAnimationGraphValidator.Validate(graph);
EditorJsonUtility.FromJsonOverwrite(EditorJsonUtility.ToJson(graph), clone);
Assert.That(report.HasErrors, Is.True);
Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.PlayableIdDuplicate), Is.True);
Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.ClipFramesEmpty), Is.True);
Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.InternalIdInvalid), Is.True);
Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.NodeClipMissing), Is.True);
Assert.That(report.Issues.Any(issue => issue.Code == FrameAnimationValidationCode.DefaultPlayableInvalid), Is.True);
Assert.That(clone.Nodes[0].InternalId, Is.EqualTo(node.InternalId));
Assert.That(
clone.Nodes[0].EndBehaviorOverride,
Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(clone.Nodes[0].SpeedOverride, Is.EqualTo(2f));
Assert.That(
clone.Flows[0].EndBehaviorOverride,
Is.EqualTo(FrameClipEndBehavior.Clear));
}
[Test]
public void Resolver_RejectsCycleAndMultipleSuccessors()
public void InvalidTopology_IsRejectedBeforePlayback()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var a = new AnimationNode(clip.Id);
var b = new AnimationNode(clip.Id);
var c = new AnimationNode(clip.Id);
var flow = new AnimationFlow("Flow", "Flow", a.InternalId);
var first = new AnimationNode(clip.Id);
var second = new AnimationNode(clip.Id);
var third = new AnimationNode(clip.Id);
var flow = new AnimationFlow("Flow", "Flow", first.InternalId);
var graph = CreateGraph(
"Graph",
new[] { clip },
new[] { a, b, c },
new[] { first, second, third },
new[]
{
new AnimationEdge(a.InternalId, b.InternalId),
new AnimationEdge(a.InternalId, c.InternalId),
new AnimationEdge(b.InternalId, a.InternalId)
new AnimationEdge(first.InternalId, second.InternalId),
new AnimationEdge(first.InternalId, third.InternalId),
new AnimationEdge(second.InternalId, first.InternalId)
},
new[] { flow },
flow.Id);
var report = FrameAnimationGraphValidator.Validate(graph);
var resolved = FrameAnimationResolver.TryResolve(
graph,
flow.Id,
@@ -95,27 +85,36 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
out _,
out var error);
Assert.That(report.HasErrors, Is.True);
Assert.That(resolved, Is.False);
Assert.That(error.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.InvalidPlayableData));
Assert.That(
error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.InvalidPlayableData));
}
[Test]
public void Session_LargeDeltaCrossesFlowAndLoopsTerminalClip()
public void PlaybackSession_CrossesFlowAndLoopsTerminalClip()
{
var intro = CreateClip("Intro", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 100);
var idle = CreateClip("Idle", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 100, 100);
var introNode = new AnimationNode(intro.Id);
var idleNode = new AnimationNode(idle.Id, endBehaviorOverride: FrameClipEndBehavior.Loop);
var idleNode = new AnimationNode(
idle.Id,
endBehaviorOverride: FrameClipEndBehavior.Loop);
var flow = new AnimationFlow("IntroToIdle", "Intro To Idle", introNode.InternalId);
var graph = CreateGraph(
"Graph",
new[] { intro, idle },
new[] { introNode, idleNode },
new[] { new AnimationEdge(introNode.InternalId, idleNode.InternalId) },
new[] { flow },
flow.Id);
Assert.That(
FrameAnimationResolver.TryResolve(graph, flow.Id, default, out var plan, out var error),
FrameAnimationResolver.TryResolve(
graph,
flow.Id,
default,
out var plan,
out var error),
Is.True,
error.ToString());
@@ -130,59 +129,23 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
}
[Test]
public void Session_ExactBoundaryAndRemainingTimeAdvanceDeterministically()
public void EndBehavior_NodeOverrideHasHighestPrecedence()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100, 200, 300);
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var node = new AnimationNode(
clip.Id,
endBehaviorOverride: FrameClipEndBehavior.HideTarget);
var flow = new AnimationFlow(
"Flow",
"Flow",
node.InternalId,
FrameClipEndBehavior.Clear);
var graph = CreateGraph(
"Graph",
new[] { clip },
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
clip.Id);
Assert.That(FrameAnimationResolver.TryResolve(graph, clip.Id, default, out var plan, out _), Is.True);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
session.Evaluate(0.1d, 1f, _ => { });
Assert.That(session.CurrentFrameIndex, Is.EqualTo(1));
session.Evaluate(0.25d, 1f, _ => { });
Assert.That(session.CurrentFrameIndex, Is.EqualTo(2));
var completion = session.Evaluate(0.25d, 1f, _ => { });
Assert.That(completion.IsCompleted, Is.True);
Assert.That(completion.EndBehavior, Is.EqualTo(FrameClipEndBehavior.HoldLastFrame));
}
[Test]
public void Session_UsesNodeSpeedInsteadOfClipSpeedAndKeepsPlayerMultiplier()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 0.5f, 100, 100);
var node = new AnimationNode(clip.Id, speedOverride: 2f);
var flow = new AnimationFlow("Flow", "Flow", node.InternalId);
var graph = CreateGraph(
"Graph",
new[] { clip },
new[] { node },
Array.Empty<AnimationEdge>(),
new[] { flow },
flow.Id);
Assert.That(FrameAnimationResolver.TryResolve(graph, flow.Id, default, out var plan, out _), Is.True);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
session.Evaluate(0.025d, 2f, _ => { });
Assert.That(session.CurrentFrameIndex, Is.EqualTo(1));
}
[Test]
public void Resolver_EndBehaviorPrecedenceIsNodeThenRequestThenFlowThenClip()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 1f, 100);
var node = new AnimationNode(clip.Id, endBehaviorOverride: FrameClipEndBehavior.HideTarget);
var flow = new AnimationFlow("Flow", "Flow", node.InternalId, FrameClipEndBehavior.Clear);
var graph = CreateGraph("Graph", new[] { clip }, new[] { node }, Array.Empty<AnimationEdge>(), new[] { flow }, flow.Id);
Assert.That(
FrameAnimationResolver.TryResolve(
@@ -192,22 +155,17 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
out var plan,
out _),
Is.True);
Assert.That(plan.Steps[0].TerminalEndBehavior, Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(
plan.Steps[0].TerminalEndBehavior,
Is.EqualTo(FrameClipEndBehavior.HideTarget));
}
[Test]
public void Handle_CompletesOnceAndLateObserversReceiveSameResult()
public void PlaybackHandle_CompletesExactlyOnce()
{
var handle = new FrameAnimationPlaybackHandle(42);
var callbackCount = 0;
FrameAnimationPlaybackResult observed = default;
handle.RegisterCompleted(result =>
{
callbackCount++;
observed = result;
});
Assert.Throws<InvalidOperationException>(() => _ = handle.Result);
handle.RegisterCompleted(_ => callbackCount++);
var completion = new FrameAnimationPlaybackResult(
42,
"Clip",
@@ -219,26 +177,7 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
handle.RegisterCompleted(_ => callbackCount++);
Assert.That(callbackCount, Is.EqualTo(2));
Assert.That(observed.RequestId, Is.EqualTo(42));
Assert.That(handle.WaitAsync().Result.RequestId, Is.EqualTo(42));
}
[Test]
public void SessionSnapshotAndSeek_UseTheRuntimeEvaluator()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, 2f, 100, 200, 300);
var graph = CreateGraph("Graph", new[] { clip }, Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), clip.Id);
Assert.That(FrameAnimationResolver.TryResolve(graph, clip.Id, default, out var plan, out _), Is.True);
var session = new FrameAnimationPlaybackSession(plan);
session.Start(_ => { });
session.Seek(0.075d, _ => { });
Assert.That(session.Snapshot.StepIndex, Is.EqualTo(0));
Assert.That(session.Snapshot.FrameIndex, Is.EqualTo(1));
Assert.That(session.Snapshot.FrameElapsedSeconds, Is.EqualTo(0.05d).Within(0.000001d));
Assert.That(session.Snapshot.StepSpeed, Is.EqualTo(2f));
Assert.That(handle.Result.RequestId, Is.EqualTo(42));
}
private FrameClip CreateClip(
@@ -248,14 +187,17 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
params int[] durations)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
var frames = durations.Select((duration, index) =>
new FrameAnimationFrame(null, duration, $"frame_{index}", index));
clip.Configure(id, id, frames, speed, endBehavior);
clip.Configure(
id,
id,
durations.Select((duration, index) =>
new FrameAnimationFrame(null, duration, $"frame_{index}", index)),
speed,
endBehavior);
return clip;
}
private FrameAnimationGraph CreateGraph(
string id,
IEnumerable<FrameClip> clips,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> edges,
@@ -263,7 +205,14 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
string defaultPlayableId)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(id, id, clips, nodes, edges, flows, defaultPlayableId);
graph.Configure(
"Graph",
"Graph",
clips,
nodes,
edges,
flows,
defaultPlayableId);
return graph;
}
@@ -1,243 +0,0 @@
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<AnimationNode>(),
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 SetFlowColor_IsStoredAndSupportsUndo()
{
var flow = new AnimationFlow("Flow", "Flow", "entry");
var graph = CreateGraph("FlowColor.asset", flows: new[] { flow });
graph.EditorData.GetOrCreateFlowData(flow.Id, Color.red);
FrameAnimationGraphMutationService.SetFlowColor(graph, flow, new Color(0.1f, 0.4f, 0.9f, 0.2f));
Assert.That(graph.EditorData.FlowEditorData.Single().Color.b, Is.EqualTo(0.9f).Within(0.001f));
Assert.That(graph.EditorData.FlowEditorData.Single().Color.a, Is.EqualTo(0.2f).Within(0.001f));
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(graph.EditorData.FlowEditorData.Single().Color, Is.EqualTo(Color.red));
}
[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<FrameAnimationGraph>();
graph.Configure(PathId(fileName), PathId(fileName), clips ?? Array.Empty<FrameClip>(),
nodes ?? Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(),
flows ?? Array.Empty<AnimationFlow>(), defaultPlayableId);
AssetDatabase.CreateAsset(graph, TestRoot + "/" + fileName);
foreach (var clip in (clips ?? Array.Empty<FrameClip>()).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<FrameClip>();
clip.name = id;
clip.Configure(id, displayName ?? id, Array.Empty<FrameAnimationFrame>(), 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;
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8b1f03a11f33454a9a861ff25290577a
@@ -1,303 +0,0 @@
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;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4a7f50b95d6f49e093f3842f9ef81bc8
@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -9,335 +10,173 @@ using UnityEngine;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
/// <summary>
/// Aseprite 到 FrameAnimationGraph 的边界契约。
/// 只覆盖解析语义与一次完整刷新,不枚举编辑器操作步骤。
/// </summary>
public sealed class FrameAnimationImportTests
{
private string TestRoot { get; set; }
private string TexturePath => TestRoot + "/Atlas.png";
private string JsonPath => TestRoot + "/Atlas.json";
private string GraphPath => TestRoot + "/ImportGraph.asset";
private string testRoot;
private string TexturePath => testRoot + "/Atlas.png";
private string JsonPath => testRoot + "/Atlas.json";
private string GraphPath => testRoot + "/ImportGraph.asset";
[SetUp]
public void SetUp()
{
TestRoot =
testRoot =
$"Assets/Tests/FrameAnimation/GeneratedImportTests_{Guid.NewGuid():N}";
EnsureFolder(TestRoot);
EnsureFolder(testRoot);
}
[TearDown]
public void TearDown()
{
if (!string.IsNullOrEmpty(TestRoot))
if (string.IsNullOrEmpty(testRoot))
return;
if (AssetDatabase.IsValidFolder(testRoot))
AssetDatabase.DeleteAsset(testRoot);
var projectRoot = Directory.GetParent(Application.dataPath).FullName;
var absoluteRoot = Path.GetFullPath(Path.Combine(projectRoot, testRoot));
if (Directory.Exists(absoluteRoot))
FileUtil.DeleteFileOrDirectory(absoluteRoot);
if (File.Exists(absoluteRoot + ".meta"))
FileUtil.DeleteFileOrDirectory(absoluteRoot + ".meta");
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
Assert.That(Directory.Exists(absoluteRoot), Is.False,
$"Test asset directory was not removed: {testRoot}");
Assert.That(File.Exists(absoluteRoot + ".meta"), Is.False,
$"Test asset meta was not removed: {testRoot}.meta");
}
[Test]
public void Parser_PreservesUnicodeOrderAndSharedSpriteSlots()
{
Assert.That(
AssetDatabase.DeleteAsset(TestRoot),
AsepriteJsonParser.TryParse(
ObjectJson(),
"source",
out var ordered,
out var orderedIssue),
Is.True,
$"Failed to delete test asset folder: {TestRoot}");
}
orderedIssue?.Message);
Assert.That(
ordered.Frames.Select(frame => frame.FrameName),
Is.EqualTo(new[] { "第二帧.png", "第一帧.png" }));
Assert.That(
ordered.Tags.Select(tag => tag.Name),
Is.EqualTo(new[] { "待机", "眨眼" }));
AssetDatabase.Refresh();
Assert.That(
AsepriteJsonParser.TryParse(
SharedRectJson(),
"source",
out var shared,
out var sharedIssue),
Is.True,
sharedIssue?.Message);
Assert.That(shared.Frames.Count, Is.EqualTo(3));
Assert.That(shared.SpriteSlots.Count, Is.EqualTo(2));
Assert.That(
shared.SpriteSlotBySourceIndex[0],
Is.SameAs(shared.SpriteSlotBySourceIndex[1]));
}
[Test]
public void Parser_PreservesObjectAndArrayOrderAndUnicode()
public void TagExpansion_SupportsEveryAsepriteDirection()
{
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);
var cases = new Dictionary<string, int[]>
{
["forward"] = new[] { 0, 1, 2, 3 },
["reverse"] = new[] { 3, 2, 1, 0 },
["pingpong"] = new[] { 0, 1, 2, 3, 2, 1 },
["pingpong_reverse"] = new[] { 3, 2, 1, 0, 1, 2 }
};
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));
var trimJson = "{" +
"\"frames\":{" +
"\"FrameA\":{\"frame\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":100,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}}," +
"\"meta\":{\"image\":\"Atlas.png\",\"size\":{\"w\":2,\"h\":2},\"frameTags\":[" +
"{\"name\":\" 待机 \",\"from\":0,\"to\":0,\"direction\":\"forward\"}]}}";
Assert.That(AsepriteJsonParser.TryParse(trimJson, "source", out var trimDocument, out var trimIssue),
Is.True, trimIssue?.Message);
Assert.That(trimDocument.Tags.Single().Name, Is.EqualTo("待机"));
foreach (var pair in cases)
{
var tag = new AsepriteSourceTag("测试", 0, 3, pair.Key);
Assert.That(
AsepriteJsonParser.TryExpandTag(
tag,
4,
"source",
out var actual,
out var issue),
Is.True,
issue?.Message);
Assert.That(actual, Is.EqualTo(pair.Value), pair.Key);
}
}
[Test]
public void Parser_GroupsSharedRectsWithoutCollapsingLogicalFrames()
{
Assert.That(AsepriteJsonParser.TryParse(
SharedRectJson(), "source", out var document, out var issue), Is.True, issue?.Message);
Assert.That(document.Frames.Count, Is.EqualTo(3));
Assert.That(document.SpriteSlots.Count, Is.EqualTo(2));
Assert.That(document.SpriteSlots[0].CanonicalFrameName, Is.EqualTo("FrameA"));
CollectionAssert.AreEqual(new[] { 0, 1 }, document.SpriteSlots[0].SourceIndices);
CollectionAssert.AreEqual(new[] { "FrameA", "FrameB" }, document.SpriteSlots[0].FrameNames);
Assert.That(document.SpriteSlotBySourceIndex[0], Is.SameAs(document.SpriteSlotBySourceIndex[1]));
Assert.That(document.SpriteSlotBySourceIndex[2], Is.Not.SameAs(document.SpriteSlotBySourceIndex[0]));
}
[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()
public void WritableRefresh_UpdatesFramesWithoutReplacingOwnedClip()
{
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(
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 == "待机");
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);
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 refreshPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(
FrameAnimationImportService.Apply(
refreshPreview,
true,
out var refreshError),
Is.True,
refreshError);
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));
Assert.That(
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(
idle,
out _,
out long refreshedLocalId),
Is.True);
Assert.That(refreshedLocalId, Is.EqualTo(firstLocalId));
}
[Test]
public void WritableRefresh_SharedRectsCreateOneSpriteAndPreserveLogicalFrames()
{
var graph = CreateWritableGraph(SharedRectJson());
var firstPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(firstPreview.HasErrors, Is.False, JoinIssues(firstPreview));
var sourcePreview = firstPreview.Sources.Single();
Assert.That(sourcePreview.Document.Frames.Count, Is.EqualTo(3));
Assert.That(sourcePreview.Document.SpriteSlots.Count, Is.EqualTo(2));
Assert.That(sourcePreview.SpriteDiffs.Count(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added), Is.EqualTo(2));
Assert.That(sourcePreview.SpriteDiffs.Single(diff => diff.SourceFrameCount == 2).FrameName,
Is.EqualTo("FrameA"));
Assert.That(FrameAnimationImportService.Apply(firstPreview, true, out var firstError),
Is.True, firstError);
var sprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>().ToArray();
Assert.That(sprites.Length, Is.EqualTo(2));
var clip = graph.Clips.Single(item => item.ImportInfo.SourceTagName == "Shared");
Assert.That(clip.FrameCount, Is.EqualTo(3));
Assert.That(clip.Frames[0].Sprite, Is.SameAs(clip.Frames[1].Sprite));
Assert.That(clip.Frames[2].Sprite, Is.Not.SameAs(clip.Frames[0].Sprite));
CollectionAssert.AreEqual(new[] { "FrameA", "FrameB", "FrameC" },
clip.Frames.Select(frame => frame.FrameName));
CollectionAssert.AreEqual(new[] { 0, 1, 2 }, clip.Frames.Select(frame => frame.SourceIndex));
CollectionAssert.AreEqual(new[] { 100, 300, 80 }, clip.Frames.Select(frame => frame.DurationMs));
var firstIds = sprites.ToDictionary(sprite => sprite.name, GetLocalFileId);
var secondPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(secondPreview.HasErrors, Is.False, JoinIssues(secondPreview));
Assert.That(secondPreview.Sources.Single().SpriteDiffs.All(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Unchanged), Is.True);
Assert.That(secondPreview.Sources.Single().ClipDiffs.All(diff =>
diff.Kind == FrameAnimationImportChangeKind.Unchanged), Is.True);
Assert.That(FrameAnimationImportService.Apply(secondPreview, true, out var secondError),
Is.True, secondError);
var secondIds = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
.ToDictionary(sprite => sprite.name, GetLocalFileId);
CollectionAssert.AreEquivalent(firstIds, secondIds);
Assert.That(clip.Frames[0].Sprite, Is.SameAs(clip.Frames[1].Sprite));
}
[Test]
public void WritableRefresh_SharedSlotSplitAndMergeKeepDeterministicSpriteIdentity()
{
var graph = CreateWritableGraph(SharedPairJson(true));
var mergedPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(FrameAnimationImportService.Apply(mergedPreview, true, out var mergedError),
Is.True, mergedError);
var canonical = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>().Single();
var canonicalId = GetLocalFileId(canonical);
WriteJson(SharedPairJson(false));
var splitPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(splitPreview.HasErrors, Is.False, JoinIssues(splitPreview));
Assert.That(splitPreview.Sources.Single().SpriteDiffs.Count(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added), Is.EqualTo(1));
Assert.That(FrameAnimationImportService.Apply(splitPreview, true, out var splitError),
Is.True, splitError);
var splitSprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
.ToDictionary(sprite => sprite.name);
Assert.That(splitSprites.Keys, Is.EquivalentTo(new[] { "FrameA", "FrameB" }));
Assert.That(GetLocalFileId(splitSprites["FrameA"]), Is.EqualTo(canonicalId));
var clip = graph.Clips.Single(item => item.ImportInfo.SourceTagName == "SharedPair");
Assert.That(clip.Frames[0].Sprite, Is.Not.SameAs(clip.Frames[1].Sprite));
WriteJson(SharedPairJson(true));
var mergedAgainPreview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(mergedAgainPreview.HasErrors, Is.False, JoinIssues(mergedAgainPreview));
Assert.That(mergedAgainPreview.Sources.Single().SpriteDiffs.Any(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Retained && diff.FrameName == "FrameB"), Is.True);
Assert.That(FrameAnimationImportService.Apply(mergedAgainPreview, true, out var mergedAgainError),
Is.True, mergedAgainError);
Assert.That(GetLocalFileId(AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
.Single(sprite => sprite.name == "FrameA")), Is.EqualTo(canonicalId));
Assert.That(clip.Frames[0].Sprite, Is.SameAs(clip.Frames[1].Sprite));
}
[Test]
public void ReadOnlyPreview_PreservesImporterAndMapsSharedSprites()
{
var writableGraph = CreateWritableGraph(ObjectJson());
var writablePreview = FrameAnimationImportService.PreviewAll(writableGraph);
Assert.That(FrameAnimationImportService.Apply(writablePreview, true, out var objectApplyError),
Is.True, objectApplyError);
var importer = (TextureImporter)AssetImporter.GetAtPath(TexturePath);
var before = EditorJsonUtility.ToJson(importer);
var readOnlyGraph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
readOnlyGraph.Configure("ReadOnly", "Read Only", Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), string.Empty);
readOnlyGraph.AddImportSource(new FrameAnimationImportSource(
"Read Only", AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath), new Vector2(0.5f, 0.5f), false));
AssetDatabase.CreateAsset(readOnlyGraph, TestRoot + "/ReadOnlyGraph.asset");
AssetDatabase.SaveAssets();
var objectPreview = FrameAnimationImportService.PreviewAll(readOnlyGraph);
Assert.That(objectPreview.HasErrors, Is.False, JoinIssues(objectPreview));
Assert.That(objectPreview.HasSpriteChanges, Is.False);
Assert.That(EditorJsonUtility.ToJson(importer), Is.EqualTo(before));
var sharedWritableGraph = CreateWritableGraph(SharedRectJson(), TestRoot + "/SharedImportGraph.asset");
var sharedPreview = FrameAnimationImportService.PreviewAll(sharedWritableGraph);
Assert.That(FrameAnimationImportService.Apply(sharedPreview, true, out var sharedApplyError),
Is.True, sharedApplyError);
before = EditorJsonUtility.ToJson(importer);
var readOnlySharedGraph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
readOnlySharedGraph.Configure("ReadOnlyShared", "Read Only Shared", Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), string.Empty);
readOnlySharedGraph.AddImportSource(new FrameAnimationImportSource(
"Read Only Shared", AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath), new Vector2(0.5f, 0.5f), false));
AssetDatabase.CreateAsset(readOnlySharedGraph, TestRoot + "/ReadOnlySharedGraph.asset");
AssetDatabase.SaveAssets();
var sharedReadOnlyPreview = FrameAnimationImportService.PreviewAll(readOnlySharedGraph);
Assert.That(sharedReadOnlyPreview.HasErrors, Is.False, JoinIssues(sharedReadOnlyPreview));
Assert.That(sharedReadOnlyPreview.HasSpriteChanges, Is.False);
Assert.That(sharedReadOnlyPreview.Sources.Single().SpritePlan.ReadOnlySpritesBySourceIndex[0],
Is.SameAs(sharedReadOnlyPreview.Sources.Single().SpritePlan.ReadOnlySpritesBySourceIndex[1]));
Assert.That(FrameAnimationImportService.Apply(sharedReadOnlyPreview, false, out var error), Is.True, error);
var clip = readOnlySharedGraph.Clips.Single(item => item.ImportInfo.SourceTagName == "Shared");
Assert.That(clip.Frames[0].Sprite, Is.SameAs(clip.Frames[1].Sprite));
Assert.That(EditorJsonUtility.ToJson(importer), Is.EqualTo(before));
}
[Test]
public void ReadOnlyPreview_BlocksWhenSharedRectHasNoSprite()
{
CreateTexture();
WriteJson(SharedPairJson(true));
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.Configure("MissingSprite", "Missing Sprite", Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), string.Empty);
graph.AddImportSource(new FrameAnimationImportSource(
"Read Only", AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath), new Vector2(0.5f, 0.5f), false));
AssetDatabase.CreateAsset(graph, TestRoot + "/MissingSpriteGraph.asset");
AssetDatabase.SaveAssets();
var preview = FrameAnimationImportService.PreviewAll(graph);
Assert.That(preview.HasErrors, Is.True);
Assert.That(preview.Sources.Single().Issues.Any(issue =>
issue.Code == FrameAnimationImportIssueCode.SpriteMatchFailed), Is.True);
Assert.That(FrameAnimationImportService.Apply(preview, false, out _), Is.False);
Assert.That(graph.Clips, Is.Empty);
}
[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 FrameAnimationGraph CreateWritableGraph(string json, string graphAssetPath = null)
private FrameAnimationGraph CreateWritableGraph(string json)
{
CreateTexture();
WriteJson(json);
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.Configure("ImportGraph", "Import Graph", Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), string.Empty);
graph.Configure(
"ImportGraph",
"Import Graph",
Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
string.Empty);
graph.AddImportSource(new FrameAnimationImportSource(
"中文来源",
AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath),
new Vector2(0.5f, 0.5f),
true));
AssetDatabase.CreateAsset(graph, graphAssetPath ?? GraphPath);
AssetDatabase.CreateAsset(graph, GraphPath);
AssetDatabase.SaveAssets();
return graph;
}
@@ -345,42 +184,31 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
private void CreateTexture()
{
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
var pixels = Enumerable.Range(0, 8)
texture.SetPixels(Enumerable.Range(0, 8)
.Select(index => index < 4 ? Color.red : Color.green)
.ToArray();
texture.SetPixels(pixels);
.ToArray());
texture.Apply();
File.WriteAllBytes(TexturePath, texture.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(texture);
AssetDatabase.ImportAsset(TexturePath, ImportAssetOptions.ForceSynchronousImport);
AssetDatabase.ImportAsset(
TexturePath,
ImportAssetOptions.ForceSynchronousImport);
}
private void WriteJson(string json)
{
File.WriteAllText(JsonPath, json, new UTF8Encoding(false));
AssetDatabase.ImportAsset(JsonPath, ImportAssetOptions.ForceSynchronousImport);
AssetDatabase.ImportAsset(
JsonPath,
ImportAssetOptions.ForceSynchronousImport);
}
private static string ObjectJson(int duration = 120, bool includeIdle = true)
private static string ObjectJson(int duration = 120)
{
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\"}]}}";
@@ -397,26 +225,13 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
"{\"name\":\"Shared\",\"from\":0,\"to\":2,\"direction\":\"forward\"}]}}";
}
private static string SharedPairJson(bool merged)
{
var secondX = merged ? 0 : 2;
return "{" +
"\"frames\":{" +
"\"FrameA\":{\"frame\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":100,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}," +
$"\"FrameB\":{{\"frame\":{{\"x\":{secondX},\"y\":0,\"w\":2,\"h\":2}},\"rotated\":false,\"trimmed\":false,\"duration\":300,\"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\":\"SharedPair\",\"from\":0,\"to\":1,\"direction\":\"forward\"}]}}";
}
private static long GetLocalFileId(Sprite sprite)
{
Assert.That(AssetDatabase.TryGetGUIDAndLocalFileIdentifier(sprite, out _, out long localId), Is.True);
return localId;
}
private static string JoinIssues(FrameAnimationImportPreview preview)
{
return string.Join("\n", preview.Sources.SelectMany(source => source.Issues).Select(issue => issue.Message));
return string.Join(
"\n",
preview.Sources
.SelectMany(source => source.Issues)
.Select(issue => issue.Message));
}
private static void EnsureFolder(string path)
@@ -427,9 +242,7 @@ namespace AibisDream.FrameAnimation.Tests.EditMode
{
var next = current + "/" + segments[index];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, segments[index]);
}
current = next;
}
}
@@ -1,311 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AibisDream.FrameAnimation.Editor;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
public sealed class FrameAnimationPreviewTests
{
private readonly List<UnityEngine.Object> objects = new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
foreach (var item in objects.Where(item => item != null))
{
UnityEngine.Object.DestroyImmediate(item);
}
objects.Clear();
}
[Test]
public void NodeResolver_AppliesNodeOverridesWithoutChangingClip()
{
var clip = Clip("Clip", 0.5f, FrameClipEndBehavior.HoldLastFrame, 100);
var node = new AnimationNode(clip.Id, speedOverride: 3f,
endBehaviorOverride: FrameClipEndBehavior.HideTarget);
var graph = Graph(new[] { clip }, new[] { node }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
Assert.That(FrameAnimationResolver.TryResolveNode(graph, node, out var plan, out var error),
Is.True, error.ToString());
Assert.That(plan.Steps.Single().PlaybackSpeed, Is.EqualTo(3f));
Assert.That(plan.Steps.Single().TerminalEndBehavior, Is.EqualTo(FrameClipEndBehavior.HideTarget));
Assert.That(plan.Steps.Single().NodeId, Is.EqualTo(node.InternalId));
Assert.That(clip.Speed, Is.EqualTo(0.5f));
}
[Test]
public void Timeline_FlattensFlowUsingEffectiveStepDurations()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.HoldLastFrame, 300);
var firstNode = new AnimationNode(first.Id, speedOverride: 2f);
var secondNode = new AnimationNode(second.Id, speedOverride: 0.5f);
var edge = new AnimationEdge(firstNode.InternalId, secondNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode }, new[] { edge }, new[] { flow });
Assert.That(FrameAnimationResolver.TryResolve(graph, flow.Id, default, out var plan, out _), Is.True);
var timeline = new FrameAnimationPreviewTimeline(plan);
Assert.That(timeline.Segments.Count, Is.EqualTo(3));
Assert.That(timeline.Segments[2].NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(timeline.Segments[2].StartSeconds, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(timeline.DisplayDurationSeconds, Is.EqualTo(0.7d).Within(0.000001d));
Assert.That(timeline.IsInfinite, Is.False);
}
[Test]
public void Timeline_LoopUsesOneCycle_AndZeroSpeedStopsReachability()
{
var loopClip = Clip("Loop", 1f, FrameClipEndBehavior.Loop, 100, 200);
var loopNode = new AnimationNode(loopClip.Id, endBehaviorOverride: FrameClipEndBehavior.Loop);
var loopFlow = new AnimationFlow("LoopFlow", "Loop", loopNode.InternalId);
var loopGraph = Graph(new[] { loopClip }, new[] { loopNode }, Array.Empty<AnimationEdge>(), new[] { loopFlow });
FrameAnimationResolver.TryResolve(loopGraph, loopFlow.Id, default, out var loopPlan, out _);
var loopTimeline = new FrameAnimationPreviewTimeline(loopPlan);
Assert.That(loopTimeline.IsInfinite, Is.True);
Assert.That(loopTimeline.Segments.Count, Is.EqualTo(2));
Assert.That(loopTimeline.DisplayDurationSeconds, Is.EqualTo(0.3d).Within(0.000001d));
var blockedClip = Clip("Blocked", 1f, FrameClipEndBehavior.HoldLastFrame, 100, 100);
var blockedNode = new AnimationNode(blockedClip.Id, speedOverride: 0f);
var blockedGraph = Graph(new[] { blockedClip }, new[] { blockedNode }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
FrameAnimationResolver.TryResolveNode(blockedGraph, blockedNode, out var blockedPlan, out _);
var blockedTimeline = new FrameAnimationPreviewTimeline(blockedPlan);
Assert.That(blockedTimeline.IsBlocked, Is.True);
Assert.That(blockedTimeline.IsInfinite, Is.True);
Assert.That(blockedTimeline.Segments.Count, Is.EqualTo(1));
Assert.That(blockedTimeline.Segments[0].IsBlocked, Is.True);
}
[Test]
public void Coordinator_FlowPreviewCrossesNodesAndRestoresAutomaticPolicyOnStop()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.Loop, 100);
var firstNode = new AnimationNode(first.Id);
var secondNode = new AnimationNode(second.Id, endBehaviorOverride: FrameClipEndBehavior.Loop);
var edge = new AnimationEdge(firstNode.InternalId, secondNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode }, new[] { edge }, new[] { flow });
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, flow);
coordinator.Play();
coordinator.Tick(0.1d, graph.Nodes);
Assert.That(coordinator.IsFlowPreview, Is.True);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.GetNodeDisplay(firstNode).FlowState, Is.EqualTo(FrameAnimationFlowElementState.Passed));
Assert.That(coordinator.GetNodeDisplay(secondNode).FlowState, Is.EqualTo(FrameAnimationFlowElementState.Current));
coordinator.Stop();
Assert.That(coordinator.IsFlowPreview, Is.False);
Assert.That(coordinator.NodePolicy, Is.EqualTo(NodePreviewPolicy.SelectedOnly));
}
[Test]
public void Coordinator_FlowSeekAndStepCrossNodeBoundaries()
{
var first = Clip("First", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var second = Clip("Second", 1f, FrameClipEndBehavior.HoldLastFrame, 200, 200);
var firstNode = new AnimationNode(first.Id);
var secondNode = new AnimationNode(second.Id);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Graph(new[] { first, second }, new[] { firstNode, secondNode },
new[] { new AnimationEdge(firstNode.InternalId, secondNode.InternalId) }, new[] { flow });
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetTarget(FrameAnimationPreviewTargetKind.Flow, flow);
coordinator.Seek(0.1d);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(0));
coordinator.Step(1);
Assert.That(coordinator.Snapshot.NodeId, Is.EqualTo(secondNode.InternalId));
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(1));
coordinator.Step(-1);
Assert.That(coordinator.Snapshot.FrameIndex, Is.EqualTo(0));
}
[Test]
public void GraphViewPreview_RendersOnePreviewPerNodeWithoutMutatingGraph()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.Loop, 100);
var first = new AnimationNode(clip.Id);
var second = new AnimationNode(clip.Id);
var graph = Graph(new[] { clip }, new[] { first, second }, Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>());
var before = JsonUtility.ToJson(graph);
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), string.Empty);
var coordinator = new FrameAnimationPreviewCoordinator();
coordinator.SetGraph(graph);
coordinator.SetNodePolicy(NodePreviewPolicy.AllVisible);
view.ApplyPreview(coordinator);
Assert.That(view.Query<FrameAnimationPreviewElement>().ToList().Count, Is.EqualTo(2));
Assert.That(JsonUtility.ToJson(graph), Is.EqualTo(before));
}
[Test]
public void FlowColorPalette_IsOpaqueAndReadableForExtremeColors()
{
var dark = FrameAnimationFlowColorUtility.CreatePalette(new Color(0.005f, 0.01f, 0.02f, 0f));
var light = FrameAnimationFlowColorUtility.CreatePalette(new Color(1f, 0.98f, 0.9f, 0.1f));
Assert.That(dark.Raw.a, Is.EqualTo(1f));
Assert.That(dark.Stroke.maxColorComponent, Is.GreaterThan(dark.Raw.maxColorComponent));
Assert.That(light.Raw.a, Is.EqualTo(1f));
Assert.That(FrameAnimationFlowColorUtility.Luminance(light.Text),
Is.LessThan(FrameAnimationFlowColorUtility.Luminance(light.Header)));
}
[Test]
public void GraphView_FocusFlowColorSwitchAndValidationOverride()
{
var clip = Clip("Clip", 1f, FrameClipEndBehavior.HoldLastFrame, 100);
var first = new AnimationNode(clip.Id, internalId: "first");
var second = new AnimationNode(clip.Id, internalId: "second");
var edge = new AnimationEdge(first.InternalId, second.InternalId, internalId: "edge");
var redFlow = new AnimationFlow("Red", "Red", first.InternalId);
var blueFlow = new AnimationFlow("Blue", "Blue", first.InternalId);
var graph = Graph(new[] { clip }, new[] { first, second }, new[] { edge }, new[] { redFlow, blueFlow });
graph.EditorData.GetOrCreateFlowData(redFlow.Id, Color.red);
graph.EditorData.GetOrCreateFlowData(blueFlow.Id, Color.blue);
var view = new FrameAnimationGraphView();
view.Bind(graph, Array.Empty<FrameAnimationEditorIssue>(), redFlow.Id);
var edgeView = view.Query<FrameAnimationEdgeView>().First();
var red = edgeView.edgeControl.inputColor;
view.SetFocusedFlow(blueFlow.Id);
var blue = edgeView.edgeControl.inputColor;
Assert.That(red.r, Is.GreaterThan(red.b));
Assert.That(blue.b, Is.GreaterThan(blue.r));
Assert.That(view.Query<FrameAnimationClipNodeView>(className: "fa-node--focused").ToList().Count,
Is.EqualTo(2));
var issue = new FrameAnimationEditorIssue(FrameAnimationValidationSeverity.Error, "BrokenEdge",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, edge), "Broken", string.Empty);
view.Bind(graph, new[] { issue }, redFlow.Id);
edgeView = view.Query<FrameAnimationEdgeView>().First();
edgeView.SetPreviewState(FrameAnimationFlowElementState.Current);
Assert.That(edgeView.edgeControl.inputColor.r, Is.EqualTo(223f / 255f).Within(0.003f));
Assert.That(edgeView.edgeControl.inputColor.g, Is.EqualTo(106f / 255f).Within(0.003f));
}
[Test]
public void PreviewSample_IsIsolatedAndStructurallyPlayable()
{
var sample = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(
"Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset");
Assert.That(sample, Is.Not.Null);
Assert.That(sample.Clips.Count, Is.EqualTo(3));
Assert.That(sample.Flows.Count, Is.EqualTo(5));
Assert.That(sample.Clips.Any(item => item.Frames.Any(frame => frame.Sprite == null)), Is.True);
foreach (var flow in sample.Flows)
{
Assert.That(FrameAnimationResolver.TryResolve(sample, flow.Id, default, out _, out var error),
Is.True, error.ToString());
}
}
[Test]
public void Workbench_CreateGUIBuildsTabsAndSelectionPreviewForClipNodeAndFlow()
{
var sample = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(
"Assets/GameContent/Test/FrameAnimation/Preview/PreviewSampleGraph.asset");
var window = ScriptableObject.CreateInstance<FrameAnimationGraphEditorWindow>();
try
{
typeof(FrameAnimationGraphEditorWindow).GetField("graph",
BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(window, sample);
typeof(FrameAnimationGraphEditorWindow).GetMethod("CreateGUI",
BindingFlags.Instance | BindingFlags.NonPublic)?.Invoke(window, null);
Assert.That(window.rootVisualElement.Q<VisualElement>("fa-workbench"), Is.Not.Null);
Assert.That(window.rootVisualElement.Query<FrameAnimationPreviewElement>().ToList().Count,
Is.GreaterThanOrEqualTo(1));
var setSelection = typeof(FrameAnimationGraphEditorWindow).GetMethod("SetSelection",
BindingFlags.Instance | BindingFlags.NonPublic);
var clip = sample.Clips.First(item => item != null);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
false
});
AssertSelectionPreview(window, "Clip Preview");
var node = sample.Nodes.First(item => item != null);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
false
});
AssertSelectionPreview(window, "Node Preview");
var flow = sample.Flows.First(item => item != null);
setSelection?.Invoke(window, new object[]
{
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow),
false
});
AssertSelectionPreview(window, "Flow Preview");
}
finally
{
UnityEngine.Object.DestroyImmediate(window);
}
}
private static void AssertSelectionPreview(FrameAnimationGraphEditorWindow window, string expectedTitle)
{
var panel = window.rootVisualElement.Q<VisualElement>("selection-preview-panel");
Assert.That(panel, Is.Not.Null);
Assert.That(panel.style.display.value, Is.EqualTo(DisplayStyle.Flex));
Assert.That(window.rootVisualElement.Q<Label>("selection-preview-title").text,
Is.EqualTo(expectedTitle));
}
private FrameClip Clip(string id, float speed, FrameClipEndBehavior end, params int[] durations)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
clip.Configure(id, id, durations.Select((duration, index) =>
new FrameAnimationFrame(null, duration, $"frame_{index}", index)), speed, end);
return clip;
}
private FrameAnimationGraph Graph(
IEnumerable<FrameClip> clips,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> edges,
IEnumerable<AnimationFlow> flows)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure("Graph", "Graph", clips, nodes, edges, flows, string.Empty);
return graph;
}
private T Track<T>(T value) where T : UnityEngine.Object
{
objects.Add(value);
return value;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: be46a724d02949ef842fdcf646c65e76
@@ -1,307 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using AibisDream.FrameAnimation.Timeline;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.TestTools;
using UnityEngine.Timeline;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Tests.EditMode
{
public sealed class FrameAnimationTimelineTests
{
private readonly List<UnityEngine.Object> createdObjects = new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
for (var index = createdObjects.Count - 1; index >= 0; index--)
{
if (createdObjects[index] != null)
{
UnityEngine.Object.DestroyImmediate(createdObjects[index]);
}
}
createdObjects.Clear();
}
[Test]
public void TimelineClip_ReportsDurationAndSupportedCapabilities()
{
var holdClip = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 2f, null, null);
var loopClip = CreateClip("Loop", FrameClipEndBehavior.Loop, 1f, null, null);
var asset = Track(ScriptableObject.CreateInstance<FrameAnimationTimelineClip>());
asset.SetFrameClipForTests(holdClip);
Assert.That(asset.duration, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(asset.TrySynchronizeSourceDuration(out var synchronizedDuration), Is.True);
Assert.That(synchronizedDuration, Is.EqualTo(0.1d).Within(0.000001d));
Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.False);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.ClipIn), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.SpeedMultiplier), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Blending), Is.False);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.False);
asset.SetFrameClipForTests(loopClip);
Assert.That(asset.TrySynchronizeSourceDuration(out _), Is.True);
Assert.That(asset.clipCaps.HasFlag(ClipCaps.Looping), Is.True);
asset.SetFrameClipForTests(null);
Assert.That(asset.duration, Is.EqualTo(1d));
}
[Test]
public void Track_ScrubsBothDirectionsAndHonorsClipInAndSpeedMultiplier()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Scrub", FrameClipEndBehavior.HoldLastFrame, 1f, first, second);
var playerObject = Track(new GameObject("Timeline Sprite Player"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clip, 0d, 0.2d);
setup.Clip.clipIn = 0.05d;
setup.Clip.timeScale = 2d;
Evaluate(setup.Director, 0.03d);
Assert.That(renderer.sprite, Is.SameAs(second));
Evaluate(setup.Director, 0d);
Assert.That(renderer.sprite, Is.SameAs(first));
Evaluate(setup.Director, 0.03d);
Assert.That(renderer.sprite, Is.SameAs(second));
}
[Test]
public void Track_GapUsesPreviousTerminalStateAndOverlapPrefersLaterClip()
{
var baseline = CreateSprite(Color.white);
var firstSprite = CreateSprite(Color.red);
var secondSprite = CreateSprite(Color.blue);
var hold = CreateClip("Hold", FrameClipEndBehavior.HoldLastFrame, 1f, firstSprite);
var later = CreateClip("Later", FrameClipEndBehavior.HoldLastFrame, 1f, secondSprite);
var playerObject = Track(new GameObject("Timeline Overlap Player"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
renderer.sprite = baseline;
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, hold, 0.1d, 0.3d);
AddClip(setup.Track, later, 0.2d, 0.1d);
Evaluate(setup.Director, 0.25d);
Assert.That(renderer.sprite, Is.SameAs(secondSprite));
Evaluate(setup.Director, 0.35d);
Assert.That(renderer.sprite, Is.SameAs(firstSprite));
Evaluate(setup.Director, 0.45d);
Assert.That(renderer.sprite, Is.SameAs(firstSprite));
Evaluate(setup.Director, -0.01d);
Assert.That(renderer.sprite, Is.SameAs(baseline));
}
[Test]
public void Track_AppliesClearAndHideTerminalStates()
{
var sprite = CreateSprite(Color.green);
var clear = CreateClip("Clear", FrameClipEndBehavior.Clear, 1f, sprite);
var hide = CreateClip("Hide", FrameClipEndBehavior.HideTarget, 1f, sprite);
var playerObject = Track(new GameObject("Timeline End States"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clear, 0d, 0.1d);
AddClip(setup.Track, hide, 0.2d, 0.1d);
Evaluate(setup.Director, 0.15d);
Assert.That(renderer.enabled, Is.True);
Assert.That(renderer.sprite, Is.Null);
Evaluate(setup.Director, 0.35d);
Assert.That(renderer.enabled, Is.False);
}
[Test]
public void Track_SamplesImageAndZeroSpeedClipStaysOnFirstFrame()
{
var first = CreateSprite(Color.yellow);
var second = CreateSprite(Color.black);
var clip = CreateClip("Zero", FrameClipEndBehavior.HoldLastFrame, 0f, first, second);
var playerObject = Track(new GameObject(
"Timeline Image Player",
typeof(RectTransform),
typeof(CanvasRenderer)));
var image = playerObject.AddComponent<Image>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clip, 0d, 1d);
Evaluate(setup.Director, 0.75d);
Assert.That(image.sprite, Is.SameAs(first));
Assert.That(setup.Asset.duration, Is.EqualTo(1d));
}
[Test]
public void Track_InvalidClipLogsStructuredErrorWithoutThrowing()
{
var invalid = CreateClip("Empty", FrameClipEndBehavior.HoldLastFrame, 1f);
var playerObject = Track(new GameObject("Timeline Invalid Clip"));
playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, invalid, 0d, 1d);
LogAssert.Expect(
LogType.Error,
new Regex("Frame Animation Timeline 播放失败.*InvalidPlayableData"));
Assert.DoesNotThrow(() => Evaluate(setup.Director, 0.25d));
}
[Test]
public void Track_GathersSpriteAndEnabledPropertiesForPreview()
{
var clip = CreateClip(
"Preview",
FrameClipEndBehavior.HoldLastFrame,
1f,
CreateSprite(Color.white));
var spriteObject = Track(new GameObject("Timeline Sprite Preview"));
spriteObject.AddComponent<SpriteRenderer>();
var spritePlayer = spriteObject.AddComponent<FrameClipPlayer>();
var spriteSetup = CreateTimeline(spritePlayer, clip, 0d, 0.1d);
var spriteCollector = new RecordingPropertyCollector();
spriteSetup.Track.GatherProperties(spriteSetup.Director, spriteCollector);
Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Sprite"));
Assert.That(spriteCollector.Properties, Does.Contain("SpriteRenderer:m_Enabled"));
var imageObject = Track(new GameObject(
"Timeline Image Preview",
typeof(RectTransform),
typeof(CanvasRenderer)));
imageObject.AddComponent<Image>();
var imagePlayer = imageObject.AddComponent<FrameClipPlayer>();
var imageSetup = CreateTimeline(imagePlayer, clip, 0d, 0.1d);
var imageCollector = new RecordingPropertyCollector();
imageSetup.Track.GatherProperties(imageSetup.Director, imageCollector);
Assert.That(imageCollector.Properties, Does.Contain("Image:m_Sprite"));
Assert.That(imageCollector.Properties, Does.Contain("Image:m_Enabled"));
}
private TimelineSetup CreateTimeline(
FrameClipPlayer player,
FrameClip clip,
double start,
double duration)
{
var timeline = Track(ScriptableObject.CreateInstance<TimelineAsset>());
var track = Track(timeline.CreateTrack<FrameAnimationTrack>());
var timelineClip = AddClip(track, clip, start, duration);
var directorObject = Track(new GameObject("Frame Animation Director"));
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
director.SetGenericBinding(track, player);
return new TimelineSetup(
director,
track,
timelineClip,
(FrameAnimationTimelineClip)timelineClip.asset);
}
private TimelineClip AddClip(
FrameAnimationTrack track,
FrameClip clip,
double start,
double duration)
{
var timelineClip = track.CreateClip<FrameAnimationTimelineClip>();
var asset = Track((FrameAnimationTimelineClip)timelineClip.asset);
asset.SetFrameClipForTests(clip);
timelineClip.start = start;
timelineClip.duration = duration;
return timelineClip;
}
private static void Evaluate(PlayableDirector director, double time)
{
director.time = Math.Max(0d, time);
director.Evaluate();
}
private FrameClip CreateClip(
string id,
FrameClipEndBehavior endBehavior,
float speed,
params Sprite[] sprites)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
var frames = new List<FrameAnimationFrame>();
for (var index = 0; index < sprites.Length; index++)
{
frames.Add(new FrameAnimationFrame(sprites[index], 100, $"frame_{index}", index));
}
clip.Configure(id, id, frames, speed, endBehavior);
return clip;
}
private Sprite CreateSprite(Color color)
{
var texture = Track(new Texture2D(2, 2));
texture.SetPixels(new[] { color, color, color, color });
texture.Apply();
return Track(Sprite.Create(texture, new Rect(0, 0, 2, 2), Vector2.one * 0.5f));
}
private T Track<T>(T value) where T : UnityEngine.Object
{
createdObjects.Add(value);
return value;
}
private readonly struct TimelineSetup
{
internal PlayableDirector Director { get; }
internal FrameAnimationTrack Track { get; }
internal TimelineClip Clip { get; }
internal FrameAnimationTimelineClip Asset { get; }
internal TimelineSetup(
PlayableDirector director,
FrameAnimationTrack track,
TimelineClip clip,
FrameAnimationTimelineClip asset)
{
Director = director;
Track = track;
Clip = clip;
Asset = asset;
}
}
private sealed class RecordingPropertyCollector : IPropertyCollector
{
internal List<string> Properties { get; } = new List<string>();
public void PushActiveGameObject(GameObject gameObject) { }
public void PopActiveGameObject() { }
public void AddFromClip(AnimationClip clip) { }
public void AddFromClips(IEnumerable<AnimationClip> clips) { }
public void AddFromName<T>(string name) where T : Component =>
Properties.Add($"{typeof(T).Name}:{name}");
public void AddFromName(string name) { }
public void AddFromClip(GameObject obj, AnimationClip clip) { }
public void AddFromClips(GameObject obj, IEnumerable<AnimationClip> clips) { }
public void AddFromName<T>(GameObject obj, string name) where T : Component =>
Properties.Add($"{typeof(T).Name}:{name}");
public void AddFromName(GameObject obj, string name) { }
public void AddFromName(Component component, string name) { }
public void AddFromComponent(GameObject obj, Component component) { }
public void AddObjectProperties(UnityEngine.Object obj, AnimationClip clip) { }
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 0886b53af79f4406ba432ad67cd822be
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,196 +0,0 @@
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 FrameClipStandaloneImportTests
{
private const string Root = "Assets/Tests/FrameAnimation/GeneratedStandaloneClipTests";
private const string TexturePath = Root + "/Atlas.png";
private const string JsonPath = Root + "/Atlas.json";
private const string ClipPath = Root + "/Standalone.asset";
[SetUp]
public void SetUp()
{
AssetDatabase.DeleteAsset(Root);
EnsureFolder(Root);
}
[TearDown]
public void TearDown()
{
AssetDatabase.DeleteAsset(Root);
AssetDatabase.Refresh();
}
[Test]
public void Preview_UsesOnlyFirstTagAndRespectsDirection()
{
var clip = CreateSourceClip(string.Empty, JsonWithTags("reverse"));
var preview = FrameClipImportService.Preview(clip);
Assert.That(preview.HasErrors, Is.False, JoinIssues(preview));
Assert.That(preview.SourceTagName, Is.EqualTo("First"));
CollectionAssert.AreEqual(new[] { 1, 0 }, preview.SourceIndices);
Assert.That(preview.SpriteDiffs.Count(diff => diff.Kind == FrameAnimationSpriteChangeKind.Added),
Is.EqualTo(2));
}
[Test]
public void Preview_WithoutTagsUsesAllFrames()
{
var clip = CreateSourceClip("Existing", JsonWithoutTags());
var preview = FrameClipImportService.Preview(clip);
Assert.That(preview.HasErrors, Is.False, JoinIssues(preview));
Assert.That(preview.SourceTagName, Is.Empty);
CollectionAssert.AreEqual(new[] { 0, 1 }, preview.SourceIndices);
}
[Test]
public void Apply_InitializesEmptyIdAndRefreshPreservesExistingId()
{
var clip = CreateSourceClip(string.Empty, JsonWithTags("forward"));
var first = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(first, true, out var error), Is.True, error);
Assert.That(clip.Id, Is.EqualTo("First"));
Assert.That(clip.DisplayName, Is.EqualTo("First"));
Assert.That(clip.FrameCount, Is.EqualTo(2));
Assert.That(clip.StandaloneImportSource.LastImportedTagName, Is.EqualTo("First"));
var serialized = new SerializedObject(clip);
serialized.FindProperty("id").stringValue = "CustomId";
serialized.ApplyModifiedPropertiesWithoutUndo();
WriteJson(JsonWithTags("reverse"));
var second = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(second, true, out error), Is.True, error);
Assert.That(clip.Id, Is.EqualTo("CustomId"));
CollectionAssert.AreEqual(new[] { 1, 0 }, clip.Frames.Select(frame => frame.SourceIndex));
}
[Test]
public void Detach_PreservesFramesAndAllowsGraphReference()
{
var clip = CreateSourceClip("External", JsonWithoutTags());
var preview = FrameClipImportService.Preview(clip);
Assert.That(FrameClipImportService.Apply(preview, true, out var error), Is.True, error);
var frames = clip.Frames.ToArray();
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.Configure("Graph", "Graph", Array.Empty<FrameClip>(), Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(), Array.Empty<AnimationFlow>(), clip.Id);
AssetDatabase.CreateAsset(graph, Root + "/Graph.asset");
Assert.That(FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out error), Is.True, error);
Assert.That(FrameAnimationGraphValidator.Validate(graph).HasErrors, Is.False);
FrameClipImportService.DetachSource(clip);
Assert.That(clip.HasStandaloneImportSource, Is.False);
CollectionAssert.AreEqual(frames, clip.Frames);
}
[Test]
public void Preview_RejectsSecondWritableStandaloneOwnerForSameTexture()
{
CreateTexture();
WriteJson(JsonWithoutTags());
var first = CreateLinkedClipAt(ClipPath, "FirstOwner");
var second = CreateLinkedClipAt(Root + "/Second.asset", "SecondOwner");
var preview = FrameClipImportService.Preview(second);
Assert.That(first.StandaloneImportSource.Texture, Is.SameAs(second.StandaloneImportSource.Texture));
Assert.That(preview.HasErrors, Is.True);
Assert.That(preview.Issues.Any(issue =>
issue.Code == FrameAnimationImportIssueCode.TextureOwnershipConflict), Is.True);
}
private static FrameClip CreateSourceClip(string id, string json)
{
CreateTexture();
WriteJson(json);
return CreateLinkedClipAt(ClipPath, id);
}
private static FrameClip CreateLinkedClipAt(string path, string id)
{
var clip = ScriptableObject.CreateInstance<FrameClip>();
clip.Configure(id, string.Empty, Array.Empty<FrameAnimationFrame>());
AssetDatabase.CreateAsset(clip, path);
Assert.That(FrameClipImportService.EnableSource(clip, out var error), Is.True, error);
var serialized = new SerializedObject(clip);
var source = serialized.FindProperty("standaloneImportSource");
source.FindPropertyRelative("texture").objectReferenceValue =
AssetDatabase.LoadAssetAtPath<Texture2D>(TexturePath);
source.FindPropertyRelative("asepriteJson").objectReferenceValue =
AssetDatabase.LoadAssetAtPath<TextAsset>(JsonPath);
source.FindPropertyRelative("manageSpriteSlicing").boolValue = true;
serialized.ApplyModifiedPropertiesWithoutUndo();
AssetDatabase.SaveAssets();
return clip;
}
private static void CreateTexture()
{
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
texture.SetPixels(Enumerable.Range(0, 8).Select(index => index < 4 ? Color.red : Color.green).ToArray());
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 JsonWithTags(string direction)
{
return Json("[{\"name\":\"First\",\"from\":0,\"to\":1,\"direction\":\"" + direction +
"\"},{\"name\":\"Ignored\",\"from\":1,\"to\":1,\"direction\":\"invalid\"}]");
}
private static string JsonWithoutTags()
{
return Json("[]");
}
private static string Json(string tags)
{
return "{" +
"\"frames\":{" +
"\"FrameA\":{\"frame\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":100,\"spriteSourceSize\":{\"x\":0,\"y\":0,\"w\":2,\"h\":2},\"sourceSize\":{\"w\":2,\"h\":2}}," +
"\"FrameB\":{\"frame\":{\"x\":2,\"y\":0,\"w\":2,\"h\":2},\"rotated\":false,\"trimmed\":false,\"duration\":200,\"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 JoinIssues(FrameClipImportPreview preview)
{
return string.Join("\n", preview.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;
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: fd9f1e89abcc595428c137e42258e008
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: ce717c358aef1574ab0edd4df45152e6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -1,209 +0,0 @@
fileFormatVersion: 2
guid: ad7135517a3e7d44fab0e319f21e484c
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 12
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: FrameA
rect:
serializedVersion: 2
x: 0
y: 0
width: 2
height: 2
alignment: 9
pivot: {x: 0.5, y: 0.5}
border: {x: 0, y: 0, z: 0, w: 0}
outline: []
physicsShape: []
tessellationDetail: 0
bones: []
spriteID: 3203ad1f5d9bbf94d8607def47ab8917
internalID: -3259341585125249141
vertices: []
indices:
edges: []
weights: []
- serializedVersion: 2
name: FrameB
rect:
serializedVersion: 2
x: 2
y: 0
width: 2
height: 2
alignment: 9
pivot: {x: 0.5, y: 0.5}
border: {x: 0, y: 0, z: 0, w: 0}
outline: []
physicsShape: []
tessellationDetail: 0
bones: []
spriteID: e18a996baef906548b59c270239ac77d
internalID: 760278470
vertices: []
indices:
edges: []
weights: []
outline: []
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable:
FrameA: -3259341585125249141
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,75 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-7417057855660477100
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 408fe45af4d321848a6c97f0356e8e5c, type: 3}
m_Name: SharedPair
m_EditorClassIdentifier:
id: SharedPair
displayName: SharedPair
frames:
- sprite: {fileID: -3259341585125249141, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3}
durationMs: 100
frameName: FrameA
sourceIndex: 0
- sprite: {fileID: -3259341585125249141, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3}
durationMs: 300
frameName: FrameB
sourceIndex: 1
speed: 1
defaultEndBehavior: 0
hasImportInfo: 1
importInfo:
importSourceId: 52e497304ca9471c89e585f213e29f12
sourceTagName: SharedPair
isMissingFromSource: 0
hasStandaloneImportSource: 0
standaloneImportSource:
texture: {fileID: 0}
asepriteJson: {fileID: 0}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 0
lastSourceHash:
lastImportedTagName:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4c9e0458642034545b44454bbadcf059, type: 3}
m_Name: ImportGraph
m_EditorClassIdentifier:
id: ImportGraph
displayName: Import Graph
clips:
- {fileID: -7417057855660477100}
nodes: []
edges: []
flows: []
importSources:
- internalId: 52e497304ca9471c89e585f213e29f12
displayName: "\u4E2D\u6587\u6765\u6E90"
isEnabled: 1
texture: {fileID: 2800000, guid: ad7135517a3e7d44fab0e319f21e484c, type: 3}
asepriteJson: {fileID: 4900000, guid: eea7be1f48597614e8e13e85c42c8a42, type: 3}
pivot: {x: 0.5, y: 0.5}
manageSpriteSlicing: 1
defaultNewClipEndBehavior: 0
lastSourceHash: cc9e5035efe44f71c253579af48d546926f600feeb8719e30b9995e404c13077
settings:
defaultPlayableId:
newManualClipDefaultEndBehavior: 0
editorData:
nodeEditorData: []
flowEditorData: []
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c31fd505b342238419aa20e24f87671d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -4,547 +4,197 @@ using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Tests.PlayMode
{
/// <summary>
/// 帧动画组件的最小运行时集成测试。
/// 时间推进细节由 EditMode 的 PlaybackSession 契约覆盖。
/// </summary>
public sealed class FrameAnimationPlayerTests
{
private readonly List<UnityEngine.Object> createdObjects = new List<UnityEngine.Object>();
private readonly List<UnityEngine.Object> createdObjects =
new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
{
foreach (var createdObject in createdObjects)
for (var index = createdObjects.Count - 1; index >= 0; index--)
{
if (createdObject != null)
{
UnityEngine.Object.DestroyImmediate(createdObject);
}
if (createdObjects[index] != null)
UnityEngine.Object.DestroyImmediate(createdObjects[index]);
}
createdObjects.Clear();
}
[Test]
public void SpriteRenderer_PlayerShowsFirstFrameAndCompletesWithClear()
public void GraphPlayer_RendersAndAppliesTerminalBehavior()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Clip", FrameClipEndBehavior.Clear, first, second);
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Sprite Player"));
var clip = CreateClip(
"Clip",
FrameClipEndBehavior.Clear,
first,
second);
var graph = CreateGraph(clip);
var gameObject = Track(new GameObject("Graph Player"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var handle = player.Play(clip.Id);
Assert.That(renderer.sprite, Is.EqualTo(first));
Assert.That(renderer.sprite, Is.SameAs(first));
player.EvaluateForTests(0.2d);
Assert.That(handle.IsCompleted, Is.True);
Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(handle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(renderer.sprite, Is.Null);
Assert.That(renderer.enabled, Is.True);
}
[Test]
public void FrameClipPlayer_PlaysDefaultClipWithoutGraph()
public void StandalonePlayer_PlaysConfiguredClipWithoutGraph()
{
var first = CreateSprite(Color.magenta);
var second = CreateSprite(Color.cyan);
var clip = CreateClip("Standalone", FrameClipEndBehavior.HoldLastFrame, first, second);
var gameObject = Track(new GameObject("Standalone Clip Player"));
var clip = CreateClip(
"Standalone",
FrameClipEndBehavior.HoldLastFrame,
first,
second);
var gameObject = Track(new GameObject("Standalone Player"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameClipPlayer>();
player.ConfigureForAuthoring(clip, false, 1f);
var handle = player.Play();
Assert.That(renderer.sprite, Is.SameAs(first));
Assert.That(player.Clip, Is.SameAs(clip));
Assert.That(player.CurrentClip, Is.SameAs(clip));
player.EvaluateForTests(0.2d);
Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(handle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(renderer.sprite, Is.SameAs(second));
}
[Test]
public void FrameClipPlayer_TemporaryClipDoesNotReplaceDefaultAndFailedPlayKeepsActive()
public void PlayRequest_FailureKeepsActiveAndSuccessReplacesIt()
{
var defaultClip = CreateClip("Default", FrameClipEndBehavior.Loop, CreateSprite(Color.red));
var temporary = CreateClip("Temporary", FrameClipEndBehavior.Loop, CreateSprite(Color.blue));
var invalid = Track(ScriptableObject.CreateInstance<FrameClip>());
var gameObject = Track(new GameObject("Temporary Clip Player"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameClipPlayer>();
player.ConfigureForAuthoring(defaultClip, false, 1f);
var active = player.Play(temporary);
var failed = player.Play(invalid);
Assert.That(failed.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Failed));
Assert.That(active.IsCompleted, Is.False);
Assert.That(player.Clip, Is.SameAs(defaultClip));
Assert.That(player.CurrentClip, Is.SameAs(temporary));
}
[Test]
public void GraphAndClipPlayersOnSameObjectReturnConflict()
{
var clip = CreateClip("ConflictClip", FrameClipEndBehavior.Loop, CreateSprite(Color.white));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Player Conflict"));
gameObject.AddComponent<SpriteRenderer>();
var graphPlayer = gameObject.AddComponent<FrameAnimationPlayer>();
graphPlayer.ConfigureForAuthoring(graph, false, 1f);
var clipPlayer = gameObject.AddComponent<FrameClipPlayer>();
clipPlayer.ConfigureForAuthoring(clip, false, 1f);
Assert.That(graphPlayer.Play().Result.Error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.TargetConflict));
Assert.That(clipPlayer.Play().Result.Error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.TargetConflict));
}
[Test]
public void Image_PlayerSupportsHideAndNextPlayReEnablesTarget()
{
var sprite = CreateSprite(Color.green);
var clip = CreateClip("Clip", FrameClipEndBehavior.HideTarget, sprite);
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Image Player", typeof(RectTransform), typeof(CanvasRenderer)));
var image = gameObject.AddComponent<Image>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
player.Play(clip.Id);
player.EvaluateForTests(0.1d);
Assert.That(image.enabled, Is.False);
player.Play(clip.Id, new FrameAnimationPlayOptions(FrameClipEndBehavior.HoldLastFrame));
Assert.That(image.enabled, Is.True);
Assert.That(image.sprite, Is.EqualTo(sprite));
}
[Test]
public void FailedPlayDoesNotReplaceActiveRequest()
{
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, CreateSprite(Color.white));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Player"));
var first = CreateClip(
"First",
FrameClipEndBehavior.Loop,
CreateSprite(Color.white));
var second = CreateClip(
"Second",
FrameClipEndBehavior.Loop,
CreateSprite(Color.black));
var graph = CreateGraph(first, second);
var gameObject = Track(new GameObject("Request Arbitration"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var active = player.Play(clip.Id);
var failedPlay = player.Play("Missing");
var failedRestore = player.RestoreTerminalState("Missing");
var failedHandle = player.Play("Missing");
var failedWait = player.WaitForFirstPass(failedHandle);
var active = player.Play(first.Id);
var failed = player.Play("Missing");
Assert.That(failedPlay.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Failed));
Assert.That(
failedPlay.Result.Error.Code,
Assert.That(failed.Result.Error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.PlayableNotFound));
Assert.That(failedRestore.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Failed));
Assert.That(
failedRestore.Result.Error.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.PlayableNotFound));
Assert.That(failedWait.keepWaiting, Is.False);
Assert.That(active.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentPlayableId, Is.EqualTo(clip.Id));
}
Assert.That(player.CurrentPlayableId, Is.EqualTo(first.Id));
[Test]
public void SuccessfulPlayReplacesPreviousRequestExactlyOnce()
{
var first = CreateClip("First", FrameClipEndBehavior.Loop, CreateSprite(Color.white));
var second = CreateClip("Second", FrameClipEndBehavior.Loop, CreateSprite(Color.black));
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
new[] { first, second },
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
first.Id);
var gameObject = Track(new GameObject("Replace"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var replacement = player.Play(second.Id);
var firstHandle = player.Play(first.Id);
var secondHandle = player.Play(second.Id);
Assert.That(firstHandle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Replaced));
Assert.That(secondHandle.IsCompleted, Is.False);
Assert.That(active.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Replaced));
Assert.That(replacement.IsCompleted, Is.False);
Assert.That(player.CurrentPlayableId, Is.EqualTo(second.Id));
}
[Test]
public void WaitForFirstPass_DirectLoopReleasesAfterFirstWrapAndKeepsPlaying()
public void RestoreTerminalState_RebuildsSavedVisualState()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, first, second);
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Wait Direct Loop"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 0f);
var handle = player.Play(clip.Id);
var wait = player.WaitForFirstPass(handle);
player.EvaluateForTests(1d);
Assert.That(wait.keepWaiting, Is.True);
player.SetSpeed(1f);
player.Pause();
player.EvaluateForTests(1d);
Assert.That(wait.keepWaiting, Is.True);
player.Resume();
player.EvaluateForTests(0.1d);
Assert.That(wait.keepWaiting, Is.True);
player.EvaluateForTests(0.1d);
Assert.That(wait.keepWaiting, Is.False);
Assert.That(handle.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
Assert.That(renderer.sprite, Is.SameAs(first));
}
[Test]
public void WaitForFirstPass_FlowWaitsForIntroAndOneTerminalLoopCycle()
{
var introClip = CreateClip(
"Intro",
FrameClipEndBehavior.HoldLastFrame,
CreateSprite(Color.red));
var loopFirst = CreateSprite(Color.green);
var loopClip = CreateClip(
"Loop",
FrameClipEndBehavior.Loop,
loopFirst,
CreateSprite(Color.blue));
var introNode = new AnimationNode(introClip.Id);
var loopNode = new AnimationNode(loopClip.Id);
var edge = new AnimationEdge(introNode.InternalId, loopNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", introNode.InternalId);
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
new[] { introClip, loopClip },
new[] { introNode, loopNode },
new[] { edge },
new[] { flow },
flow.Id);
var gameObject = Track(new GameObject("Wait Flow Loop"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var handle = player.Play(flow.Id);
var wait = player.WaitForFirstPass(handle);
player.EvaluateForTests(0.1d);
Assert.That(player.CurrentClipId, Is.EqualTo(loopClip.Id));
Assert.That(wait.keepWaiting, Is.True);
player.EvaluateForTests(1.35d);
Assert.That(wait.keepWaiting, Is.False);
Assert.That(handle.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
Assert.That(renderer.sprite, Is.SameAs(loopFirst));
}
[Test]
public void FailedTargetProbeDoesNotDiscardCurrentTargetOrRequest()
{
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, CreateSprite(Color.white));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Player", typeof(RectTransform), typeof(CanvasRenderer)));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var active = player.Play(clip.Id);
gameObject.AddComponent<Image>();
var failed = player.Play(clip.Id);
Assert.That(failed.Result.Error.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.TargetConflict));
Assert.That(active.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
player.EvaluateForTests(0.05d);
Assert.That(active.IsCompleted, Is.False);
}
[Test]
public void ZeroSpeedKeepsRequestPlayingWithoutAdvancing()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, first, second);
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Zero Speed"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 0f);
var handle = player.Play(clip.Id);
player.EvaluateForTests(10d);
Assert.That(handle.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
Assert.That(renderer.sprite, Is.EqualTo(first));
}
[Test]
public void PauseAndResumePreserveFrameProgress()
{
var clip = CreateClip(
"Clip",
FrameClipEndBehavior.HoldLastFrame,
CreateSprite(Color.red),
CreateSprite(Color.blue));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Pause"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
player.Play(clip.Id);
player.EvaluateForTests(0.05d);
player.Pause();
player.EvaluateForTests(1d);
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
player.Resume();
player.EvaluateForTests(0.05d);
Assert.That(player.CurrentFrameIndex, Is.EqualTo(1));
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
}
[Test]
public void TargetConflictReturnsStructuredFailure()
{
var clip = CreateClip("Clip", FrameClipEndBehavior.HoldLastFrame, CreateSprite(Color.white));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Conflict", typeof(RectTransform), typeof(CanvasRenderer)));
gameObject.AddComponent<SpriteRenderer>();
gameObject.AddComponent<Image>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var handle = player.Play(clip.Id);
Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Failed));
Assert.That(handle.Result.Error.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.TargetConflict));
}
[Test]
public void StopAppliesVisualModeWithoutActiveRequest()
{
var gameObject = Track(new GameObject("Stop"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
renderer.sprite = CreateSprite(Color.yellow);
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.Stop(FrameAnimationStopMode.Clear);
Assert.That(renderer.sprite, Is.Null);
Assert.That(renderer.enabled, Is.True);
}
[Test]
public void TryBindGraph_AllowsInitialBindAndRejectsReplacementAfterPlayback()
{
var firstGraph = CreateDirectGraph(
CreateClip("First", FrameClipEndBehavior.Loop, CreateSprite(Color.red)));
var secondGraph = CreateDirectGraph(
CreateClip("Second", FrameClipEndBehavior.Loop, CreateSprite(Color.blue)));
var gameObject = Track(new GameObject("Runtime Bind"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
Assert.That(player.TryBindGraph(firstGraph, out var bindError), Is.True);
Assert.That(bindError.Code, Is.EqualTo(FrameAnimationPlaybackErrorCode.None));
player.Play("First");
Assert.That(player.TryBindGraph(secondGraph, out var replaceError), Is.False);
Assert.That(
replaceError.Code,
Is.EqualTo(FrameAnimationPlaybackErrorCode.GraphBindingNotAllowed));
Assert.That(player.Graph, Is.SameAs(firstGraph));
}
[Test]
public void RestoreTerminalState_AppliesTargetEndBehaviors()
{
var holdFirst = CreateSprite(Color.red);
var holdLast = CreateSprite(Color.blue);
var holdClip = CreateClip(
var terminalSprite = CreateSprite(Color.green);
var hold = CreateClip(
"Hold",
FrameClipEndBehavior.HoldLastFrame,
holdFirst,
holdLast);
var clearClip = CreateClip(
"Clear",
FrameClipEndBehavior.Clear,
CreateSprite(Color.red));
var hideClip = CreateClip(
"Hide",
CreateSprite(Color.red),
terminalSprite);
var hidden = CreateClip(
"Hidden",
FrameClipEndBehavior.HideTarget,
CreateSprite(Color.blue));
var loopFirst = CreateSprite(Color.green);
var loopSecond = CreateSprite(Color.yellow);
var loopClip = CreateClip(
"Loop",
FrameClipEndBehavior.Loop,
loopFirst,
loopSecond);
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
new[] { holdClip, clearClip, hideClip, loopClip },
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
holdClip.Id);
var gameObject = Track(new GameObject("Restore End Behaviors"));
var graph = CreateGraph(hold, hidden);
var gameObject = Track(new GameObject("Restore Terminal State"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var holdHandle = player.RestoreTerminalState(holdClip.Id);
Assert.That(holdHandle.IsCompleted, Is.True);
Assert.That(holdHandle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Stopped));
Assert.That(renderer.sprite, Is.SameAs(holdLast));
Assert.That(renderer.enabled, Is.True);
var holdResult = player.RestoreTerminalState(hold.Id);
player.RestoreTerminalState(clearClip.Id);
Assert.That(renderer.sprite, Is.Null);
Assert.That(renderer.enabled, Is.True);
player.RestoreTerminalState(hideClip.Id);
Assert.That(renderer.enabled, Is.False);
renderer.enabled = true;
var loopHandle = player.RestoreTerminalState(loopClip.Id);
Assert.That(loopHandle.IsCompleted, Is.False);
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
Assert.That(renderer.sprite, Is.SameAs(loopFirst));
}
[Test]
public void RestoreTerminalState_FlowUsesTerminalStepEndState()
{
var firstClip = CreateClip(
"First",
FrameClipEndBehavior.HoldLastFrame,
CreateSprite(Color.red));
var terminalSprite = CreateSprite(Color.green);
var terminalClip = CreateClip(
"Terminal",
FrameClipEndBehavior.HoldLastFrame,
CreateSprite(Color.blue),
terminalSprite);
var firstNode = new AnimationNode(firstClip.Id);
var terminalNode = new AnimationNode(terminalClip.Id);
var edge = new AnimationEdge(firstNode.InternalId, terminalNode.InternalId);
var flow = new AnimationFlow("Flow", "Flow", firstNode.InternalId);
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
new[] { firstClip, terminalClip },
new[] { firstNode, terminalNode },
new[] { edge },
new[] { flow },
flow.Id);
var gameObject = Track(new GameObject("Restore Flow"));
var renderer = gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
var handle = player.RestoreTerminalState(flow.Id);
Assert.That(handle.IsCompleted, Is.True);
Assert.That(holdResult.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Completed));
Assert.That(renderer.sprite, Is.SameAs(terminalSprite));
Assert.That(renderer.enabled, Is.True);
player.RestoreTerminalState(hidden.Id);
Assert.That(renderer.enabled, Is.False);
}
[UnityTest]
public IEnumerator OnDisableSettlesActiveRequestAsStopped()
public IEnumerator Disable_SettlesActiveRequestAsStopped()
{
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, CreateSprite(Color.white));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Disable"));
var clip = CreateClip(
"Loop",
FrameClipEndBehavior.Loop,
CreateSprite(Color.white));
var gameObject = Track(new GameObject("Disable Player"));
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, false, 1f);
player.ConfigureForAuthoring(CreateGraph(clip), false, 1f);
var handle = player.Play(clip.Id);
player.enabled = false;
yield return null;
Assert.That(handle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Stopped));
Assert.That(handle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Stopped));
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Stopped));
}
[UnityTest]
public IEnumerator PlayOnEnableStartsDefaultPlayableFromBeginning()
{
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, CreateSprite(Color.cyan));
var graph = CreateDirectGraph(clip);
var gameObject = Track(new GameObject("Auto Play"));
gameObject.SetActive(false);
gameObject.AddComponent<SpriteRenderer>();
var player = gameObject.AddComponent<FrameAnimationPlayer>();
player.ConfigureForAuthoring(graph, true, 1f);
gameObject.SetActive(true);
yield return null;
Assert.That(player.State, Is.EqualTo(FrameAnimationPlaybackState.Playing));
Assert.That(player.CurrentPlayableId, Is.EqualTo(clip.Id));
Assert.That(player.CurrentFrameIndex, Is.EqualTo(0));
}
private FrameClip CreateClip(string id, FrameClipEndBehavior endBehavior, params Sprite[] sprites)
private FrameClip CreateClip(
string id,
FrameClipEndBehavior endBehavior,
params Sprite[] sprites)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
var frames = new List<FrameAnimationFrame>();
for (var index = 0; index < sprites.Length; index++)
{
frames.Add(new FrameAnimationFrame(sprites[index], 100, $"frame_{index}", index));
frames.Add(new FrameAnimationFrame(
sprites[index],
100,
$"frame_{index}",
index));
}
clip.Configure(id, id, frames, 1f, endBehavior);
return clip;
}
private FrameAnimationGraph CreateDirectGraph(FrameClip clip)
private FrameAnimationGraph CreateGraph(params FrameClip[] clips)
{
var graph = Track(ScriptableObject.CreateInstance<FrameAnimationGraph>());
graph.Configure(
"Graph",
"Graph",
new[] { clip },
clips,
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
clip.Id);
clips[0].Id);
return graph;
}
@@ -553,7 +203,10 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
var texture = Track(new Texture2D(2, 2));
texture.SetPixels(new[] { color, color, color, color });
texture.Apply();
return Track(Sprite.Create(texture, new Rect(0, 0, 2, 2), new Vector2(0.5f, 0.5f)));
return Track(Sprite.Create(
texture,
new Rect(0, 0, 2, 2),
new Vector2(0.5f, 0.5f)));
}
private T Track<T>(T value) where T : UnityEngine.Object
@@ -11,7 +11,8 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
{
public sealed class FrameAnimationTimelinePlayModeTests
{
private readonly List<UnityEngine.Object> createdObjects = new List<UnityEngine.Object>();
private readonly List<UnityEngine.Object> createdObjects =
new List<UnityEngine.Object>();
[TearDown]
public void TearDown()
@@ -19,20 +20,19 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
for (var index = createdObjects.Count - 1; index >= 0; index--)
{
if (createdObjects[index] != null)
{
UnityEngine.Object.DestroyImmediate(createdObjects[index]);
}
}
createdObjects.Clear();
}
[UnityTest]
public IEnumerator Director_TakesControlStopsAtTerminalAndPlayerCanPlayAgain()
public IEnumerator Director_TakesControlSupportsSeekAndReleasesPlayer()
{
var first = CreateSprite(Color.red);
var second = CreateSprite(Color.blue);
var clip = CreateClip("Timeline", FrameClipEndBehavior.HoldLastFrame, first, second);
var playerObject = Track(new GameObject("Timeline Runtime Player"));
var clip = CreateClip(first, second);
var playerObject = Track(new GameObject("Timeline Player"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
player.ConfigureForAuthoring(clip, false, 1f);
@@ -44,47 +44,19 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
setup.Director.Evaluate();
yield return null;
Assert.That(ordinaryHandle.IsCompleted, Is.True);
Assert.That(ordinaryHandle.Result.Reason, Is.EqualTo(FrameAnimationCompletionReason.Replaced));
Assert.That(renderer.sprite, Is.SameAs(second));
setup.Director.Stop();
Assert.That(renderer.sprite, Is.SameAs(second));
var nextHandle = player.Play();
Assert.That(nextHandle.IsCompleted, Is.False);
Assert.That(renderer.sprite, Is.SameAs(first));
}
[UnityTest]
public IEnumerator Director_CanPauseSeekBackwardAndReplayLoop()
{
var first = CreateSprite(Color.green);
var second = CreateSprite(Color.yellow);
var clip = CreateClip("Loop", FrameClipEndBehavior.Loop, first, second);
var playerObject = Track(new GameObject("Timeline Runtime Loop"));
var renderer = playerObject.AddComponent<SpriteRenderer>();
var player = playerObject.AddComponent<FrameClipPlayer>();
var setup = CreateTimeline(player, clip);
setup.Clip.duration = 0.6d;
setup.Director.Play();
setup.Director.time = 0.15d;
setup.Director.Evaluate();
setup.Director.Pause();
Assert.That(ordinaryHandle.Result.Reason,
Is.EqualTo(FrameAnimationCompletionReason.Replaced));
Assert.That(renderer.sprite, Is.SameAs(second));
setup.Director.time = 0.01d;
setup.Director.Evaluate();
Assert.That(renderer.sprite, Is.SameAs(first));
setup.Director.Play();
setup.Director.time = 0.25d;
setup.Director.Evaluate();
yield return null;
Assert.That(renderer.sprite, Is.SameAs(first));
setup.Director.Stop();
Assert.That(renderer.sprite, Is.Not.Null);
var nextHandle = player.Play();
Assert.That(nextHandle.IsCompleted, Is.False);
Assert.That(renderer.sprite, Is.SameAs(first));
}
private TimelineSetup CreateTimeline(FrameClipPlayer player, FrameClip clip)
@@ -94,24 +66,34 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
var timelineClip = track.CreateClip<FrameAnimationTimelineClip>();
var asset = Track((FrameAnimationTimelineClip)timelineClip.asset);
asset.SetFrameClipForTests(clip);
timelineClip.start = 0d;
timelineClip.duration = asset.duration;
var directorObject = Track(new GameObject("Frame Animation Runtime Director"));
var directorObject = Track(new GameObject("Timeline Director"));
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
director.SetGenericBinding(track, player);
return new TimelineSetup(director, timelineClip);
return new TimelineSetup(director);
}
private FrameClip CreateClip(string id, FrameClipEndBehavior endBehavior, params Sprite[] sprites)
private FrameClip CreateClip(params Sprite[] sprites)
{
var clip = Track(ScriptableObject.CreateInstance<FrameClip>());
var frames = new List<FrameAnimationFrame>();
for (var index = 0; index < sprites.Length; index++)
{
frames.Add(new FrameAnimationFrame(sprites[index], 100, $"frame_{index}", index));
frames.Add(new FrameAnimationFrame(
sprites[index],
100,
$"frame_{index}",
index));
}
clip.Configure(id, id, frames, 1f, endBehavior);
clip.Configure(
"Timeline",
"Timeline",
frames,
1f,
FrameClipEndBehavior.HoldLastFrame);
return clip;
}
@@ -120,7 +102,10 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
var texture = Track(new Texture2D(2, 2));
texture.SetPixels(new[] { color, color, color, color });
texture.Apply();
return Track(Sprite.Create(texture, new Rect(0, 0, 2, 2), Vector2.one * 0.5f));
return Track(Sprite.Create(
texture,
new Rect(0, 0, 2, 2),
Vector2.one * 0.5f));
}
private T Track<T>(T value) where T : UnityEngine.Object
@@ -131,14 +116,12 @@ namespace AibisDream.FrameAnimation.Tests.PlayMode
private readonly struct TimelineSetup
{
internal PlayableDirector Director { get; }
internal TimelineClip Clip { get; }
internal TimelineSetup(PlayableDirector director, TimelineClip clip)
internal TimelineSetup(PlayableDirector director)
{
Director = director;
Clip = clip;
}
internal PlayableDirector Director { get; }
}
}
}
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 84ac13641a36486d8f1a891bad6b1f28
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f6244beee7e24912bd0e6019ac802f92
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,19 +0,0 @@
{
"name": "AibisDream.Huoshan.Tests.PlayMode",
"rootNamespace": "AibisDream.Huoshan.Tests.PlayMode",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false,
"optionalUnityReferences": [
"TestAssemblies"
]
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 6ab7be47fb2561242a443b00c0089c26
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,90 +0,0 @@
using System;
using System.Collections;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace AibisDream.Huoshan.Tests.PlayMode
{
public sealed class ExpressionParticlePlayModeTests
{
private const string RuntimeAssemblyName = "Assembly-CSharp";
[UnityTest]
public IEnumerator LongWords_ConnectThroughVisualEdgeDistance()
{
GameObject firstObject = new GameObject("FirstWordParticle");
GameObject secondObject = new GameObject("SecondWordParticle");
try
{
Type particleType = RequireRuntimeType(
"AibisDream.MiniGame.Language.CandidateParticle");
Component first = firstObject.AddComponent(particleType);
Component second = secondObject.AddComponent(particleType);
Invoke(first, "ForceSetUnitText", "internationalization");
Invoke(second, "ForceSetUnitText", "characteristically");
yield return null;
Bounds firstBounds = (Bounds)Invoke(first, "GetVisualWorldBounds");
Bounds secondBounds = (Bounds)Invoke(second, "GetVisualWorldBounds");
secondObject.transform.position = new Vector3(
firstBounds.extents.x + secondBounds.extents.x + 0.1f,
0f,
0f);
Type geometryType = RequireRuntimeType(
"AibisDream.MiniGame.Language.ExpressionParticleGeometry");
MethodInfo connection = geometryType.GetMethod(
"TryGetConnection",
BindingFlags.Public | BindingFlags.Static);
object[] arguments =
{
first,
second,
0.01f,
0.18f,
Vector3.zero,
Vector3.zero,
0f
};
Assert.That(connection, Is.Not.Null);
bool connected = (bool)connection.Invoke(null, arguments);
Assert.That(connected, Is.True);
Assert.That((float)arguments[6], Is.GreaterThan(0f));
Assert.That(
Vector3.Distance(
firstObject.transform.position,
secondObject.transform.position),
Is.GreaterThan(0.01f));
}
finally
{
UnityEngine.Object.Destroy(firstObject);
UnityEngine.Object.Destroy(secondObject);
}
yield return null;
}
private static Type RequireRuntimeType(string fullName)
{
Type type = Type.GetType($"{fullName}, {RuntimeAssemblyName}");
Assert.That(type, Is.Not.Null, fullName);
return type;
}
private static object Invoke(
object target,
string methodName,
params object[] arguments)
{
MethodInfo method = target.GetType().GetMethod(
methodName,
BindingFlags.Public | BindingFlags.Instance);
Assert.That(method, Is.Not.Null, methodName);
return method.Invoke(target, arguments);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 26043594097949158ba1572f70c94f78
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cd3aa47d1ff14e328957509007908436
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+22
View File
@@ -0,0 +1,22 @@
# Temporary Unity Tests
开发中、尚未形成稳定契约的自动测试统一放在这里,不要混入长期领域测试。
## 目录约定
```text
Assets/Tests/_Temporary/<Feature>/
├── README.md # 用途、负责人、创建日期、删除/转正条件
├── Editor/ # 默认 Assembly-CSharp 的临时 EditMode 测试
├── EditMode/ # 独立 asmdef 系统的临时 EditMode 测试
└── PlayMode/ # 仅在确实依赖 Unity 运行时行为时使用
```
功能目录中的 `README.md` 至少记录:
- Purpose:当前阶段需要验证什么。
- Owner:由谁或哪个功能分支负责清理。
- Created:创建日期。
- Remove or promote when:明确的删除或转正条件。
功能验收、方案替换、功能放弃或已有长期契约测试覆盖时,删除整个功能目录。需要长期保留的测试只迁移最小必要用例。
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 93f5a8a1c0b04437afe675e5a1f5330c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+170
View File
@@ -0,0 +1,170 @@
# AIBIS Dream Unity Test 规范
## 1. 目标
Unity Test 用来快速发现问题、保护已经确认的系统契约和高风险回归。可以为开发中的功能编写阶段性自动测试,但它们必须与长期测试隔离,并在达到删除或转正条件时处理。代码修改不要求机械地新增测试。
测试套件应满足三个目标:
1. 失败时能指出一个明确的玩家行为、数据兼容性或系统边界问题。
2. 重构实现但不改变行为时,大部分测试无需修改。
3. 开发者能在合理时间内运行完整 EditMode 套件,并愿意经常运行它。
## 2. 何时应该添加测试
满足以下任一项时,适合添加或扩展长期 Unity Test:
- 已验收、会长期存在的业务规则或公共 API 契约。
- 存档格式、Addressable 地址、资源导入等一旦破坏就会造成严重后果的边界。
- 曾经发生且很可能复发的缺陷;测试应复现用户可观察的错误。
- 复杂纯逻辑存在清晰输入输出,测试比场景手测更快、更可靠。
- 必须由 Unity 生命周期、Timeline、协程或真实组件组合才能验证的关键集成。
以下测试不应直接进入长期测试套件:
- 功能仍处于方案探索、中间阶段或尚未验收;如确有自动验证价值,应进入临时测试目录。
- 仅调整编辑器窗口布局、按钮位置、颜色、动效参数或调试展示。
- 私有方法、字段赋值、属性包装器等实现细节。
- 同一行为已经由更低层、更快的测试覆盖。
- 为每个枚举值、边界值机械复制一个测试,而这些输入属于同一等价类。
- 资源内容完整性扫描。此类检查优先放到专用 Validator、导入检查或发布前检查中。
- 只服务开发期的临时工具、测试存档、实验性入口;此类测试应随对应功能一起删除,或在工具转正时重新评审。
代码发生变化本身不是添加测试的理由。没有满足上述条件时,可以不改测试。
### 2.1 临时自动测试
开发中的复杂功能可以使用临时自动测试辅助迭代。临时并不代表低质量:它们仍需可重复运行、可靠清理状态,并选择合适的测试层级。
临时测试必须满足:
- 放在 `Assets/Tests/_Temporary/<Feature>/`,不能混入长期领域套件。
- 目录内包含简短说明,写明用途、负责人、创建日期以及删除或转正条件。
- 测试名称或断言可以面向当前阶段,但不得迫使生产代码暴露只为测试服务的公共 API。
- 功能验收、方案替换、功能放弃或已有长期契约测试覆盖时,删除整个临时目录。
- 若测试发现了需要长期保护的契约,只将最小必要用例整理后迁入正式领域套件,其余删除。
- 评审和功能验收时必须检查 `_Temporary`;不允许没有清理条件的临时测试长期滞留。
## 3. 测试分层
一个行为默认只选择能够完整覆盖它的最低测试层级,不能按“纯逻辑一份、组件一份、场景再一份”的方式机械叠加。只有上层测试覆盖了下层无法发现的独立风险时,才允许跨层重复。
### 3.1 EditMode 逻辑契约
首选层级。用于解析、状态机、序列化 DTO、规则计算和错误处理。应尽量不加载场景、不等待帧、不依赖真实时间。
### 3.2 EditMode 资源集成
只保留少量端到端路径,例如“源文件 -> 导入服务 -> 可播放资源”或“Prefab -> Addressable 地址 -> 运行时组件”。不要逐个测试编辑器操作步骤和窗口状态。
### 3.3 PlayMode 关键集成
仅当行为依赖 `OnEnable` / `OnDisable`、协程、`PlayableDirector`、物理或真实帧推进时使用。能通过显式求值器或纯逻辑 EditMode 测试验证的行为,不应再写一份 PlayMode 测试。
原则上,每个系统只保留少数关键 PlayMode 主链。新增 PlayMode 测试时,需要说明为什么 EditMode 无法覆盖。
## 4. 目录与程序集
- 默认 `Assembly-CSharp` 中运行时代码的测试放在 `Assets/Editor/Tests/`。这是因为 asmdef 测试程序集不能直接依赖 Unity 的预定义运行时程序集。
- 已有独立 asmdef 的系统放在 `Assets/Tests/<System>/EditMode/``PlayMode/`
- 临时测试统一从 `Assets/Tests/_Temporary/<Feature>/` 开始组织。默认程序集的临时 EditMode 测试放入其 `Editor/` 子目录;独立程序集测试可在功能目录中建立最小临时 asmdef。
- 测试 asmdef 必须包含 `UNITY_INCLUDE_TESTS``TestAssemblies`,并且只引用被测系统真正需要的程序集。
- 不把测试脚本混放在业务 `Editor/` 工具、运行时代码或功能目录中。
- 测试生成资源使用唯一临时目录,并在 `TearDown` 中删除;生成物和 `.meta` 不得提交。
当前保留的测试职责如下:
| 套件 | 职责 |
| --- | --- |
| `Assets/Editor/Tests` | 音频/存档兼容、本地化、Yarn 元数据、设置值、关键 Prefab 契约 |
| `FrameAnimationCoreTests` | 图模型、解析拒绝、播放会话、结束行为、请求句柄 |
| `FrameAnimationImportTests` | Aseprite 解析和一次完整刷新链路 |
| `FrameAnimationPlayerTests` | 运行时组件、请求替换、恢复和生命周期 |
| `FrameAnimationTimelinePlayModeTests` | Timeline 接管、Seek 和释放播放器 |
## 5. 单个测试的设计
### 命名
使用 `Subject_State_Outcome`,例如:
```csharp
[Test]
public void PlayRequest_MissingPlayable_KeepsActiveRequest()
```
名称描述行为,不描述内部调用顺序。
### 结构
每个测试保持清晰的 Arrange / Act / Assert 三段。一个测试可以有多个断言,但这些断言必须共同说明一个失败原因。
优先断言:
- 玩家或调用方可观察的最终状态。
- 稳定的错误码、存档字段、资源地址或跨系统契约。
- 必须保持的副作用边界,例如失败请求不能替换当前播放。
避免断言:
- 私有字段的每个中间值。
- UI 层级中的全部节点和样式。
- 与结果无关的精确调用次数或实现顺序。
- 大段序列化快照;只检查兼容性所需字段。
### 用例合并
同一等价类的输入放在一个测试内循环验证,避免大量 `[TestCase]` 让测试面板产生虚假的覆盖感。只有不同输入应呈现为独立失败、且失败定位确实有价值时才使用 `[TestCase]`
不要为了减少文件数,把互不相关的契约塞进同一个测试。合并的是重复路径,不是失败原因。
## 6. 稳定性与清理
- 不依赖网络、系统时间、线程调度顺序、随机种子或本机语言环境。
- 修改全局状态时,必须在 `TearDown``finally` 中恢复。
- 创建的 `GameObject``ScriptableObject`、纹理和 Sprite 必须销毁,销毁顺序与创建顺序相反。
- 预期日志使用 `LogAssert.Expect`,不要为了通过测试而全局忽略日志。
- 资产测试使用同步导入和唯一目录;清理逻辑应幂等,不能掩盖原始断言失败。
- 不直接调用 `Resources.Load`;测试中的运行时资源获取同样遵守 `ResourceSystem` 规范。
## 7. 评审检查清单
新增或修改测试前检查:
1. 这是长期契约测试,还是阶段性的临时测试?目录是否匹配?
2. 临时测试是否写明删除或转正条件?
3. 现有长期测试能否扩展,而不是新建文件或新建程序集?
4. 是否选择了能够覆盖该风险的最低层级?
5. 上下层是否重复验证同一件事,而没有新增独立风险覆盖?
6. 删除或重构实现后,长期测试是否仍然表达同一个需求?
7. 失败信息能否直接告诉开发者哪条契约坏了?
8. 是否创建了临时资产、全局状态或日志,且全部可靠清理?
若测试归属、层级选择或清理条件无法明确回答,不应立即添加测试。
## 8. Codex 行为约束
Codex 修改本仓库时遵守以下规则:
- 可以主动添加有明确反馈价值的 Unity Test,但不因“修改了代码”在每个层级机械补测试。
- 对每个风险选择一个主要测试层级;只有存在不同的集成失败模式时才增加上层测试。
- 未验收、中间态或实验功能的测试必须放在 `_Temporary`,并同时写明删除或转正条件。
- 稳定契约优先扩展现有领域套件;没有新的长期领域边界时,不新建永久测试文件或 asmdef。
- 添加 PlayMode 测试前,确认 EditMode 无法覆盖对应的 Unity 集成风险。
- 如果测试数量或测试代码增幅明显大于生产改动,应先重新审视测试层级和重复覆盖。
- 功能验收时检查 `_Temporary`,将最小必要契约测试转正并删除其余阶段性测试。
- 功能被删除、契约被替换或测试只剩实现细节时,应同步删除测试,不保留“以防万一”的历史测试。
## 9. 运行方式
关闭正在打开该项目的 Unity Editor 后运行:
```powershell
& 'C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe' `
-batchmode -projectPath 'D:\UnityProject\aibis-dream' `
-runTests -testPlatform EditMode `
-testResults 'D:\UnityProject\aibis-dream\Temp\EditModeResults.xml' `
-logFile 'D:\UnityProject\aibis-dream\Temp\EditMode.log'
```
高风险运行时或 Timeline 修改再运行 PlayMode。纯文档、编辑器样式或不影响运行时契约的修改无需机械运行全部 PlayMode。
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: aa61d2651c0743fd99a9e0ced265ec4b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: