diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
index 4cdac7da..dc7f6dc2 100644
--- a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
+++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs
@@ -1,5 +1,4 @@
using System.IO;
-using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
@@ -12,6 +11,8 @@ namespace GFramework.Game.Tests.Config;
[TestFixture]
public class GeneratedConfigConsumerIntegrationTests
{
+ private string _rootPath = null!;
+
///
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
///
@@ -34,8 +35,6 @@ public class GeneratedConfigConsumerIntegrationTests
}
}
- private string _rootPath = null!;
-
///
/// 验证生成器自动拾取消费者项目的 schema 后,
/// 可以用生成的聚合注册辅助完成加载,并通过强类型表包装访问运行时数据与查询辅助。
@@ -281,12 +280,17 @@ public class GeneratedConfigConsumerIntegrationTests
var exception = Assert.Throws(() =>
MonsterConfigBindings.ValidateYaml(_rootPath, "monster/generated.yaml", invalidYaml));
+ var asyncException = Assert.ThrowsAsync(async () =>
+ await MonsterConfigBindings.ValidateYamlAsync(_rootPath, "monster/generated.yaml", invalidYaml));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
Assert.That(exception.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
+ Assert.That(asyncException, Is.Not.Null);
+ Assert.That(asyncException!.Diagnostic.SchemaPath, Is.EqualTo(schemaPath));
+ Assert.That(asyncException.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.UnknownProperty));
});
}
diff --git a/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs
new file mode 100644
index 00000000..11b26894
--- /dev/null
+++ b/GFramework.Game.Tests/Config/YamlConfigSchemaValidatorTests.cs
@@ -0,0 +1,93 @@
+using System.IO;
+using GFramework.Game.Config;
+
+namespace GFramework.Game.Tests.Config;
+
+///
+/// 验证内部 schema 解析器会输出稳定且可预期的运行时依赖元数据。
+///
+[TestFixture]
+public sealed class YamlConfigSchemaValidatorTests
+{
+ private string _rootPath = null!;
+
+ ///
+ /// 为每个测试准备独立临时目录。
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.SchemaValidatorTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_rootPath);
+ }
+
+ ///
+ /// 清理测试临时目录。
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_rootPath))
+ {
+ Directory.Delete(_rootPath, true);
+ }
+ }
+
+ ///
+ /// 验证 schema 中声明的跨表引用名称会以序数排序形式输出,
+ /// 避免热重载依赖推导与测试快照受哈希集合枚举顺序影响。
+ ///
+ [Test]
+ public void Load_Should_Return_Referenced_Table_Names_In_Ordinal_Sorted_Order()
+ {
+ var schemaPath = CreateSchemaFile(
+ "schemas/monster.schema.json",
+ """
+ {
+ "type": "object",
+ "properties": {
+ "weaponId": {
+ "type": "string",
+ "x-gframework-ref-table": "weapon"
+ },
+ "allies": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "x-gframework-ref-table": "ally"
+ }
+ },
+ "itemId": {
+ "type": "string",
+ "x-gframework-ref-table": "item"
+ }
+ }
+ }
+ """);
+
+ var schema = YamlConfigSchemaValidator.Load("monster", schemaPath);
+
+ Assert.That(schema.ReferencedTableNames, Is.EqualTo(new[] { "ally", "item", "weapon" }));
+ }
+
+ ///
+ /// 在临时目录中创建 schema 文件。
+ ///
+ /// 相对根目录的路径。
+ /// 文件内容。
+ /// 写入后的绝对路径。
+ private string CreateSchemaFile(
+ string relativePath,
+ string content)
+ {
+ var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ var directoryPath = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrWhiteSpace(directoryPath))
+ {
+ Directory.CreateDirectory(directoryPath);
+ }
+
+ File.WriteAllText(fullPath, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal));
+ return fullPath;
+ }
+}
diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
index 4c689264..b525cf79 100644
--- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs
+++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs
@@ -267,8 +267,13 @@ internal static class YamlConfigSchemaValidator
var referencedTableNames = new HashSet(StringComparer.Ordinal);
CollectReferencedTableNames(rootNode, referencedTableNames);
+ // Preserve a deterministic dependency order so hot-reload bookkeeping and tests
+ // do not depend on HashSet enumeration details.
+ var orderedReferencedTableNames = referencedTableNames
+ .OrderBy(static name => name, StringComparer.Ordinal)
+ .ToArray();
- return new YamlConfigSchema(schemaPath, rootNode, referencedTableNames.ToArray());
+ return new YamlConfigSchema(schemaPath, rootNode, orderedReferencedTableNames);
}
catch (JsonException exception)
{
diff --git a/GFramework.Game/Config/YamlConfigTextSerializer.cs b/GFramework.Game/Config/YamlConfigTextSerializer.cs
index 12a26e31..726a2e39 100644
--- a/GFramework.Game/Config/YamlConfigTextSerializer.cs
+++ b/GFramework.Game/Config/YamlConfigTextSerializer.cs
@@ -1,6 +1,3 @@
-using YamlDotNet.Serialization;
-using YamlDotNet.Serialization.NamingConventions;
-
namespace GFramework.Game.Config;
///
@@ -20,6 +17,7 @@ public static class YamlConfigTextSerializer
/// 配置对象类型。
/// 要序列化的配置对象。
/// 带尾随换行的 YAML 文本。
+ /// 当 为 时抛出。
public static string Serialize(TValue value)
{
ArgumentNullException.ThrowIfNull(value);
diff --git a/GFramework.Game/Config/YamlConfigTextValidator.cs b/GFramework.Game/Config/YamlConfigTextValidator.cs
index 84747633..e8e79a0b 100644
--- a/GFramework.Game/Config/YamlConfigTextValidator.cs
+++ b/GFramework.Game/Config/YamlConfigTextValidator.cs
@@ -12,6 +12,9 @@ public static class YamlConfigTextValidator
/// Schema 文件绝对路径。
/// YAML 文件路径,仅用于诊断信息。
/// 待校验的 YAML 文本。
+ /// 当 或 为空白时抛出。
+ /// 当 或 为 时抛出。
+ /// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
public static void Validate(
string tableName,
string schemaPath,
@@ -30,6 +33,10 @@ public static class YamlConfigTextValidator
/// YAML 文件路径,仅用于诊断信息。
/// 待校验的 YAML 文本。
/// 取消令牌。
+ /// 表示异步校验操作的任务。
+ /// 当 或 为空白时抛出。
+ /// 当 或 为 时抛出。
+ /// 当 schema 文件不可用,或 YAML 内容与 schema 不匹配时抛出。
public static async Task ValidateAsync(
string tableName,
string schemaPath,
diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
index e657c7ff..08f2a506 100644
--- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
+++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfigBindings.g.txt
@@ -105,6 +105,7 @@ public static class MonsterConfigBindings
///
/// The generated config instance to serialize.
/// YAML text that preserves the shared camelCase field naming convention.
+ /// Thrown when is .
public static string SerializeToYaml(MonsterConfig config)
{
return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);
@@ -115,6 +116,7 @@ public static class MonsterConfigBindings
///
/// Absolute or workspace-local config root directory.
/// The absolute config directory path for the generated table.
+ /// Thrown when is null, empty, or whitespace.
public static string GetConfigDirectoryPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);
@@ -125,6 +127,7 @@ public static class MonsterConfigBindings
///
/// Absolute or workspace-local config root directory.
/// The absolute schema file path for the generated table.
+ /// Thrown when is null, empty, or whitespace.
public static string GetSchemaPath(string configRootPath)
{
return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);
@@ -136,6 +139,9 @@ public static class MonsterConfigBindings
/// Absolute or workspace-local config root directory.
/// Logical or absolute YAML path used for diagnostics.
/// YAML text to validate.
+ /// Thrown when is null, empty, or whitespace.
+ /// Thrown when or is .
+ /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.
public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)
{
global::GFramework.Game.Config.YamlConfigTextValidator.Validate(
@@ -152,6 +158,10 @@ public static class MonsterConfigBindings
/// Logical or absolute YAML path used for diagnostics.
/// YAML text to validate.
/// Cancellation token.
+ /// A task that represents the asynchronous validation operation.
+ /// Thrown when is null, empty, or whitespace.
+ /// Thrown when or is .
+ /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.
public static global::System.Threading.Tasks.Task ValidateYamlAsync(
string configRootPath,
string yamlPath,
diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
index bb9e7f7b..ac1c7d70 100644
--- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
+++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs
@@ -1691,6 +1691,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(" /// The generated config instance to serialize.");
builder.AppendLine(
" /// YAML text that preserves the shared camelCase field naming convention.");
+ builder.AppendLine(
+ " /// Thrown when is .");
builder.AppendLine($" public static string SerializeToYaml({schema.ClassName} config)");
builder.AppendLine(" {");
builder.AppendLine(" return global::GFramework.Game.Config.YamlConfigTextSerializer.Serialize(config);");
@@ -1703,6 +1705,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// Absolute or workspace-local config root directory.");
builder.AppendLine(" /// The absolute config directory path for the generated table.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
builder.AppendLine(" public static string GetConfigDirectoryPath(string configRootPath)");
builder.AppendLine(" {");
builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.ConfigRelativePath);");
@@ -1715,6 +1719,8 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// Absolute or workspace-local config root directory.");
builder.AppendLine(" /// The absolute schema file path for the generated table.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
builder.AppendLine(" public static string GetSchemaPath(string configRootPath)");
builder.AppendLine(" {");
builder.AppendLine(" return ResolveAbsolutePath(configRootPath, Metadata.SchemaRelativePath);");
@@ -1729,6 +1735,12 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
builder.AppendLine(
" /// Logical or absolute YAML path used for diagnostics.");
builder.AppendLine(" /// YAML text to validate.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
+ builder.AppendLine(
+ " /// Thrown when or is .");
+ builder.AppendLine(
+ " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.");
builder.AppendLine(
" public static void ValidateYaml(string configRootPath, string yamlPath, string yamlText)");
builder.AppendLine(" {");
@@ -1749,6 +1761,13 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
" /// Logical or absolute YAML path used for diagnostics.");
builder.AppendLine(" /// YAML text to validate.");
builder.AppendLine(" /// Cancellation token.");
+ builder.AppendLine(" /// A task that represents the asynchronous validation operation.");
+ builder.AppendLine(
+ " /// Thrown when is null, empty, or whitespace.");
+ builder.AppendLine(
+ " /// Thrown when or is .");
+ builder.AppendLine(
+ " /// Thrown when the generated schema file cannot be loaded or the YAML text fails schema validation.");
builder.AppendLine(
" public static global::System.Threading.Tasks.Task ValidateYamlAsync(");
builder.AppendLine(" string configRootPath,");