#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