333 lines
13 KiB
C#
333 lines
13 KiB
C#
#if UNITY_EDITOR
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using AibisDream.SaveSystem;
|
|
using Newtonsoft.Json;
|
|
using NUnit.Framework;
|
|
|
|
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_UsesSourceNodeMetadata()
|
|
{
|
|
var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA");
|
|
request.Snapshot.anchor.nodeName = string.Empty;
|
|
request.SaveTrigger = "DialogueExit";
|
|
request.ResumeMode = nameof(SaveResumeMode.StateOnly);
|
|
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.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public void Repository_LegacyMetadata_DefaultsToRestartNode()
|
|
{
|
|
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
|
|
entry.Meta.saveTrigger = null;
|
|
entry.Meta.resumeMode = null;
|
|
|
|
Assert.That(entry.SaveTrigger, Is.EqualTo("NodeEnter"));
|
|
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.RestartNode)));
|
|
}
|
|
|
|
[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 }));
|
|
}
|
|
|
|
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
|
|
}
|
|
},
|
|
DedupeKey = key,
|
|
EntryId = TestSaveRepository.StableHash(key),
|
|
ChapterId = sceneSoName,
|
|
ChapterTitle = sceneSoName,
|
|
SceneSoName = sceneSoName,
|
|
YarnProjectId = yarnProject,
|
|
NodeName = nodeName,
|
|
SaveTrigger = "NodeEnter",
|
|
ResumeMode = nameof(SaveResumeMode.RestartNode),
|
|
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,
|
|
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
|