Files
aibis-dream/Assets/Editor/SaveSystemValidation/DevSavePromoteWindow.cs
T

535 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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
{
/// <summary>从本机 testsavs 选择章节、去重排序并提升为可提交测试档。</summary>
public sealed class DevSavePromoteWindow : EditorWindow
{
private readonly List<ArchiveGroup> groups = new();
private readonly List<Candidate> 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<DevSavePromoteWindow>("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<Candidate>();
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<string> ValidateCandidates(IEnumerable<Candidate> candidates)
{
var errors = new List<string>();
var nodes = new HashSet<string>(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<string>();
if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError);
foreach (var section in sections)
{
var orders = new HashSet<int>();
var nodes = new HashSet<string>(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<string>(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<TalkSceneSO>(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<TalkSceneSO>(firstChapterPath);
var visited = new HashSet<TalkSceneSO>();
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<DevSaveCatalogDto>(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<Candidate> Items;
public string SceneName => Items[0].SceneName;
public string SceneSoName => Items[0].SceneSoName;
public string YarnProjectId => Items[0].YarnProjectId;
public ArchiveGroup(List<Candidate> 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<DevSaveCatalogSectionDto>
{
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