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,");