237 lines
9.7 KiB
C#
237 lines
9.7 KiB
C#
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using UnityEngine;
|
|
|
|
namespace AibisDream.SaveSystem
|
|
{
|
|
public enum TestSaveCoverageRowKind
|
|
{
|
|
Recorded,
|
|
Missing,
|
|
Invalid
|
|
}
|
|
|
|
public sealed class TestSaveCoverageRow
|
|
{
|
|
public TestSaveCoverageRowKind Kind { get; internal set; }
|
|
public string NodeName { get; internal set; }
|
|
public string ResumeMode { get; internal set; }
|
|
public TestSaveEntry Entry { get; internal set; }
|
|
public long SortOrder { get; internal set; }
|
|
}
|
|
|
|
public sealed class TestSaveChapterCoverage
|
|
{
|
|
public string ChapterId { get; internal set; }
|
|
public string Title { get; internal set; }
|
|
public int ChapterOrder { get; internal set; }
|
|
public int RecordedCount { get; internal set; }
|
|
public int ExpectedCount { get; internal set; }
|
|
public IReadOnlyList<TestSaveCoverageRow> Rows { get; internal set; } = Array.Empty<TestSaveCoverageRow>();
|
|
public bool IsOrphanGroup { get; internal set; }
|
|
}
|
|
|
|
public static class TestSaveCoverage
|
|
{
|
|
public const string OrphanChapterId = "__invalid_or_orphan__";
|
|
|
|
public static IReadOnlyList<TestSaveChapterCoverage> Build(
|
|
IReadOnlyList<TalkSceneSO> chapters,
|
|
IReadOnlyList<TestSaveEntry> entries)
|
|
{
|
|
chapters ??= Array.Empty<TalkSceneSO>();
|
|
entries ??= Array.Empty<TestSaveEntry>();
|
|
var result = new List<TestSaveChapterCoverage>();
|
|
var consumed = new HashSet<TestSaveEntry>();
|
|
|
|
for (var chapterIndex = 0; chapterIndex < chapters.Count; chapterIndex++)
|
|
{
|
|
var chapter = chapters[chapterIndex];
|
|
if (chapter == null) continue;
|
|
|
|
var expectedCheckpoints = GetExpectedCheckpoints(chapter);
|
|
var chapterEntries = entries
|
|
.Where(item => item.Meta != null
|
|
&& string.Equals(item.Meta.sceneSoName, chapter.name, StringComparison.Ordinal)
|
|
&& string.Equals(
|
|
item.Meta.yarnProjectId,
|
|
chapter.yarnProject?.name,
|
|
StringComparison.Ordinal))
|
|
.ToArray();
|
|
foreach (var entry in chapterEntries) consumed.Add(entry);
|
|
|
|
var validByCheckpoint = chapterEntries
|
|
.Where(item => item.IsValid)
|
|
.GroupBy(
|
|
item => BuildCheckpointKey(item.NodeName, item.ResumeMode),
|
|
StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
group => group.Key,
|
|
group => group.OrderBy(item => item.Meta.firstSeenOrder).First(),
|
|
StringComparer.Ordinal);
|
|
var rows = new List<TestSaveCoverageRow>();
|
|
foreach (var entry in validByCheckpoint.Values.OrderBy(item => item.Meta.firstSeenOrder))
|
|
{
|
|
rows.Add(new TestSaveCoverageRow
|
|
{
|
|
Kind = TestSaveCoverageRowKind.Recorded,
|
|
NodeName = entry.NodeName,
|
|
ResumeMode = entry.ResumeMode,
|
|
Entry = entry,
|
|
SortOrder = entry.Meta.firstSeenOrder
|
|
});
|
|
}
|
|
|
|
foreach (var expectation in expectedCheckpoints.Values
|
|
.Where(item => !validByCheckpoint.ContainsKey(item.Key))
|
|
.OrderBy(item => item.NodeName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
rows.Add(new TestSaveCoverageRow
|
|
{
|
|
Kind = TestSaveCoverageRowKind.Missing,
|
|
NodeName = expectation.NodeName,
|
|
ResumeMode = expectation.ResumeMode,
|
|
SortOrder = long.MaxValue
|
|
});
|
|
}
|
|
|
|
foreach (var entry in chapterEntries.Where(item => !item.IsValid)
|
|
.OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue))
|
|
{
|
|
rows.Add(new TestSaveCoverageRow
|
|
{
|
|
Kind = TestSaveCoverageRowKind.Invalid,
|
|
NodeName = entry.NodeName ?? "(未知节点)",
|
|
ResumeMode = entry.ResumeMode,
|
|
Entry = entry,
|
|
SortOrder = entry.Meta?.firstSeenOrder ?? long.MaxValue
|
|
});
|
|
}
|
|
|
|
result.Add(new TestSaveChapterCoverage
|
|
{
|
|
ChapterId = chapter.name,
|
|
Title = string.IsNullOrWhiteSpace(chapter.title) ? chapter.name : chapter.title,
|
|
ChapterOrder = chapterIndex,
|
|
RecordedCount = validByCheckpoint.Keys.Count(expectedCheckpoints.ContainsKey),
|
|
ExpectedCount = expectedCheckpoints.Count,
|
|
Rows = rows
|
|
});
|
|
}
|
|
|
|
var orphanEntries = entries.Where(item => !consumed.Contains(item)).ToArray();
|
|
if (orphanEntries.Length > 0)
|
|
{
|
|
result.Add(new TestSaveChapterCoverage
|
|
{
|
|
ChapterId = OrphanChapterId,
|
|
Title = "无效 / 已脱离当前流程",
|
|
ChapterOrder = int.MaxValue,
|
|
RecordedCount = 0,
|
|
ExpectedCount = 0,
|
|
IsOrphanGroup = true,
|
|
Rows = orphanEntries
|
|
.OrderBy(item => item.Meta?.firstSeenOrder ?? long.MaxValue)
|
|
.Select(item => new TestSaveCoverageRow
|
|
{
|
|
Kind = TestSaveCoverageRowKind.Invalid,
|
|
NodeName = item.NodeName ?? "(未知节点)",
|
|
ResumeMode = item.ResumeMode,
|
|
Entry = item,
|
|
SortOrder = item.Meta?.firstSeenOrder ?? long.MaxValue
|
|
})
|
|
.ToArray()
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public static HashSet<string> GetExpectedNodes(TalkSceneSO chapter)
|
|
{
|
|
return GetExpectedCheckpoints(chapter).Values
|
|
.Select(item => item.NodeName)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
}
|
|
|
|
private static Dictionary<string, ExpectedCheckpoint> GetExpectedCheckpoints(TalkSceneSO chapter)
|
|
{
|
|
var result = new Dictionary<string, ExpectedCheckpoint>(StringComparer.Ordinal);
|
|
var project = chapter?.yarnProject;
|
|
if (project?.Program?.Nodes == null) return result;
|
|
|
|
foreach (var pair in project.Program.Nodes)
|
|
{
|
|
var node = pair.Value;
|
|
if (node == null || node.Name.StartsWith("$", StringComparison.Ordinal)) continue;
|
|
var tagsValue = node.Headers
|
|
.FirstOrDefault(header => string.Equals(header.Key, "tags", StringComparison.OrdinalIgnoreCase))
|
|
?.Value;
|
|
var tags = string.IsNullOrWhiteSpace(tagsValue)
|
|
? Array.Empty<string>()
|
|
: tagsValue.Split(Array.Empty<char>(), StringSplitOptions.RemoveEmptyEntries);
|
|
var policy = SavePointEvaluator.ResolveNodePolicy(
|
|
node.Name,
|
|
tags,
|
|
out var reason,
|
|
logWarnings: false);
|
|
if (reason == SavePointRejectReason.InvalidTagConfiguration)
|
|
{
|
|
Debug.LogError(
|
|
$"[TestSaveCoverage] 节点 {node.Name} 的保存标签配置无效: " +
|
|
$"[{string.Join(", ", tags)}]");
|
|
}
|
|
else if (tags.Any(tag =>
|
|
string.Equals(tag, "interaction", StringComparison.OrdinalIgnoreCase))
|
|
&& tags.Any(tag =>
|
|
string.Equals(tag, "save_on_exit", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
Debug.LogWarning(
|
|
$"[TestSaveCoverage] 节点 {node.Name} 的 interaction 已包含 save_on_exit 语义," +
|
|
"请移除重复标记。");
|
|
}
|
|
|
|
if (policy.Timing == NodeSaveTiming.None)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var resumeMode = policy.ResumeMode.ToString();
|
|
var key = BuildCheckpointKey(node.Name, resumeMode);
|
|
result[key] = new ExpectedCheckpoint(key, node.Name, resumeMode);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static string BuildCheckpointKey(string nodeName, string resumeMode)
|
|
{
|
|
return $"{nodeName ?? string.Empty}\n{NormalizeResumeMode(resumeMode)}";
|
|
}
|
|
|
|
private static string NormalizeResumeMode(string resumeMode)
|
|
{
|
|
return string.IsNullOrEmpty(resumeMode)
|
|
? nameof(SaveResumeMode.RestartNode)
|
|
: resumeMode;
|
|
}
|
|
|
|
private readonly struct ExpectedCheckpoint
|
|
{
|
|
public readonly string Key;
|
|
public readonly string NodeName;
|
|
public readonly string ResumeMode;
|
|
|
|
public ExpectedCheckpoint(string key, string nodeName, string resumeMode)
|
|
{
|
|
Key = key;
|
|
NodeName = nodeName;
|
|
ResumeMode = resumeMode;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|