diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 86f01098..8254afe1 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -412,6 +412,55 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证开区间数值边界约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Number_Violates_Exclusive_Minimum_Or_Exclusive_Maximum() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 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("10")); + Assert.That(exception.Message, Does.Contain("greater than 10")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -461,6 +510,111 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证字符串正则模式约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_String_Does_Not_Match_Pattern() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "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("slime")); + Assert.That(exception.Message, Does.Contain("regular expression")); + Assert.That(exception.Message, Does.Contain("^[A-Z][a-z]+$")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证数组元素数量约束会在运行时被统一拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Array_Violates_MinItems_Or_MaxItems() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + dropRates: + - 1 + - 2 + - 3 + - 4 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "dropRates"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "dropRates": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "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("dropRates")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("4")); + Assert.That(exception.Message, Does.Contain("at most 3 items")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// @@ -1594,4 +1748,4 @@ public class YamlConfigLoaderTests /// 配置主键。 /// 配置名称。 private sealed record ExistingConfigStub(int Id, string Name); -} \ No newline at end of file +} diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index a53fccab..98f670b3 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using GFramework.Game.Abstractions.Config; namespace GFramework.Game.Config; @@ -297,6 +298,7 @@ internal static class YamlConfigSchemaValidator referenceTableName: null, allowedValues: null, constraints: null, + arrayConstraints: null, schemaPath); } @@ -365,6 +367,7 @@ internal static class YamlConfigSchemaValidator referenceTableName: null, allowedValues: null, constraints: null, + arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); } @@ -395,6 +398,7 @@ internal static class YamlConfigSchemaValidator referenceTableName, ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), + arrayConstraints: null, schemaPath); } @@ -580,6 +584,11 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(displayPath)); } + if (schemaNode.ArrayConstraints is not null) + { + ValidateArrayConstraints(tableName, yamlPath, displayPath, sequenceNode.Children.Count, schemaNode); + } + for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { ValidateNode( @@ -739,8 +748,10 @@ internal static class YamlConfigSchemaValidator } /// - /// 解析标量字段支持的范围与长度约束。 - /// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。 + /// 解析标量字段支持的范围、长度与模式约束。 + /// 当前共享子集支持: + /// `integer/number` 上的 `minimum/maximum/exclusiveMinimum/exclusiveMaximum`, + /// 以及 `string` 上的 `minLength/maxLength/pattern`。 /// /// 所属配置表名称。 /// Schema 文件路径。 @@ -757,8 +768,13 @@ internal static class YamlConfigSchemaValidator { var minimum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minimum"); var maximum = TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maximum"); + var exclusiveMinimum = + TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMinimum"); + var exclusiveMaximum = + TryParseNumericConstraint(tableName, schemaPath, propertyPath, element, nodeType, "exclusiveMaximum"); var minLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "minLength"); var maxLength = TryParseLengthConstraint(tableName, schemaPath, propertyPath, element, nodeType, "maxLength"); + var pattern = TryParsePatternConstraint(tableName, schemaPath, propertyPath, element, nodeType); if (minimum.HasValue && maximum.HasValue && minimum.Value > maximum.Value) { @@ -770,6 +786,15 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } + ValidateNumericConstraintRange( + tableName, + schemaPath, + propertyPath, + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum); + if (minLength.HasValue && maxLength.HasValue && minLength.Value > maxLength.Value) { throw ConfigLoadExceptionFactory.Create( @@ -780,12 +805,62 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - if (!minimum.HasValue && !maximum.HasValue && !minLength.HasValue && !maxLength.HasValue) + if (!minimum.HasValue && + !maximum.HasValue && + !exclusiveMinimum.HasValue && + !exclusiveMaximum.HasValue && + !minLength.HasValue && + !maxLength.HasValue && + pattern is null) { return null; } - return new YamlConfigScalarConstraints(minimum, maximum, minLength, maxLength); + return new YamlConfigScalarConstraints( + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + minLength, + maxLength, + pattern, + pattern is null + ? null + : new Regex( + pattern, + RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)); + } + + /// + /// 解析数组节点支持的元素数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 数组字段路径。 + /// Schema 节点。 + /// 数组约束模型;未声明时返回空。 + private static YamlConfigArrayConstraints? ParseArrayConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + var minItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "minItems"); + var maxItems = TryParseArrayLengthConstraint(tableName, schemaPath, propertyPath, element, "maxItems"); + + if (minItems.HasValue && maxItems.HasValue && minItems.Value > maxItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minItems' greater than 'maxItems'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return !minItems.HasValue && !maxItems.HasValue + ? null + : new YamlConfigArrayConstraints(minItems, maxItems); } /// @@ -886,6 +961,180 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取字符串正则约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 字段类型。 + /// 正则模式;未声明时返回空。 + private static string? TryParsePatternConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + YamlConfigSchemaPropertyType nodeType) + { + if (!element.TryGetProperty("pattern", out var patternElement)) + { + return null; + } + + if (nodeType != YamlConfigSchemaPropertyType.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' uses 'pattern', but only 'string' scalar types support regular-expression constraints.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + if (patternElement.ValueKind != JsonValueKind.String) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' must declare 'pattern' as a string.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + var pattern = patternElement.GetString() ?? string.Empty; + try + { + _ = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); + } + catch (ArgumentException exception) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares an invalid 'pattern' regular expression.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath), + rawValue: pattern, + innerException: exception); + } + + return pattern; + } + + /// + /// 读取数组元素数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// Schema 节点。 + /// 关键字名称。 + /// 数组元素数量约束;未声明时返回空。 + private static int? TryParseArrayLengthConstraint( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element, + string keywordName) + { + if (!element.TryGetProperty(keywordName, out var constraintElement)) + { + return null; + } + + 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; + } + + /// + /// 校验数值上下界组合不会形成空区间。 + /// 这里把闭区间与开区间统一折算为最强边界,避免 schema 进入“无任何合法值”的状态。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 字段路径。 + /// 闭区间最小值。 + /// 闭区间最大值。 + /// 开区间最小值。 + /// 开区间最大值。 + private static void ValidateNumericConstraintRange( + string tableName, + string schemaPath, + string propertyPath, + double? minimum, + double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum) + { + var hasLowerBound = false; + var lowerBound = double.MinValue; + var isLowerBoundExclusive = false; + + if (minimum.HasValue) + { + hasLowerBound = true; + lowerBound = minimum.Value; + } + + if (exclusiveMinimum.HasValue && + (!hasLowerBound || + exclusiveMinimum.Value > lowerBound || + (exclusiveMinimum.Value.Equals(lowerBound) && !isLowerBoundExclusive))) + { + hasLowerBound = true; + lowerBound = exclusiveMinimum.Value; + isLowerBoundExclusive = true; + } + + var hasUpperBound = false; + var upperBound = double.MaxValue; + var isUpperBoundExclusive = false; + + if (maximum.HasValue) + { + hasUpperBound = true; + upperBound = maximum.Value; + } + + if (exclusiveMaximum.HasValue && + (!hasUpperBound || + exclusiveMaximum.Value < upperBound || + (exclusiveMaximum.Value.Equals(upperBound) && !isUpperBoundExclusive))) + { + hasUpperBound = true; + upperBound = exclusiveMaximum.Value; + isUpperBoundExclusive = true; + } + + if (!hasLowerBound || !hasUpperBound) + { + return; + } + + if (lowerBound > upperBound || + (lowerBound.Equals(upperBound) && (isLowerBoundExclusive || isUpperBoundExclusive))) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares numeric constraints that do not leave any valid value range.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + } + /// /// 校验标量值是否满足范围与长度约束。 /// @@ -943,6 +1192,20 @@ internal static class YamlConfigSchemaValidator $"Minimum allowed value: {constraints.Minimum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.ExclusiveMinimum.HasValue && numericValue <= constraints.ExclusiveMinimum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be greater than {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Exclusive minimum allowed value: {constraints.ExclusiveMinimum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + if (constraints.Maximum.HasValue && numericValue > constraints.Maximum.Value) { throw ConfigLoadExceptionFactory.Create( @@ -957,6 +1220,20 @@ internal static class YamlConfigSchemaValidator $"Maximum allowed value: {constraints.Maximum.Value.ToString(CultureInfo.InvariantCulture)}."); } + if (constraints.ExclusiveMaximum.HasValue && numericValue >= constraints.ExclusiveMaximum.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be less than {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: + $"Exclusive maximum allowed value: {constraints.ExclusiveMaximum.Value.ToString(CultureInfo.InvariantCulture)}."); + } + return; case YamlConfigSchemaPropertyType.String: @@ -988,6 +1265,20 @@ internal static class YamlConfigSchemaValidator detail: $"Maximum length: {constraints.MaxLength.Value}."); } + if (constraints.PatternRegex is not null && + !constraints.PatternRegex.IsMatch(rawValue)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must match regular expression '{constraints.Pattern}', but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Expected pattern: {constraints.Pattern}."); + } + return; default: @@ -1002,6 +1293,54 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 校验数组值是否满足元素数量约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 当前数组元素数量。 + /// 数组 schema 节点。 + private static void ValidateArrayConstraints( + string tableName, + string yamlPath, + string displayPath, + int itemCount, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.ArrayConstraints; + if (constraints is null) + { + return; + } + + if (constraints.MinItems.HasValue && itemCount < constraints.MinItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at least {constraints.MinItems.Value} items, but the current YAML sequence contains {itemCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: itemCount.ToString(CultureInfo.InvariantCulture), + detail: $"Minimum item count: {constraints.MinItems.Value}."); + } + + if (constraints.MaxItems.HasValue && itemCount > constraints.MaxItems.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must contain at most {constraints.MaxItems.Value} items, but the current YAML sequence contains {itemCount}.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: itemCount.ToString(CultureInfo.InvariantCulture), + detail: $"Maximum item count: {constraints.MaxItems.Value}."); + } + } + /// /// 解析跨表引用目标表名称。 /// @@ -1323,6 +1662,7 @@ internal sealed class YamlConfigSchemaNode /// 目标引用表名称。 /// 标量允许值集合。 /// 标量范围与长度约束。 + /// 数组元素数量约束。 /// 用于错误信息的 schema 文件路径提示。 public YamlConfigSchemaNode( YamlConfigSchemaPropertyType nodeType, @@ -1332,6 +1672,7 @@ internal sealed class YamlConfigSchemaNode string? referenceTableName, IReadOnlyCollection? allowedValues, YamlConfigScalarConstraints? constraints, + YamlConfigArrayConstraints? arrayConstraints, string schemaPathHint) { NodeType = nodeType; @@ -1341,6 +1682,7 @@ internal sealed class YamlConfigSchemaNode ReferenceTableName = referenceTableName; AllowedValues = allowedValues; Constraints = constraints; + ArrayConstraints = arrayConstraints; SchemaPathHint = schemaPathHint; } @@ -1379,6 +1721,11 @@ internal sealed class YamlConfigSchemaNode /// public YamlConfigScalarConstraints? Constraints { get; } + /// + /// 获取数组元素数量约束;未声明时返回空。 + /// + public YamlConfigArrayConstraints? ArrayConstraints { get; } + /// /// 获取用于诊断显示的 schema 路径提示。 /// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。 @@ -1401,6 +1748,7 @@ internal sealed class YamlConfigSchemaNode referenceTableName, AllowedValues, Constraints, + ArrayConstraints, SchemaPathHint); } } @@ -1416,18 +1764,30 @@ internal sealed class YamlConfigScalarConstraints /// /// 最小值约束。 /// 最大值约束。 + /// 开区间最小值约束。 + /// 开区间最大值约束。 /// 最小长度约束。 /// 最大长度约束。 + /// 正则模式约束。 + /// 已编译的正则表达式。 public YamlConfigScalarConstraints( double? minimum, double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum, int? minLength, - int? maxLength) + int? maxLength, + string? pattern, + Regex? patternRegex) { Minimum = minimum; Maximum = maximum; + ExclusiveMinimum = exclusiveMinimum; + ExclusiveMaximum = exclusiveMaximum; MinLength = minLength; MaxLength = maxLength; + Pattern = pattern; + PatternRegex = patternRegex; } /// @@ -1440,6 +1800,16 @@ internal sealed class YamlConfigScalarConstraints /// public double? Maximum { get; } + /// + /// 获取开区间最小值约束。 + /// + public double? ExclusiveMinimum { get; } + + /// + /// 获取开区间最大值约束。 + /// + public double? ExclusiveMaximum { get; } + /// /// 获取最小长度约束。 /// @@ -1449,6 +1819,44 @@ internal sealed class YamlConfigScalarConstraints /// 获取最大长度约束。 /// public int? MaxLength { get; } + + /// + /// 获取正则模式约束原文。 + /// + public string? Pattern { get; } + + /// + /// 获取已编译的正则表达式。 + /// + public Regex? PatternRegex { get; } +} + +/// +/// 表示一个数组节点上声明的元素数量约束。 +/// 该模型与标量约束拆分保存,避免数组节点继续共享不适用的标量字段。 +/// +internal sealed class YamlConfigArrayConstraints +{ + /// + /// 初始化数组约束模型。 + /// + /// 最小元素数量约束。 + /// 最大元素数量约束。 + public YamlConfigArrayConstraints(int? minItems, int? maxItems) + { + MinItems = minItems; + MaxItems = maxItems; + } + + /// + /// 获取最小元素数量约束。 + /// + public int? MinItems { get; } + + /// + /// 获取最大元素数量约束。 + /// + public int? MaxItems { get; } } /// @@ -1558,4 +1966,4 @@ internal enum YamlConfigSchemaPropertyType /// 数组类型。 /// Array -} \ No newline at end of file +} diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index 9c375432..8dd98ed5 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -81,6 +81,7 @@ public class SchemaConfigGeneratorSnapshotTests "description": "Localized monster display name.", "minLength": 3, "maxLength": 16, + "pattern": "^[A-Z][a-z]+$", "default": "Slime", "enum": ["Slime", "Goblin"] }, @@ -88,11 +89,15 @@ public class SchemaConfigGeneratorSnapshotTests "type": "integer", "minimum": 1, "maximum": 999, + "exclusiveMinimum": 0, + "exclusiveMaximum": 1000, "default": 10 }, "dropItems": { "description": "Referenced drop ids.", "type": "array", + "minItems": 1, + "maxItems": 3, "items": { "type": "string", "minLength": 3, diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index 482d016a..2306fa00 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -24,7 +24,7 @@ public sealed partial class MonsterConfig /// Schema property path: 'name'. /// Display title: 'Monster Name'. /// Allowed values: Slime, Goblin. - /// Constraints: minLength = 3, maxLength = 16. + /// Constraints: minLength = 3, maxLength = 16, pattern = '^[A-Z][a-z]+$'. /// Generated default initializer: = "Slime"; /// public string Name { get; set; } = "Slime"; @@ -34,7 +34,7 @@ public sealed partial class MonsterConfig /// /// /// Schema property path: 'hp'. - /// Constraints: minimum = 1, maximum = 999. + /// Constraints: minimum = 1, exclusiveMinimum = 0, maximum = 999, exclusiveMaximum = 1000. /// Generated default initializer: = 10; /// public int? Hp { get; set; } = 10; @@ -45,6 +45,7 @@ public sealed partial class MonsterConfig /// /// Schema property path: 'dropItems'. /// Allowed values: potion, slime_gel. + /// Constraints: minItems = 1, maxItems = 3. /// References config table: 'item'. /// Item constraints: minLength = 3, maxLength = 12. /// Generated default initializer: = new string[] { "potion" }; diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 38eec75f..cd4e1392 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -477,7 +477,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator TryBuildArrayInitializer(property.Value, itemType, itemClrType) ?? $" = global::System.Array.Empty<{itemClrType}>();", TryBuildEnumDocumentation(itemsElement, itemType), - null, + TryBuildConstraintDocumentation(property.Value, "array"), refTableName, null, new SchemaTypeSpec( @@ -527,7 +527,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator $"global::System.Collections.Generic.IReadOnlyList<{objectSpec.ClassName}>", $" = global::System.Array.Empty<{objectSpec.ClassName}>();", null, - null, + TryBuildConstraintDocumentation(property.Value, "array"), null, null, new SchemaTypeSpec( @@ -1876,7 +1876,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、长度、模式与数组数量约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -1891,12 +1891,24 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"minimum = {minimum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "exclusiveMinimum", out var exclusiveMinimum)) + { + parts.Add($"exclusiveMinimum = {exclusiveMinimum.ToString(CultureInfo.InvariantCulture)}"); + } + if ((schemaType == "integer" || schemaType == "number") && TryGetFiniteNumber(element, "maximum", out var maximum)) { parts.Add($"maximum = {maximum.ToString(CultureInfo.InvariantCulture)}"); } + if ((schemaType == "integer" || schemaType == "number") && + TryGetFiniteNumber(element, "exclusiveMaximum", out var exclusiveMaximum)) + { + parts.Add($"exclusiveMaximum = {exclusiveMaximum.ToString(CultureInfo.InvariantCulture)}"); + } + if (schemaType == "string" && TryGetNonNegativeInt32(element, "minLength", out var minLength)) { @@ -1909,6 +1921,25 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add($"maxLength = {maxLength.ToString(CultureInfo.InvariantCulture)}"); } + if (schemaType == "string" && + element.TryGetProperty("pattern", out var patternElement) && + patternElement.ValueKind == JsonValueKind.String) + { + parts.Add($"pattern = '{patternElement.GetString() ?? string.Empty}'"); + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "minItems", out var minItems)) + { + parts.Add($"minItems = {minItems.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "array" && + TryGetNonNegativeInt32(element, "maxItems", out var maxItems)) + { + parts.Add($"maxItems = {maxItems.ToString(CultureInfo.InvariantCulture)}"); + } + return parts.Count > 0 ? string.Join(", ", parts) : null; } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 5409c4ad..f28b4f13 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -12,7 +12,7 @@ - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 -- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`minLength`、`maxLength` +- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -553,7 +553,10 @@ var loader = new YamlConfigLoader("config-root") - 嵌套对象字段类型不匹配 - 对象数组元素结构不匹配 - 数值字段违反 `minimum` / `maximum` +- 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum` - 字符串字段违反 `minLength` / `maxLength` +- 字符串字段违反 `pattern` +- 数组字段违反 `minItems` / `maxItems` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -602,7 +605,10 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `default`:供生成类型属性初始值和工具提示复用 - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 +- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 +- `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 31467f78..2d563723 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -379,6 +379,26 @@ function normalizeSchemaNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0 ? value : undefined; } +/** + * Normalize one schema pattern string when the regular expression can be + * compiled by the local tooling runtime. + * + * @param {unknown} value Raw schema value. + * @returns {string | undefined} Normalized pattern string. + */ +function normalizeSchemaPattern(value) { + if (typeof value !== "string") { + return undefined; + } + + try { + void new RegExp(value); + return value; + } catch { + return undefined; + } +} + /** * Convert a schema default value into a compact string that can be shown in UI * metadata hints. @@ -410,6 +430,25 @@ function formatSchemaDefaultValue(value) { return undefined; } +/** + * Test one scalar value against one schema pattern string. + * + * @param {string} scalarValue Scalar value from YAML. + * @param {string | undefined} pattern Schema pattern string. + * @returns {boolean} True when the value matches or no pattern is declared. + */ +function matchesSchemaPattern(scalarValue, pattern) { + if (typeof pattern !== "string") { + return true; + } + + try { + return new RegExp(pattern).test(scalarValue); + } catch { + return true; + } +} + /** * Format a scalar value for YAML output. * @@ -458,9 +497,14 @@ function parseSchemaNode(rawNode, displayPath) { description: typeof value.description === "string" ? value.description : undefined, defaultValue: formatSchemaDefaultValue(value.default), minimum: normalizeSchemaNumber(value.minimum), + exclusiveMinimum: normalizeSchemaNumber(value.exclusiveMinimum), maximum: normalizeSchemaNumber(value.maximum), + exclusiveMaximum: normalizeSchemaNumber(value.exclusiveMaximum), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), + pattern: normalizeSchemaPattern(value.pattern), + minItems: normalizeSchemaNonNegativeInteger(value.minItems), + maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] : undefined @@ -494,6 +538,8 @@ function parseSchemaNode(rawNode, displayPath) { title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue, + minItems: metadata.minItems, + maxItems: metadata.maxItems, refTable: metadata.refTable, items: itemNode }; @@ -508,15 +554,24 @@ function parseSchemaNode(rawNode, displayPath) { minimum: type === "integer" || type === "number" ? metadata.minimum : undefined, + exclusiveMinimum: type === "integer" || type === "number" + ? metadata.exclusiveMinimum + : undefined, maximum: type === "integer" || type === "number" ? metadata.maximum : undefined, + exclusiveMaximum: type === "integer" || type === "number" + ? metadata.exclusiveMaximum + : undefined, minLength: type === "string" ? metadata.minLength : undefined, maxLength: type === "string" ? metadata.maxLength : undefined, + pattern: type === "string" + ? metadata.pattern + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -548,6 +603,28 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) return; } + if (typeof schemaNode.minItems === "number" && + yamlNode.items.length < schemaNode.minItems) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minItemsViolation, localizer, { + displayPath, + value: String(schemaNode.minItems) + }) + }); + } + + if (typeof schemaNode.maxItems === "number" && + yamlNode.items.length > schemaNode.maxItems) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maxItemsViolation, localizer, { + displayPath, + value: String(schemaNode.maxItems) + }) + }); + } + for (let index = 0; index < yamlNode.items.length; index += 1) { validateNode( schemaNode.items, @@ -597,6 +674,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) const scalarValue = unquoteScalar(yamlNode.value); const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number"; const supportsLengthConstraints = schemaNode.type === "string"; + const supportsPatternConstraints = schemaNode.type === "string"; if (supportsNumericConstraints && typeof schemaNode.minimum === "number" && @@ -610,6 +688,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMinimum === "number" && + Number(scalarValue) <= schemaNode.exclusiveMinimum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.exclusiveMinimumViolation, localizer, { + displayPath, + value: String(schemaNode.exclusiveMinimum) + }) + }); + } + if (supportsNumericConstraints && typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { @@ -622,6 +712,18 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + if (supportsNumericConstraints && + typeof schemaNode.exclusiveMaximum === "number" && + Number(scalarValue) >= schemaNode.exclusiveMaximum) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.exclusiveMaximumViolation, localizer, { + displayPath, + value: String(schemaNode.exclusiveMaximum) + }) + }); + } + if (supportsLengthConstraints && typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { @@ -645,6 +747,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }) }); } + + if (supportsPatternConstraints && + !matchesSchemaPattern(scalarValue, schemaNode.pattern)) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { + displayPath, + value: schemaNode.pattern + }) + }); + } } /** @@ -729,14 +842,24 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; + case ValidationMessageKeys.exclusiveMaximumViolation: + return `属性“${params.displayPath}”必须小于 ${params.value}。`; + case ValidationMessageKeys.exclusiveMinimumViolation: + return `属性“${params.displayPath}”必须大于 ${params.value}。`; case ValidationMessageKeys.maximumViolation: return `属性“${params.displayPath}”必须小于或等于 ${params.value}。`; + case ValidationMessageKeys.maxItemsViolation: + return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; case ValidationMessageKeys.maxLengthViolation: return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; + case ValidationMessageKeys.minItemsViolation: + return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; + case ValidationMessageKeys.patternViolation: + return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -757,14 +880,24 @@ 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.exclusiveMaximumViolation: + return `Property '${params.displayPath}' must be less than ${params.value}.`; + case ValidationMessageKeys.exclusiveMinimumViolation: + return `Property '${params.displayPath}' must be greater than ${params.value}.`; case ValidationMessageKeys.maximumViolation: return `Property '${params.displayPath}' must be less than or equal to ${params.value}.`; + case ValidationMessageKeys.maxItemsViolation: + return `Property '${params.displayPath}' must contain at most ${params.value} items.`; 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.minItemsViolation: + return `Property '${params.displayPath}' must contain at least ${params.value} items.`; case ValidationMessageKeys.minLengthViolation: return `Property '${params.displayPath}' must be at least ${params.value} characters long.`; + case ValidationMessageKeys.patternViolation: + return `Property '${params.displayPath}' must match pattern '${params.value}'.`; case ValidationMessageKeys.expectedObject: return params.subject; case ValidationMessageKeys.missingRequired: @@ -1418,6 +1551,8 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * minItems?: number, + * maxItems?: number, * refTable?: string, * items: SchemaNode * } | { @@ -1426,6 +1561,13 @@ module.exports = { * title?: string, * description?: string, * defaultValue?: string, + * minimum?: number, + * exclusiveMinimum?: number, + * maximum?: number, + * exclusiveMaximum?: number, + * minLength?: number, + * maxLength?: number, + * pattern?: string, * enumValues?: string[], * refTable?: string * }} SchemaNode diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 5268bea3..870f01e7 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, 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 {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ @@ -1602,10 +1602,18 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum}))); } + if (!isArrayField && typeof propertySchema.exclusiveMinimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMinimum", {value: propertySchema.exclusiveMinimum}))); + } + if (!isArrayField && typeof propertySchema.maximum === "number") { hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum}))); } + if (!isArrayField && typeof propertySchema.exclusiveMaximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.exclusiveMaximum", {value: propertySchema.exclusiveMaximum}))); + } + if (!isArrayField && typeof propertySchema.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength}))); } @@ -1614,14 +1622,34 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength}))); } + if (!isArrayField && propertySchema.pattern) { + hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern}))); + } + + if (isArrayField && typeof propertySchema.minItems === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems}))); + } + + if (isArrayField && typeof propertySchema.maxItems === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maxItems", {value: propertySchema.maxItems}))); + } + 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.exclusiveMinimum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMinimum", {value: propertySchema.items.exclusiveMinimum}))); + } + 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.exclusiveMaximum === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.itemExclusiveMaximum", {value: propertySchema.items.exclusiveMaximum}))); + } + if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") { hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength}))); } @@ -1630,6 +1658,10 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength}))); } + if (isArrayField && propertySchema.items && propertySchema.items.pattern) { + hints.push(escapeHtml(localizer.t("webview.hint.itemPattern", {value: propertySchema.items.pattern}))); + } + 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 6c25487b..b73c3848 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -106,22 +106,35 @@ const enMessages = { "webview.hint.default": "Default: {value}", "webview.hint.allowed": "Allowed: {values}", "webview.hint.minimum": "Minimum: {value}", + "webview.hint.exclusiveMinimum": "Exclusive minimum: {value}", "webview.hint.maximum": "Maximum: {value}", + "webview.hint.exclusiveMaximum": "Exclusive maximum: {value}", "webview.hint.minLength": "Min length: {value}", "webview.hint.maxLength": "Max length: {value}", + "webview.hint.pattern": "Pattern: {value}", + "webview.hint.minItems": "Min items: {value}", + "webview.hint.maxItems": "Max items: {value}", "webview.hint.itemMinimum": "Item minimum: {value}", + "webview.hint.itemExclusiveMinimum": "Item exclusive minimum: {value}", "webview.hint.itemMaximum": "Item maximum: {value}", + "webview.hint.itemExclusiveMaximum": "Item exclusive maximum: {value}", "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", + "webview.hint.itemPattern": "Item pattern: {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.exclusiveMaximumViolation]: "Property '{displayPath}' must be less than {value}.", + [ValidationMessageKeys.exclusiveMinimumViolation]: "Property '{displayPath}' must be greater than {value}.", [ValidationMessageKeys.maximumViolation]: "Property '{displayPath}' must be less than or equal to {value}.", + [ValidationMessageKeys.maxItemsViolation]: "Property '{displayPath}' must contain at most {value} items.", [ValidationMessageKeys.maxLengthViolation]: "Property '{displayPath}' must be at most {value} characters long.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", + [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", + [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", [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.", @@ -192,22 +205,35 @@ const zhCnMessages = { "webview.hint.default": "默认值:{value}", "webview.hint.allowed": "允许值:{values}", "webview.hint.minimum": "最小值:{value}", + "webview.hint.exclusiveMinimum": "开区间最小值:{value}", "webview.hint.maximum": "最大值:{value}", + "webview.hint.exclusiveMaximum": "开区间最大值:{value}", "webview.hint.minLength": "最小长度:{value}", "webview.hint.maxLength": "最大长度:{value}", + "webview.hint.pattern": "正则模式:{value}", + "webview.hint.minItems": "最少元素数:{value}", + "webview.hint.maxItems": "最多元素数:{value}", "webview.hint.itemMinimum": "元素最小值:{value}", + "webview.hint.itemExclusiveMinimum": "元素开区间最小值:{value}", "webview.hint.itemMaximum": "元素最大值:{value}", + "webview.hint.itemExclusiveMaximum": "元素开区间最大值:{value}", "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", + "webview.hint.itemPattern": "元素正则模式:{value}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.exclusiveMaximumViolation]: "属性“{displayPath}”必须小于 {value}。", + [ValidationMessageKeys.exclusiveMinimumViolation]: "属性“{displayPath}”必须大于 {value}。", [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", + [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", + [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", + [ValidationMessageKeys.patternViolation]: "属性“{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 785f51ee..2eb991ff 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -1,14 +1,19 @@ const ValidationMessageKeys = Object.freeze({ enumMismatch: "validation.enumMismatch", + exclusiveMaximumViolation: "validation.exclusiveMaximumViolation", + exclusiveMinimumViolation: "validation.exclusiveMinimumViolation", expectedArray: "validation.expectedArray", expectedObject: "validation.expectedObject", expectedScalarShape: "validation.expectedScalarShape", expectedScalarValue: "validation.expectedScalarValue", maximumViolation: "validation.maximumViolation", + maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", minimumViolation: "validation.minimumViolation", + minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", missingRequired: "validation.missingRequired", + patternViolation: "validation.patternViolation", unknownProperty: "validation.unknownProperty" }); diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index a88becb6..e35c16de 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -231,6 +231,46 @@ tags: assert.match(diagnostics[2].message, /tags\[1\]|shield/u); }); +test("validateParsedConfig should report exclusive bounds, pattern, and array item-count mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "hp": { + "type": "integer", + "exclusiveMinimum": 10, + "exclusiveMaximum": 20 + }, + "tags": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "type": "string" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: slime +hp: 10 +tags: + - onlyOne +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 3); + assert.match(diagnostics[0].message, /pattern|正则模式/u); + assert.match(diagnostics[1].message, /greater than 10|大于 10/u); + assert.match(diagnostics[2].message, /at least 2 items|至少需要包含 2 个元素/u); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` { @@ -266,6 +306,41 @@ test("parseSchemaContent should capture scalar range and length metadata", () => assert.equal(schema.properties.tags.items.maxLength, 6); }); +test("parseSchemaContent should capture exclusive bounds, pattern, and array item-count metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Z][a-z]+$" + }, + "hp": { + "type": "integer", + "exclusiveMinimum": 1, + "exclusiveMaximum": 99 + }, + "tags": { + "type": "array", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "string", + "pattern": "^[a-z]+$" + } + } + } + } + `); + + assert.equal(schema.properties.name.pattern, "^[A-Z][a-z]+$"); + assert.equal(schema.properties.hp.exclusiveMinimum, 1); + assert.equal(schema.properties.hp.exclusiveMaximum, 99); + assert.equal(schema.properties.tags.minItems, 2); + assert.equal(schema.properties.tags.maxItems, 4); + assert.equal(schema.properties.tags.items.pattern, "^[a-z]+$"); +}); + test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => { const schema = parseSchemaContent(` {