#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( () => SnapshotPersistence.Deserialize(json)); } [Test] public void SerialTaskQueue_DoesNotDropOrReorderRequests() { var queue = new SerialTaskQueue(); var releaseFirst = new TaskCompletionSource(); var firstStarted = new TaskCompletionSource(); var order = new List(); 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); } [Test] public void InteractionLock_ComposesWithDialogueLock() { var gameObject = new GameObject("EventSystemEx-Test"); try { var eventSystem = gameObject.AddComponent(); 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