#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using AibisDream.SaveSystem; using AibisDream.Utility; using Newtonsoft.Json; using NUnit.Framework; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEngine; namespace AibisDream.EditorTools { /// 从本机 testsavs 选择章节、去重排序并提升为可提交测试档。 public sealed class DevSavePromoteWindow : EditorWindow { private readonly List groups = new(); private readonly List preview = new(); private int selectedGroup; private string sectionId = string.Empty; private string sectionTitle = string.Empty; private string status = string.Empty; private Vector2 scroll; [MenuItem("Tools/Aibis/Dev Save Promote")] public static void Open() { var window = GetWindow("Dev Save Promote"); window.minSize = new Vector2(720f, 520f); window.Show(); } private void OnEnable() => RefreshGroups(); private void OnGUI() { EditorGUILayout.LabelField("测试存档提升与校验", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "来源是本机 testsavs;目标是项目根目录的 DevSaveFiles。该目录可提交,但不会被 Unity 自动打包。", MessageType.Info); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("刷新本机归档", GUILayout.Height(26f))) RefreshGroups(); if (GUILayout.Button("校验全部已提交测试档", GUILayout.Height(26f))) ValidateCommittedCatalog(); } if (groups.Count == 0) { EditorGUILayout.HelpBox("本机没有可用 testsavs。", MessageType.Warning); DrawStatus(); return; } var labels = groups.Select(group => $"{group.SceneSoName} | {group.YarnProjectId} | {group.SceneName} ({group.Items.Count})").ToArray(); var nextGroup = EditorGUILayout.Popup("章节归档", Mathf.Clamp(selectedGroup, 0, labels.Length - 1), labels); if (nextGroup != selectedGroup || preview.Count == 0) { selectedGroup = nextGroup; SelectGroup(groups[selectedGroup]); } sectionId = EditorGUILayout.TextField("Section Id", sectionId); sectionTitle = EditorGUILayout.TextField("显示名称", sectionTitle); EditorGUILayout.LabelField("规则", "每个 Yarn 节点保留 savedAt 最新一份;默认按 savedAt 升序"); using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); if (GUILayout.Button("提升当前章节", GUILayout.Width(180f), GUILayout.Height(28f))) Promote(); } EditorGUILayout.Space(5f); EditorGUILayout.LabelField($"阶段预览:{preview.Count}", EditorStyles.boldLabel); scroll = EditorGUILayout.BeginScrollView(scroll); for (var i = 0; i < preview.Count; i++) { var item = preview[i]; using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField($"{i + 1:000}", GUILayout.Width(38f)); item.Label = EditorGUILayout.TextField(item.Label, GUILayout.Width(210f)); EditorGUILayout.LabelField(item.NodeName, GUILayout.MinWidth(180f)); EditorGUILayout.LabelField(item.SavedAt, GUILayout.Width(140f)); using (new EditorGUI.DisabledScope(i == 0)) { if (GUILayout.Button("↑", GUILayout.Width(26f))) Move(i, i - 1); } using (new EditorGUI.DisabledScope(i == preview.Count - 1)) { if (GUILayout.Button("↓", GUILayout.Width(26f))) Move(i, i + 1); } } } EditorGUILayout.EndScrollView(); DrawStatus(); } private void DrawStatus() { if (!string.IsNullOrWhiteSpace(status)) EditorGUILayout.HelpBox(status, status.StartsWith("OK", StringComparison.Ordinal) ? MessageType.Info : MessageType.Warning); } private void RefreshGroups() { groups.Clear(); preview.Clear(); var root = ConstRef.TestAutoSaveArchivePath; if (!Directory.Exists(root)) { status = $"找不到本机归档:{root}"; return; } var candidates = new List(); foreach (var directory in Directory.GetDirectories(root)) { var snapshotPath = Path.Combine(directory, $"{ConstRef.SaveSnapshotFileName}.json"); if (!File.Exists(snapshotPath)) continue; try { var snapshot = SnapshotPersistence.Load(Path.Combine(directory, ConstRef.SaveSnapshotFileName)); if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) continue; candidates.Add(new Candidate( snapshotPath, Path.Combine(directory, $"{ConstRef.SaveMetaFileName}.json"), snapshot.scene?.sceneName, snapshot.anchor.sceneSoName, snapshot.anchor.yarnProjectId, snapshot.anchor.nodeName, snapshot.savedAt)); } catch (Exception ex) { Debug.LogWarning($"[DevSavePromote] 跳过损坏归档 {snapshotPath}: {ex.Message}"); } } groups.AddRange(candidates .GroupBy(item => $"{item.SceneName}\n{item.SceneSoName}\n{item.YarnProjectId}", StringComparer.Ordinal) .Select(group => new ArchiveGroup(group.ToList())) .OrderBy(group => group.SceneSoName, StringComparer.Ordinal)); selectedGroup = Mathf.Clamp(selectedGroup, 0, Math.Max(0, groups.Count - 1)); if (groups.Count > 0) SelectGroup(groups[selectedGroup]); status = $"OK:读取 {candidates.Count} 份归档,分为 {groups.Count} 个章节。"; } private void SelectGroup(ArchiveGroup group) { preview.Clear(); preview.AddRange(group.Items .GroupBy(item => item.NodeName, StringComparer.Ordinal) .Select(nodes => nodes.OrderByDescending(item => item.SavedAt, StringComparer.Ordinal).First()) .OrderBy(item => item.SavedAt, StringComparer.Ordinal)); sectionId = SanitizeIdentifier(group.SceneSoName); sectionTitle = group.SceneSoName; } private void Move(int from, int to) { if (from < 0 || to < 0 || from >= preview.Count || to >= preview.Count) return; var item = preview[from]; preview.RemoveAt(from); preview.Insert(to, item); GUI.FocusControl(null); } private void Promote() { if (preview.Count == 0 || string.IsNullOrWhiteSpace(sectionId)) { status = "没有可提升阶段,或 Section Id 为空。"; return; } var validation = ValidateCandidates(preview); if (validation.Count > 0) { status = string.Join("\n", validation); return; } var group = groups[selectedGroup]; var root = ConstRef.TestSaveFilePath; var targetSection = Path.Combine(root, sectionId); if (Directory.Exists(targetSection)) Directory.Delete(targetSection, true); Directory.CreateDirectory(targetSection); var catalog = LoadCatalogForWrite(); catalog.sections.RemoveAll(section => string.Equals(section.id, sectionId, StringComparison.OrdinalIgnoreCase)); var sectionDto = new DevSaveCatalogSectionDto { order = FindDemoChapterOrder(group.SceneSoName), id = sectionId, title = string.IsNullOrWhiteSpace(sectionTitle) ? sectionId : sectionTitle, expectedSceneName = group.SceneName, expectedSceneSoName = group.SceneSoName, expectedYarnProjectId = group.YarnProjectId }; for (var i = 0; i < preview.Count; i++) { var item = preview[i]; var folder = $"{i + 1:000}_{SanitizeIdentifier(item.NodeName)}"; var destination = Path.Combine(targetSection, folder); Directory.CreateDirectory(destination); File.Copy(item.SnapshotPath, Path.Combine(destination, $"{ConstRef.SaveSnapshotFileName}.json"), true); if (File.Exists(item.MetaPath)) File.Copy(item.MetaPath, Path.Combine(destination, $"{ConstRef.SaveMetaFileName}.json"), true); sectionDto.entries.Add(new DevSaveCatalogEntryDto { order = i + 1, label = string.IsNullOrWhiteSpace(item.Label) ? item.NodeName : item.Label, path = $"{sectionId}/{folder}", anchorNode = item.NodeName }); } catalog.sections.Add(sectionDto); catalog.sections.Sort((left, right) => left.order.CompareTo(right.order)); WriteCatalog(catalog); AssetDatabase.Refresh(); ValidateCommittedCatalog(); } private static List ValidateCandidates(IEnumerable candidates) { var errors = new List(); var nodes = new HashSet(StringComparer.Ordinal); foreach (var item in candidates) { if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}"); try { var snapshot = SnapshotPersistence.Load(Path.Combine(Path.GetDirectoryName(item.SnapshotPath)!, ConstRef.SaveSnapshotFileName)); if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion) errors.Add($"{item.NodeName}: schema {snapshot.schemaVersion} 不兼容"); } catch (Exception ex) { errors.Add($"{item.NodeName}: {ex.Message}"); } } return errors; } private void ValidateCommittedCatalog() { var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath); var errors = new List(); if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError); foreach (var section in sections) { var orders = new HashSet(); var nodes = new HashSet(StringComparer.Ordinal); foreach (var entry in section.Entries) { if (!orders.Add(entry.Order)) errors.Add($"{section.Id}: 重复 order {entry.Order}"); if (!nodes.Add(entry.AnchorNode)) errors.Add($"{section.Id}: 重复节点 {entry.AnchorNode}"); if (!entry.IsValid) errors.Add($"{section.Id}/{entry.Label}: {entry.Error}"); } } var dto = LoadCatalogForWrite(); foreach (var section in dto.sections) { if (!SceneAddressExists(section.expectedSceneName)) errors.Add($"{section.id}: Addressable 场景不存在 {section.expectedSceneName}"); var sceneSo = FindTalkSceneSo(section.expectedSceneSoName); if (sceneSo == null) errors.Add($"{section.id}: TalkSceneSO 不存在 {section.expectedSceneSoName}"); else if (sceneSo.yarnProject == null || sceneSo.yarnProject.name != section.expectedYarnProjectId) errors.Add($"{section.id}: TalkSceneSO 的 YarnProject 不匹配 {section.expectedYarnProjectId}"); else { var nodeNames = new HashSet(sceneSo.yarnProject.NodeNames, StringComparer.Ordinal); foreach (var entry in section.entries.Where(entry => !nodeNames.Contains(entry.anchorNode))) errors.Add($"{section.id}: Yarn 节点不存在 {entry.anchorNode}"); } } status = errors.Count == 0 ? $"OK:{sections.Count} 个章节、{sections.Sum(s => s.Entries.Count)} 个阶段全部通过校验。" : $"校验失败({errors.Count}):\n{string.Join("\n", errors)}"; if (errors.Count > 0) Debug.LogError($"[DevSavePromote] {status}"); else Debug.Log($"[DevSavePromote] {status}"); } private static bool SceneAddressExists(string address) { var settings = AddressableAssetSettingsDefaultObject.Settings; return settings != null && settings.groups .Where(group => group != null) .SelectMany(group => group.entries) .Any(entry => string.Equals(entry.address, address, StringComparison.Ordinal)); } private static TalkSceneSO FindTalkSceneSo(string assetName) { foreach (var guid in AssetDatabase.FindAssets($"t:TalkSceneSO {assetName}")) { var asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); if (asset != null && asset.name == assetName) return asset; } return null; } private static int FindDemoChapterOrder(string sceneSoName) { const string firstChapterPath = "Assets/ScriptableObjects/SceneSO/Demo/Day0_Prologue.asset"; var chapter = AssetDatabase.LoadAssetAtPath(firstChapterPath); var visited = new HashSet(); var order = 1; while (chapter != null && visited.Add(chapter)) { if (string.Equals(chapter.name, sceneSoName, StringComparison.Ordinal)) return order; chapter = chapter.GetNextScene(); order++; } return int.MaxValue; } private static DevSaveCatalogDto LoadCatalogForWrite() { var path = Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"); if (!File.Exists(path)) return new DevSaveCatalogDto(); try { return JsonConvert.DeserializeObject(File.ReadAllText(path, Encoding.UTF8)) ?? new DevSaveCatalogDto(); } catch { return new DevSaveCatalogDto(); } } private static void WriteCatalog(DevSaveCatalogDto catalog) { Directory.CreateDirectory(ConstRef.TestSaveFilePath); File.WriteAllText( Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"), JsonConvert.SerializeObject(catalog, Formatting.Indented), new UTF8Encoding(false)); } private static string SanitizeIdentifier(string value) { if (string.IsNullOrWhiteSpace(value)) return "unnamed"; foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); value = value.Replace(' ', '_'); return value.Length <= 48 ? value : value.Substring(0, 48); } private sealed class ArchiveGroup { public readonly List Items; public string SceneName => Items[0].SceneName; public string SceneSoName => Items[0].SceneSoName; public string YarnProjectId => Items[0].YarnProjectId; public ArchiveGroup(List items) => Items = items; } private sealed class Candidate { public readonly string SnapshotPath; public readonly string MetaPath; public readonly string SceneName; public readonly string SceneSoName; public readonly string YarnProjectId; public readonly string NodeName; public readonly string SavedAt; public string Label; public Candidate(string snapshotPath, string metaPath, string sceneName, string sceneSoName, string yarnProjectId, string nodeName, string savedAt) { SnapshotPath = snapshotPath; MetaPath = metaPath; SceneName = sceneName; SceneSoName = sceneSoName; YarnProjectId = yarnProjectId; NodeName = nodeName; SavedAt = savedAt ?? string.Empty; Label = nodeName; } } } [TestFixture] public sealed class DevSaveCatalogTests { private string root; [SetUp] public void SetUp() { root = Path.Combine(Path.GetTempPath(), $"aibis-dev-save-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); } [TearDown] public void TearDown() { if (Directory.Exists(root)) Directory.Delete(root, true); } [Test] public void CatalogUsesExplicitOrderAndKeepsInvalidEntriesVisible() { WriteCatalog(new DevSaveCatalogEntryDto { order = 20, label = "late", path = "S/late", anchorNode = "Late" }, new DevSaveCatalogEntryDto { order = 10, label = "early", path = "S/early", anchorNode = "Early" }); var section = DevSaveCatalog.Load(root).Single(); Assert.That(section.Entries.Select(entry => entry.Label), Is.EqualTo(new[] { "early", "late" })); Assert.That(section.Entries.All(entry => !entry.IsValid), Is.True); Assert.That(section.Entries.All(entry => entry.Error.Contains("缺失")), Is.True); } [Test] public void CatalogRejectsPathTraversal() { WriteCatalog(new DevSaveCatalogEntryDto { order = 1, label = "unsafe", path = "../outside", anchorNode = "Start" }); var entry = DevSaveCatalog.Load(root).Single().Entries.Single(); Assert.That(entry.IsValid, Is.False); Assert.That(entry.Error, Does.Contain("越过")); } [Test] public void CatalogReportsCorruptSchemaAndAnchorMismatch() { WriteSnapshot("S/corrupt", "{"); WriteSnapshot("S/schema", JsonConvert.SerializeObject(BuildSnapshot("Node", schema: 99))); WriteSnapshot("S/node", JsonConvert.SerializeObject(BuildSnapshot("Actual"))); WriteCatalog( new DevSaveCatalogEntryDto { order = 1, label = "corrupt", path = "S/corrupt", anchorNode = "Node" }, new DevSaveCatalogEntryDto { order = 2, label = "schema", path = "S/schema", anchorNode = "Node" }, new DevSaveCatalogEntryDto { order = 3, label = "node", path = "S/node", anchorNode = "Expected" }); var entries = DevSaveCatalog.Load(root).Single().Entries; Assert.That(entries[0].Error, Does.Contain("JSON")); Assert.That(entries[1].Error, Does.Contain("版本")); Assert.That(entries[2].Error, Does.Contain("节点不匹配")); } [Test] public void CommittedCatalogContainsAllDemoChaptersWithValidUniqueEntries() { var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath); Assert.That(DevSaveCatalog.LastError, Is.Null); Assert.That(sections.Select(section => section.Id), Is.EqualTo(new[] { "Day0Prologue", "Day1Begin", "Peipei1", "Day1Mid", "Day1Night", "Day1Sleep", "Day2Begin", "Huoshan1", "Day2Mid", "Peipei2", "Day2Night", "Day2Sleep", "GeneralEnd" })); foreach (var section in sections) { Assert.That(section.Entries.All(entry => entry.IsValid), Is.True, string.Join("\n", section.Entries.Where(entry => !entry.IsValid).Select(entry => $"{entry.Label}: {entry.Error}"))); Assert.That(section.Entries.Select(entry => entry.Order).Distinct().Count(), Is.EqualTo(section.Entries.Count)); Assert.That(section.Entries.Select(entry => entry.AnchorNode).Distinct().Count(), Is.EqualTo(section.Entries.Count)); } } [Test] public void StrictRestoreContextTracksPhaseWarningsAndErrors() { var context = new SnapshotRestoreContext(new SaveSnapshot(), strictMode: true); context.SetPhase("Provider restore"); context.Warn("optional state missing"); context.Error("required state missing"); Assert.That(context.StrictMode, Is.True); Assert.That(context.CurrentPhase, Is.EqualTo("Provider restore")); Assert.That(context.Warnings, Is.EqualTo(new[] { "optional state missing" })); Assert.That(context.Errors, Is.EqualTo(new[] { "required state missing" })); Assert.That(context.HasErrors, Is.True); } private void WriteCatalog(params DevSaveCatalogEntryDto[] entries) { var catalog = new DevSaveCatalogDto { sections = new List { new() { id = "S", title = "Section", expectedSceneName = "Scene/Test", expectedSceneSoName = "TestSO", expectedYarnProjectId = "TestYarn", entries = entries.ToList() } } }; File.WriteAllText(Path.Combine(root, "catalog.json"), JsonConvert.SerializeObject(catalog)); } private void WriteSnapshot(string relativeDirectory, string json) { var directory = Path.Combine(root, relativeDirectory.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "snapshot.json"), json); } private static SaveSnapshot BuildSnapshot(string node, int schema = SaveSnapshotSchema.CurrentVersion) { return new SaveSnapshot { schemaVersion = schema, scene = new SceneSnapshotDto { sceneName = "Scene/Test" }, anchor = new AnchorSnapshot { sceneSoName = "TestSO", yarnProjectId = "TestYarn", nodeName = node } }; } } } #endif