#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); } [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(); 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