chore: 自动测试重构及规范
This commit is contained in:
@@ -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(
|
||||
"noise|doubt",
|
||||
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:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84ac13641a36486d8f1a891bad6b1f28
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd3aa47d1ff14e328957509007908436
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:明确的删除或转正条件。
|
||||
|
||||
功能验收、方案替换、功能放弃或已有长期契约测试覆盖时,删除整个功能目录。需要长期保留的测试只迁移最小必要用例。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93f5a8a1c0b04437afe675e5a1f5330c
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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。
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa61d2651c0743fd99a9e0ced265ec4b
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user