From f63714f1e12a0cbb0de7aed281e36ea2d6a01788 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:50:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E6=A8=A1=E5=9D=97=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建了 GFramework.Game.Tests 测试项目配置文件 - 添加了 Microsoft.NET.Test.Sdk、Moq、NUnit 等测试依赖包 - 配置了项目引用包括 GFramework.Game、GFramework.Core 和源代码生成器 - 实现了自动生成配置消费者集成测试验证功能 - 添加了怪物配置模式定义用于端到端测试验证 - 创建了源代码生成器目标文件实现自动化配置收集 - 验证了消费者项目自动拾取 schema 并生成绑定的功能 --- ...GeneratedConfigConsumerIntegrationTests.cs | 125 ++++++++++++++++++ .../GFramework.Game.Tests.csproj | 15 +++ .../schemas/monster.schema.json | 24 ++++ ...eWuYou.GFramework.SourceGenerators.targets | 14 +- 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs create mode 100644 GFramework.Game.Tests/schemas/monster.schema.json diff --git a/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs new file mode 100644 index 00000000..a419c260 --- /dev/null +++ b/GFramework.Game.Tests/Config/GeneratedConfigConsumerIntegrationTests.cs @@ -0,0 +1,125 @@ +using System.IO; +using GFramework.Game.Config; +using GFramework.Game.Config.Generated; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后, +/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。 +/// +[TestFixture] +public class GeneratedConfigConsumerIntegrationTests +{ + /// + /// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GeneratedConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理测试过程中创建的临时消费者目录。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + private string _rootPath = null!; + + /// + /// 验证生成器自动拾取消费者项目的 schema 后, + /// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。 + /// + [Test] + public async Task LoadAsync_Should_Support_Generated_Bindings_In_Consumer_Project() + { + CreateFile( + "schemas/monster.schema.json", + """ + { + "title": "Monster Config", + "description": "Defines one monster entry for the end-to-end consumer integration test.", + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { + "type": "integer", + "description": "Monster identifier." + }, + "name": { + "type": "string", + "description": "Monster display name." + }, + "hp": { + "type": "integer", + "description": "Monster base health." + } + } + } + """); + CreateFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateFile( + "monster/goblin.yaml", + """ + id: 2 + name: Goblin + hp: 30 + """); + + var registry = new ConfigRegistry(); + var loader = new YamlConfigLoader(_rootPath) + .RegisterMonsterTable(); + + await loader.LoadAsync(registry); + + var table = registry.GetMonsterTable(); + + Assert.Multiple(() => + { + Assert.That(MonsterConfigBindings.TableName, Is.EqualTo("monster")); + Assert.That(MonsterConfigBindings.ConfigRelativePath, Is.EqualTo("monster")); + Assert.That(MonsterConfigBindings.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json")); + Assert.That(table.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(2).Hp, Is.EqualTo(30)); + Assert.That(registry.TryGetMonsterTable(out var generatedTable), Is.True); + Assert.That(generatedTable, Is.Not.Null); + Assert.That(generatedTable!.All().Select(static config => config.Name), + Is.EquivalentTo(new[] { "Slime", "Goblin" })); + }); + } + + /// + /// 在临时消费者根目录中创建测试文件。 + /// + /// 相对根目录的文件路径。 + /// 要写入的文件内容。 + private void CreateFile( + string relativePath, + string content) + { + var path = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(path, content.Replace("\n", Environment.NewLine, StringComparison.Ordinal)); + } +} \ No newline at end of file diff --git a/GFramework.Game.Tests/GFramework.Game.Tests.csproj b/GFramework.Game.Tests/GFramework.Game.Tests.csproj index 7df909e8..6ad45dbe 100644 --- a/GFramework.Game.Tests/GFramework.Game.Tests.csproj +++ b/GFramework.Game.Tests/GFramework.Game.Tests.csproj @@ -19,6 +19,21 @@ + + + + + + diff --git a/GFramework.Game.Tests/schemas/monster.schema.json b/GFramework.Game.Tests/schemas/monster.schema.json new file mode 100644 index 00000000..4b16aa59 --- /dev/null +++ b/GFramework.Game.Tests/schemas/monster.schema.json @@ -0,0 +1,24 @@ +{ + "title": "Monster Config", + "description": "Defines one monster entry for the generated consumer integration test.", + "type": "object", + "required": [ + "id", + "name", + "hp" + ], + "properties": { + "id": { + "type": "integer", + "description": "Monster identifier." + }, + "name": { + "type": "string", + "description": "Monster display name." + }, + "hp": { + "type": "integer", + "description": "Monster base health." + } + } +} diff --git a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets index b66b376d..c2c3af2d 100644 --- a/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets +++ b/GFramework.SourceGenerators/GeWuYou.GFramework.SourceGenerators.targets @@ -12,9 +12,17 @@ - - - + + + + From 0e538738df68dea98841668c881f30c680e3be84 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:32:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=86=85=E5=AE=B9=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=92=8CYAML=E9=85=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现面向静态游戏内容的AI-First配置方案,支持怪物、物品、技能、任务等数据管理 - 集成YAML作为配置源文件格式,JSON Schema作为结构描述标准 - 提供一对象一文件的目录组织结构和运行时只读查询功能 - 实现Source Generator生成配置类型、表包装和注册/访问辅助代码 - 添加VS Code插件支持配置浏览、raw编辑、schema打开和递归校验功能 - 创建YamlConfigSchemaValidator类提供YAML与JSON Schema的运行时校验能力 - 支持嵌套对象、对象数组、标量数组的递归校验和深层约束检查 - 实现跨表引用验证和配置热重载功能 - 提供详细的错误诊断信息和开发期工具链支持 --- .../Config/ConfigLoadFailureKind.cs | 5 + .../Config/YamlConfigLoaderTests.cs | 98 ++++++ .../Config/YamlConfigSchemaValidator.cs | 303 ++++++++++++++++++ .../SchemaConfigGeneratorSnapshotTests.cs | 11 +- .../SchemaConfigGenerator/MonsterConfig.g.txt | 5 + .../Config/SchemaConfigGenerator.cs | 101 ++++++ docs/zh-CN/game/config-system.md | 5 + .../src/configValidation.js | 85 +++++ tools/gframework-config-tool/src/extension.js | 34 +- .../src/localization.js | 24 ++ .../src/localizationKeys.js | 4 + .../test/configValidation.test.js | 76 +++++ 12 files changed, 749 insertions(+), 2 deletions(-) diff --git a/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs b/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs index be0b3ef6..2bb11619 100644 --- a/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs +++ b/GFramework.Game.Abstractions/Config/ConfigLoadFailureKind.cs @@ -77,6 +77,11 @@ public enum ConfigLoadFailureKind /// EnumValueNotAllowed, + /// + /// YAML 标量值违反了 schema 声明的最小值、最大值或长度约束。 + /// + ConstraintViolation, + /// /// YAML 可被读取,但无法成功反序列化到目标 CLR 类型。 /// diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index c5355096..476f3dea 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -301,6 +301,104 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证数值最小值与最大值约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_Minimum_Or_Maximum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 101 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("hp")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("101")); + Assert.That(exception.Message, Does.Contain("100")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_String_Violates_MinLength_Or_MaxLength() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Sl + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "minLength": 3, + "maxLength": 12 + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(registry)); + + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConstraintViolation)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("Sl")); + Assert.That(exception.Message, Does.Contain("at least 3 characters")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index a4af09bc..09153c57 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -296,6 +296,7 @@ internal static class YamlConfigSchemaValidator itemNode: null, referenceTableName: null, allowedValues: null, + constraints: null, schemaPath); } @@ -363,6 +364,7 @@ internal static class YamlConfigSchemaValidator itemNode, referenceTableName: null, allowedValues: null, + constraints: null, schemaPath); } @@ -392,6 +394,7 @@ internal static class YamlConfigSchemaValidator itemNode: null, referenceTableName, ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), + ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), schemaPath); } @@ -674,6 +677,11 @@ internal static class YamlConfigSchemaValidator detail: $"Allowed values: {string.Join(", ", schemaNode.AllowedValues)}."); } + if (schemaNode.Constraints is not null) + { + ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode); + } + if (schemaNode.ReferenceTableName != null) { references.Add( @@ -730,6 +738,246 @@ internal static class YamlConfigSchemaValidator return allowedValues; } + /// + /// 解析标量字段支持的范围与长度约束。 + /// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 标量类型。 + /// 解析后的约束模型;未声明时返回空。 + private static YamlConfigScalarConstraints? ParseScalarConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum"); + var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum"); + var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); + var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); + + if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minimum' greater than 'maximum'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minLength' greater than 'maxLength'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (!minimum.HasValue && !maximum.HasValue && !minLength.HasValue && !maxLength.HasValue) + { + return null; + } + + return new YamlConfigScalarConstraints(minimum, maximum, minLength, maxLength); + } + + /// + /// 读取数值区间约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 关键字名称。 + /// 数值约束;未声明时返回空。 + private static double? TryParseNumericConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + if (nodeType != YamlConfigSchemaPropertyType.Integer && + nodeType != YamlConfigSchemaPropertyType.Number) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'integer' and 'number' scalar types support numeric range constraints.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (constraintElement.ValueKind != JsonValueKind.Number || + !constraintElement.TryGetDouble(out var constraintValue) || + double.IsNaN(constraintValue) || + double.IsInfinity(constraintValue)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a finite number.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintValue; + } + + /// + /// 读取字符串长度约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 关键字名称。 + /// 长度约束;未声明时返回空。 + private static int? TryParseLengthConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + if (nodeType != YamlConfigSchemaPropertyType.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses '{keywordName}', but only 'string' scalar types support length constraints.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (constraintElement.ValueKind != JsonValueKind.Number || + !constraintElement.TryGetInt32(out var constraintValue) || + constraintValue < 0) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare '{keywordName}' as a non-negative integer.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return constraintValue; + } + + /// + /// 校验标量值是否满足范围与长度约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 归一化后的比较值。 + /// 标量 schema 节点。 + private static void ValidateScalarConstraints( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + string normalizedValue, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.Constraints; + if (constraints is null) + { + return; + } + + switch (schemaNode.NodeType) + { + case YamlConfigSchemaPropertyType.Integer: + case YamlConfigSchemaPropertyType.Number: + var numericValue = double.Parse(normalizedValue, CultureInfo.InvariantCulture); + + if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be greater than or equal to {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be less than or equal to {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + return; + + case YamlConfigSchemaPropertyType.String: + var stringLength = rawValue.Length; + + if (constraints.MinLength.HasValue && stringLength < constraints.MinLength.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be at least {constraints.MinLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Minimum length: {constraints.MinLength.Value}."); + } + + if (constraints.MaxLength.HasValue && stringLength > constraints.MaxLength.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be at most {constraints.MaxLength.Value} characters long, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Maximum length: {constraints.MaxLength.Value}."); + } + + return; + } + } + /// /// 解析跨表引用目标表名称。 /// @@ -1037,6 +1285,7 @@ internal sealed class YamlConfigSchemaNode /// 数组元素节点。 /// 目标引用表名称。 /// 标量允许值集合。 + /// 标量范围与长度约束。 /// 用于错误信息的 schema 文件路径提示。 public YamlConfigSchemaNode( YamlConfigSchemaPropertyType nodeType, @@ -1045,6 +1294,7 @@ internal sealed class YamlConfigSchemaNode YamlConfigSchemaNode? itemNode, string? referenceTableName, IReadOnlyCollection? allowedValues, + YamlConfigScalarConstraints? constraints, string schemaPathHint) { NodeType = nodeType; @@ -1053,6 +1303,7 @@ internal sealed class YamlConfigSchemaNode ItemNode = itemNode; ReferenceTableName = referenceTableName; AllowedValues = allowedValues; + Constraints = constraints; SchemaPathHint = schemaPathHint; } @@ -1086,6 +1337,11 @@ internal sealed class YamlConfigSchemaNode /// public IReadOnlyCollection? AllowedValues { get; } + /// + /// 获取标量范围与长度约束;未声明时返回空。 + /// + public YamlConfigScalarConstraints? Constraints { get; } + /// /// 获取用于诊断显示的 schema 路径提示。 /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 @@ -1107,10 +1363,57 @@ internal sealed class YamlConfigSchemaNode ItemNode, referenceTableName, AllowedValues, + Constraints, SchemaPathHint); } } +/// +/// 表示一个标量节点上声明的数值范围或字符串长度约束。 +/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 +/// +internal sealed class YamlConfigScalarConstraints +{ + /// + /// 初始化标量约束模型。 + /// + /// 最小值约束。 + /// 最大值约束。 + /// 最小长度约束。 + /// 最大长度约束。 + public YamlConfigScalarConstraints( + double? minimum, + double? maximum, + int? minLength, + int? maxLength) + { + Minimum = minimum; + Maximum = maximum; + MinLength = minLength; + MaxLength = maxLength; + } + + /// + /// 获取最小值约束。 + /// + public double? Minimum { get; } + + /// + /// 获取最大值约束。 + /// + public double? Maximum { get; } + + /// + /// 获取最小长度约束。 + /// + public int? MinLength { get; } + + /// + /// 获取最大长度约束。 + /// + public int? MaxLength { get; } +} + /// /// 表示单个 YAML 文件中提取出的跨表引用。 /// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。 diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index b02b1307..0b237543 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -79,11 +79,15 @@ public class SchemaConfigGeneratorSnapshotTests "type": "string", "title": "Monster Name", "description": "Localized monster display name.", + "minLength": 3, + "maxLength": 16, "default": "Slime", "enum": ["Slime", "Goblin"] }, "hp": { "type": "integer", + "minimum": 1, + "maximum": 999, "default": 10 }, "dropItems": { @@ -91,6 +95,8 @@ public class SchemaConfigGeneratorSnapshotTests "type": "array", "items": { "type": "string", + "minLength": 3, + "maxLength": 12, "enum": ["potion", "slime_gel"] }, "default": ["potion"], @@ -103,6 +109,7 @@ public class SchemaConfigGeneratorSnapshotTests "properties": { "gold": { "type": "integer", + "minimum": 0, "default": 10 }, "currency": { @@ -123,7 +130,9 @@ public class SchemaConfigGeneratorSnapshotTests }, "monsterId": { "type": "string", - "description": "Monster reference id." + "description": "Monster reference id.", + "minLength": 2, + "maxLength": 32 } } } diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index d4eda3cb..f0d29e3a 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -24,6 +24,7 @@ public sealed partial class MonsterConfig /// Schema property path: 'name'. /// Display title: 'Monster Name'. /// Allowed values: Slime, Goblin. + /// Constraints: minLength = 3, maxLength = 16. /// Generated default initializer: = "Slime"; /// public string Name { get; set; } = "Slime"; @@ -33,6 +34,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'hp'. + /// Constraints: minimum = 1, maximum = 999. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -44,6 +46,7 @@ public sealed partial class MonsterConfig /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. /// References config table: 'item'. + /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; /// public global::System.Collections.Generic.IReadOnlyList DropItems { get; set; } = new string[] { "potion" }; @@ -77,6 +80,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'reward.gold'. + /// Constraints: minimum = 0. /// Generated default initializer: = 10; /// public int Gold { get; set; } = 10; @@ -112,6 +116,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'phases[].monsterId'. + /// Constraints: minLength = 2, maxLength = 32. /// Generated default initializer: = string.Empty; /// public string MonsterId { get; set; } = string.Empty; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 29d52fdb..c0e5560d 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -271,6 +271,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator isRequired ? "int" : "int?", TryBuildScalarInitializer(property.Value, "integer"), TryBuildEnumDocumentation(property.Value, "integer"), + TryBuildConstraintDocumentation(property.Value, "integer"), refTableName, null, null))); @@ -289,6 +290,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator isRequired ? "double" : "double?", TryBuildScalarInitializer(property.Value, "number"), TryBuildEnumDocumentation(property.Value, "number"), + TryBuildConstraintDocumentation(property.Value, "number"), refTableName, null, null))); @@ -307,6 +309,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator isRequired ? "bool" : "bool?", TryBuildScalarInitializer(property.Value, "boolean"), TryBuildEnumDocumentation(property.Value, "boolean"), + TryBuildConstraintDocumentation(property.Value, "boolean"), refTableName, null, null))); @@ -326,6 +329,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator TryBuildScalarInitializer(property.Value, "string") ?? (isRequired ? " = string.Empty;" : null), TryBuildEnumDocumentation(property.Value, "string"), + TryBuildConstraintDocumentation(property.Value, "string"), refTableName, null, null))); @@ -367,6 +371,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator isRequired ? " = new();" : null, null, null, + null, objectSpec, null))); @@ -450,6 +455,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", TryBuildEnumDocumentation(itemsElement, itemType), + null, refTableName, null, new SchemaTypeSpec( @@ -458,6 +464,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator itemClrType, null, TryBuildEnumDocumentation(itemsElement, itemType), + TryBuildConstraintDocumentation(itemsElement, itemType), refTableName, null, null)))); @@ -500,6 +507,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator null, null, null, + null, new SchemaTypeSpec( SchemaNodeKind.Object, "object", @@ -507,6 +515,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator null, null, null, + null, objectSpec, null)))); @@ -872,12 +881,26 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"{indent}/// Allowed values: {EscapeXmlDocumentation(property.TypeSpec.EnumDocumentation!)}."); } + if (!string.IsNullOrWhiteSpace(property.TypeSpec.ConstraintDocumentation)) + { + builder.AppendLine( + $"{indent}/// Constraints: {EscapeXmlDocumentation(property.TypeSpec.ConstraintDocumentation!)}."); + } + if (!string.IsNullOrWhiteSpace(property.TypeSpec.RefTableName)) { builder.AppendLine( $"{indent}/// References config table: '{EscapeXmlDocumentation(property.TypeSpec.RefTableName!)}'."); } + var itemConstraintDocumentation = property.TypeSpec.ItemTypeSpec?.ConstraintDocumentation; + if (property.TypeSpec.Kind == SchemaNodeKind.Array && + !string.IsNullOrWhiteSpace(itemConstraintDocumentation)) + { + builder.AppendLine( + $"{indent}/// Item constraints: {EscapeXmlDocumentation(itemConstraintDocumentation!)}."); + } + if (!string.IsNullOrWhiteSpace(property.TypeSpec.Initializer)) { builder.AppendLine( @@ -1084,6 +1107,82 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return values.Count > 0 ? string.Join(", ", values) : null; } + /// + /// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。 + /// + /// Schema 节点。 + /// 标量类型。 + /// 格式化后的约束说明。 + private static string? TryBuildConstraintDocumentation(JsonElement element, string schemaType) + { + var parts = new List(); + + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "minimum", out var minimum)) + { + parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}"); + } + + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "maximum", out var maximum)) + { + parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "string" && + TryGetNonNegativeInt32(element, "minLength", out var minLength)) + { + parts.Add($"minLength = {minLength.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "string" && + TryGetNonNegativeInt32(element, "maxLength", out var maxLength)) + { + parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : null; + } + + /// + /// 读取有限数值元数据。 + /// + /// Schema 节点。 + /// 元数据名称。 + /// 读取到的数值。 + /// 是否读取成功。 + private static bool TryGetFiniteNumber( + JsonElement element, + string propertyName, + out double value) + { + value = default; + return element.TryGetProperty(propertyName, out var metadataElement) && + metadataElement.ValueKind == JsonValueKind.Number && + metadataElement.TryGetDouble(out value) && + !double.IsNaN(value) && + !double.IsInfinity(value); + } + + /// + /// 读取非负整数元数据。 + /// + /// Schema 节点。 + /// 元数据名称。 + /// 读取到的整数值。 + /// 是否读取成功。 + private static bool TryGetNonNegativeInt32( + JsonElement element, + string propertyName, + out int value) + { + value = default; + return element.TryGetProperty(propertyName, out var metadataElement) && + metadataElement.ValueKind == JsonValueKind.Number && + metadataElement.TryGetInt32(out value) && + value >= 0; + } + /// /// 组合逻辑字段路径。 /// @@ -1221,6 +1320,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// CLR 类型名。 /// 属性初始化器。 /// 枚举文档说明。 + /// 范围或长度约束说明。 /// 目标引用表名称。 /// 对象节点对应的嵌套类型。 /// 数组元素类型模型。 @@ -1230,6 +1330,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator string ClrType, string? Initializer, string? EnumDocumentation, + string? ConstraintDocumentation, string? RefTableName, SchemaObjectSpec? NestedObject, SchemaTypeSpec? ItemTypeSpec); diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 3e72c48a..03331442 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,6 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 +- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength` - Source Generator 生成配置类型、表包装和注册/访问辅助 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -119,6 +120,8 @@ var slime = monsterTable.Get(1); - 数组元素类型不匹配 - 嵌套对象字段类型不匹配 - 对象数组元素结构不匹配 +- 数值字段违反 `minimum` / `maximum` +- 字符串字段违反 `minLength` / `maxLength` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -151,6 +154,8 @@ var slime = monsterTable.Get(1); - `description`:供表单提示、生成代码 XML 文档和接入说明复用 - `default`:供生成类型属性初始值和工具提示复用 - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 +- `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 832cc909..de70e832 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -359,6 +359,26 @@ function normalizeSchemaEnumValues(value) { return normalized.length > 0 ? normalized : undefined; } +/** + * Normalize one finite schema number for tooling metadata and comparisons. + * + * @param {unknown} value Raw schema value. + * @returns {number | undefined} Normalized finite number. + */ +function normalizeSchemaNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Normalize one non-negative integer schema value for length constraints. + * + * @param {unknown} value Raw schema value. + * @returns {number | undefined} Normalized non-negative integer. + */ +function normalizeSchemaNonNegativeInteger(value) { + return Number.isInteger(value) && value >= 0 ? value : undefined; +} + /** * Convert a schema default value into a compact string that can be shown in UI * metadata hints. @@ -437,6 +457,10 @@ function parseSchemaNode(rawNode, displayPath) { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, defaultValue: formatSchemaDefaultValue(value.default), + minimum: normalizeSchemaNumber(value.minimum), + maximum: normalizeSchemaNumber(value.maximum), + minLength: normalizeSchemaNonNegativeInteger(value.minLength), + maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined @@ -481,6 +505,10 @@ function parseSchemaNode(rawNode, displayPath) { title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, + minimum: metadata.minimum, + maximum: metadata.maximum, + minLength: metadata.minLength, + maxLength: metadata.maxLength, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -557,6 +585,47 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }) }); } + + const scalarValue = unquoteScalar(yamlNode.value); + if (typeof schemaNode.minimum === "number" && Number(scalarValue) < schemaNode.minimum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, { + displayPath, + value: String(schemaNode.minimum) + }) + }); + } + + if (typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, { + displayPath, + value: String(schemaNode.maximum) + }) + }); + } + + if (typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, { + displayPath, + value: String(schemaNode.minLength) + }) + }); + } + + if (typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, { + displayPath, + value: String(schemaNode.maxLength) + }) + }); + } } /** @@ -641,6 +710,14 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; + case ValidationMessageKeys.maximumViolation: + return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`; + case ValidationMessageKeys.maxLengthViolation: + return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; + case ValidationMessageKeys.minimumViolation: + return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; + case ValidationMessageKeys.minLengthViolation: + return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -661,6 +738,14 @@ function localizeValidationMessage(key, localizer, params) { return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; + case ValidationMessageKeys.maximumViolation: + return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`; + case ValidationMessageKeys.maxLengthViolation: + return `Property '${params.displayPath}' must be at most ${params.value} characters long.`; + case ValidationMessageKeys.minimumViolation: + return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; + case ValidationMessageKeys.minLengthViolation: + return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 9695074e..5268bea3 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1574,7 +1574,7 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{description?: string, defaultValue?: string, enumValues?: string[], items?: {enumValues?: string[]}, refTable?: string}} propertySchema Property schema metadata. + * @param {{description?: string, defaultValue?: string, minimum?: number, maximum?: number, minLength?: number, maxLength?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, maximum?: number, minLength?: number, maxLength?: number}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ @@ -1598,6 +1598,38 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")}))); } + if (!isArrayField && typeof propertySchema.minimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum}))); + } + + if (!isArrayField && typeof propertySchema.maximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum}))); + } + + if (!isArrayField && typeof propertySchema.minLength === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength}))); + } + + if (!isArrayField && typeof propertySchema.maxLength === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength}))); + } + + if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum}))); + } + + if (isArrayField && propertySchema.items && typeof propertySchema.items.maximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMaximum", {value: propertySchema.items.maximum}))); + } + + if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength}))); + } + + if (isArrayField && propertySchema.items && typeof propertySchema.items.maxLength === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength}))); + } + if (propertySchema.refTable) { hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 3c8a74d7..6c25487b 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -105,11 +105,23 @@ const enMessages = { "webview.array.hint": "One item per line. Expected type: {itemType}", "webview.hint.default": "Default: {value}", "webview.hint.allowed": "Allowed: {values}", + "webview.hint.minimum": "Minimum: {value}", + "webview.hint.maximum": "Maximum: {value}", + "webview.hint.minLength": "Min length: {value}", + "webview.hint.maxLength": "Max length: {value}", + "webview.hint.itemMinimum": "Item minimum: {value}", + "webview.hint.itemMaximum": "Item maximum: {value}", + "webview.hint.itemMinLength": "Item min length: {value}", + "webview.hint.itemMaxLength": "Item max length: {value}", "webview.hint.refTable": "Ref table: {refTable}", "webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.", "webview.unsupported.type": "{type} fields are currently raw-YAML-only.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.", + [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", + [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", + [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", @@ -179,11 +191,23 @@ const zhCnMessages = { "webview.array.hint": "每行一个元素。期望类型:{itemType}", "webview.hint.default": "默认值:{value}", "webview.hint.allowed": "允许值:{values}", + "webview.hint.minimum": "最小值:{value}", + "webview.hint.maximum": "最大值:{value}", + "webview.hint.minLength": "最小长度:{value}", + "webview.hint.maxLength": "最大长度:{value}", + "webview.hint.itemMinimum": "元素最小值:{value}", + "webview.hint.itemMaximum": "元素最大值:{value}", + "webview.hint.itemMinLength": "元素最小长度:{value}", + "webview.hint.itemMaxLength": "元素最大长度:{value}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", + [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", + [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", + [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", [ValidationMessageKeys.expectedObject]: "{subject}", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index caf2f635..785f51ee 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -4,6 +4,10 @@ const ValidationMessageKeys = Object.freeze({ expectedObject: "validation.expectedObject", expectedScalarShape: "validation.expectedScalarShape", expectedScalarValue: "validation.expectedScalarValue", + maximumViolation: "validation.maximumViolation", + maxLengthViolation: "validation.maxLengthViolation", + minimumViolation: "validation.minimumViolation", + minLengthViolation: "validation.minLengthViolation", missingRequired: "validation.missingRequired", unknownProperty: "validation.unknownProperty" }); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 225be952..cfac2fb4 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -190,6 +190,82 @@ reward: assert.match(diagnostics[0].message, /coin, gem/u); }); +test("validateParsedConfig should report numeric range and string length mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 8 + }, + "hp": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4 + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: Sl +hp: 12 +tags: + - safe + - shield +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 3); + assert.match(diagnostics[0].message, /at least 3 characters|至少为 3 个字符/u); + assert.match(diagnostics[1].message, /less than or equal to 10|小于或等于 10/u); + assert.match(diagnostics[2].message, /tags\[1\]|shield/u); +}); + +test("parseSchemaContent should capture scalar range and length metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 12 + }, + "hp": { + "type": "integer", + "minimum": 1, + "maximum": 99 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 2, + "maxLength": 6 + } + } + } + } + `); + + assert.equal(schema.properties.name.minLength, 3); + assert.equal(schema.properties.name.maxLength, 12); + assert.equal(schema.properties.hp.minimum, 1); + assert.equal(schema.properties.hp.maximum, 99); + assert.equal(schema.properties.tags.items.minLength, 2); + assert.equal(schema.properties.tags.items.maxLength, 6); +}); + test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => { const schema = parseSchemaContent(` { From b4e026a70dd5f6262666360fd8db017955052fba Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:08:24 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0YAML?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=E7=9A=84JSON=20Schema?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了YAML配置文件与JSON Schema的运行时校验能力 - 支持嵌套对象、对象数组、标量数组的递归校验 - 提供跨表引用的约束检查与引用采集功能 - 支持enum枚举值与数值范围约束验证 - 实现详细的错误诊断信息与字段路径定位 - 包含完整的异常处理与错误报告机制 --- .../Config/YamlConfigSchemaValidator.cs | 59 +++++++++++++++---- .../src/configValidation.js | 35 ++++++++--- .../test/configValidation.test.js | 22 +++++++ 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 09153c57..a53fccab 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -913,7 +913,21 @@ internal static class YamlConfigSchemaValidator { case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: - var numericValue = double.Parse(normalizedValue, CultureInfo.InvariantCulture); + if (!double.TryParse( + normalizedValue, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var numericValue)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnexpectedFailure, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue); + } if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) { @@ -975,6 +989,16 @@ internal static class YamlConfigSchemaValidator } return; + + default: + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.UnexpectedFailure, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: schemaNode.NodeType.ToString()); } } @@ -1153,16 +1177,29 @@ internal static class YamlConfigSchemaValidator return expectedType switch { YamlConfigSchemaPropertyType.String => value, - YamlConfigSchemaPropertyType.Integer => long.Parse( - value, - NumberStyles.Integer, - CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), - YamlConfigSchemaPropertyType.Number => double.Parse( - value, - NumberStyles.Float | NumberStyles.AllowThousands, - CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), - YamlConfigSchemaPropertyType.Boolean => bool.Parse(value).ToString().ToLowerInvariant(), - _ => value + YamlConfigSchemaPropertyType.Integer when long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var integerValue) => + integerValue.ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Number when double.TryParse( + value, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var numberValue) => + numberValue.ToString(CultureInfo.InvariantCulture), + YamlConfigSchemaPropertyType.Boolean when bool.TryParse(value, out var booleanValue) => + booleanValue.ToString().ToLowerInvariant(), + YamlConfigSchemaPropertyType.Integer => + throw new InvalidOperationException($"Value '{value}' cannot be normalized as integer."), + YamlConfigSchemaPropertyType.Number => + throw new InvalidOperationException($"Value '{value}' cannot be normalized as number."), + YamlConfigSchemaPropertyType.Boolean => + throw new InvalidOperationException($"Value '{value}' cannot be normalized as boolean."), + _ => + throw new InvalidOperationException( + $"Schema node type '{expectedType}' cannot be normalized as a scalar value.") }; } diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index de70e832..31467f78 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -505,10 +505,18 @@ function parseSchemaNode(rawNode, displayPath) { title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, - minimum: metadata.minimum, - maximum: metadata.maximum, - minLength: metadata.minLength, - maxLength: metadata.maxLength, + minimum: type === "integer" || type === "number" + ? metadata.minimum + : undefined, + maximum: type === "integer" || type === "number" + ? metadata.maximum + : undefined, + minLength: type === "string" + ? metadata.minLength + : undefined, + maxLength: type === "string" + ? metadata.maxLength + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -587,7 +595,12 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } const scalarValue = unquoteScalar(yamlNode.value); - if (typeof schemaNode.minimum === "number" && Number(scalarValue) < schemaNode.minimum) { + const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; + const supportsLengthConstraints = schemaNode.type === "string"; + + if (supportsNumericConstraints && + typeof schemaNode.minimum === "number" && + Number(scalarValue) < schemaNode.minimum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, { @@ -597,7 +610,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } - if (typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { + if (supportsNumericConstraints && + typeof schemaNode.maximum === "number" && + Number(scalarValue) > schemaNode.maximum) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, { @@ -607,7 +622,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } - if (typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { + if (supportsLengthConstraints && + typeof schemaNode.minLength === "number" && + scalarValue.length < schemaNode.minLength) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, { @@ -617,7 +634,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } - if (typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) { + if (supportsLengthConstraints && + typeof schemaNode.maxLength === "number" && + scalarValue.length > schemaNode.maxLength) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, { diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index cfac2fb4..a88becb6 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -266,6 +266,28 @@ test("parseSchemaContent should capture scalar range and length metadata", () => assert.equal(schema.properties.tags.items.maxLength, 6); }); +test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "minimum": 1, + "minLength": 3 + } + } + } + `); + const yaml = parseTopLevelYaml(` +enabled: true +`); + + assert.equal(schema.properties.enabled.minimum, undefined); + assert.equal(schema.properties.enabled.minLength, undefined); + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => { const schema = parseSchemaContent(` {