diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 488c3427..29d57119 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -558,6 +558,47 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证科学计数法数值会按 number 类型被运行时接受。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Scientific_Notation_Number() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + dropRate: 1.5e10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "dropRate"], + "properties": { + "id": { "type": "integer" }, + "dropRate": { "type": "number" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).DropRate, Is.EqualTo(1.5e10)); + }); + } + /// /// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。 /// @@ -864,6 +905,68 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证 uniqueItems 的归一化键不会把带分隔符的不同对象值误判为重复项。 + /// + [Test] + public async Task LoadAsync_Should_Accept_Distinct_Object_Items_When_Comparable_Values_Contain_Separators() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + entries: + - + a: "x|1:b=string:yz" + - + a: x + b: yz + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "entries"], + "properties": { + "id": { "type": "integer" }, + "entries": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "a": { "type": "string" }, + "b": { "type": "string" } + } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Entries.Count, Is.EqualTo(2)); + Assert.That(table.Get(1).Entries[0].A, Is.EqualTo("x|1:b=string:yz")); + Assert.That(table.Get(1).Entries[1].A, Is.EqualTo("x")); + Assert.That(table.Get(1).Entries[1].B, Is.EqualTo("yz")); + }); + } + /// /// 验证启用 schema 校验后,未知字段不会再被静默忽略。 /// @@ -1801,6 +1904,22 @@ public class YamlConfigLoaderTests public int Hp { get; set; } } + /// + /// 用于浮点数 schema 校验测试的最小怪物配置类型。 + /// + private sealed class MonsterNumberConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置浮点掉落率。 + /// + public double DropRate { get; set; } + } + /// /// 用于数组 schema 校验测试的最小怪物配置类型。 /// @@ -1880,6 +1999,22 @@ public class YamlConfigLoaderTests public IReadOnlyList Phases { get; set; } = Array.Empty(); } + /// + /// 用于 uniqueItems 比较键碰撞回归测试的最小配置类型。 + /// + private sealed class MonsterComparableEntryArrayConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置待比较对象数组。 + /// + public List Entries { get; set; } = new(); + } + /// /// 表示对象数组中的阶段元素。 /// @@ -1896,6 +2031,22 @@ public class YamlConfigLoaderTests public string MonsterId { get; set; } = string.Empty; } + /// + /// 表示用于比较键碰撞回归测试的对象数组元素。 + /// + private sealed class ComparableEntryConfigStub + { + /// + /// 获取或设置字段 A。 + /// + public string A { get; set; } = string.Empty; + + /// + /// 获取或设置字段 B。 + /// + public string B { get; set; } = string.Empty; + } + /// /// 用于深层跨表引用测试的怪物配置类型。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index c1f85a3e..d8c09f9f 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -110,7 +110,7 @@ internal static class YamlConfigSchemaValidator string yamlPath, string yamlText) { - ValidateAndCollectReferences(tableName, schema, yamlPath, yamlText); + ValidateCore(tableName, schema, yamlPath, yamlText, references: null); } /// @@ -129,6 +129,29 @@ internal static class YamlConfigSchemaValidator YamlConfigSchema schema, string yamlPath, string yamlText) + { + var references = new List(); + ValidateCore(tableName, schema, yamlPath, yamlText, references); + return references; + } + + /// + /// 执行共享的 YAML 结构校验流程,并按需收集跨表引用。 + /// 这样 可以复用同一条校验链路,同时避免为“不关心引用结果”的调用方分配临时列表。 + /// + /// 所属配置表名称。 + /// 已解析的 schema 模型。 + /// YAML 文件路径,仅用于诊断信息。 + /// YAML 文本内容。 + /// 可选的跨表引用收集器;为 时只做结构校验。 + /// 当参数为空时抛出。 + /// 当 YAML 内容与 schema 不匹配时抛出。 + private static void ValidateCore( + string tableName, + YamlConfigSchema schema, + string yamlPath, + string yamlText, + ICollection? references) { if (string.IsNullOrWhiteSpace(tableName)) { @@ -166,9 +189,7 @@ internal static class YamlConfigSchemaValidator schemaPath: schema.SchemaPath); } - var references = new List(); ValidateNode(tableName, yamlPath, string.Empty, yamlStream.Documents[0].RootNode, schema.RootNode, references); - return references; } /// @@ -296,16 +317,7 @@ internal static class YamlConfigSchemaValidator property.Value); } - return new YamlConfigSchemaNode( - YamlConfigSchemaPropertyType.Object, - properties, - requiredProperties, - itemNode: null, - referenceTableName: null, - allowedValues: null, - constraints: null, - arrayConstraints: null, - schemaPath); + return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath); } /// @@ -365,15 +377,9 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - return new YamlConfigSchemaNode( - YamlConfigSchemaPropertyType.Array, - properties: null, - requiredProperties: null, + return YamlConfigSchemaNode.CreateArray( itemNode, - referenceTableName: null, - allowedValues: null, - constraints: null, - arrayConstraints: ParseArrayConstraints(tableName, schemaPath, propertyPath, element), + ParseArrayConstraints(tableName, schemaPath, propertyPath, element), schemaPath); } @@ -396,15 +402,11 @@ internal static class YamlConfigSchemaValidator string? referenceTableName) { EnsureReferenceKeywordIsSupported(tableName, schemaPath, propertyPath, nodeType, referenceTableName); - return new YamlConfigSchemaNode( + return YamlConfigSchemaNode.CreateScalar( nodeType, - properties: null, - requiredProperties: null, - itemNode: null, referenceTableName, ParseEnumValues(tableName, schemaPath, propertyPath, element, nodeType, "enum"), ParseScalarConstraints(tableName, schemaPath, propertyPath, element, nodeType), - arrayConstraints: null, schemaPath); } @@ -424,7 +426,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { switch (schemaNode.NodeType) { @@ -470,7 +472,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlMappingNode mappingNode) { @@ -566,7 +568,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlSequenceNode sequenceNode) { @@ -624,7 +626,7 @@ internal static class YamlConfigSchemaValidator string displayPath, YamlNode node, YamlConfigSchemaNode schemaNode, - ICollection references) + ICollection? references) { if (node is not YamlScalarNode scalarNode) { @@ -699,7 +701,8 @@ internal static class YamlConfigSchemaValidator ValidateScalarConstraints(tableName, yamlPath, displayPath, value, normalizedValue, schemaNode); } - if (schemaNode.ReferenceTableName != null) + if (schemaNode.ReferenceTableName != null && + references is not null) { references.Add( new YamlConfigReferenceUsage( @@ -814,32 +817,20 @@ internal static class YamlConfigSchemaValidator displayPath: GetDiagnosticPath(propertyPath)); } - if (!minimum.HasValue && - !maximum.HasValue && - !exclusiveMinimum.HasValue && - !exclusiveMaximum.HasValue && - !multipleOf.HasValue && - !minLength.HasValue && - !maxLength.HasValue && - pattern is null) - { - return null; - } - - return new YamlConfigScalarConstraints( + var numericConstraints = CreateNumericScalarConstraints( minimum, maximum, exclusiveMinimum, exclusiveMaximum, - multipleOf, + multipleOf); + var stringConstraints = CreateStringScalarConstraints( minLength, maxLength, - pattern, - pattern is null - ? null - : new Regex( - pattern, - SupportedPatternRegexOptions)); + pattern); + + return numericConstraints is null && stringConstraints is null + ? null + : new YamlConfigScalarConstraints(numericConstraints, stringConstraints); } /// @@ -1242,137 +1233,24 @@ internal static class YamlConfigSchemaValidator { case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Number: - 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) - { - 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.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( - 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)}."); - } - - 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)}."); - } - - if (constraints.MultipleOf.HasValue && - !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) - { - throw ConfigLoadExceptionFactory.Create( - ConfigLoadFailureKind.ConstraintViolation, - tableName, - $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", - yamlPath: yamlPath, - schemaPath: schemaNode.SchemaPathHint, - displayPath: GetDiagnosticPath(displayPath), - rawValue: rawValue, - detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); - } - + ValidateNumericScalarConstraints( + tableName, + yamlPath, + displayPath, + rawValue, + normalizedValue, + schemaNode, + constraints.NumericConstraints); 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}."); - } - - 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}."); - } - + ValidateStringScalarConstraints( + tableName, + yamlPath, + displayPath, + rawValue, + schemaNode, + constraints.StringConstraints); return; default: @@ -1387,6 +1265,265 @@ internal static class YamlConfigSchemaValidator } } + /// + /// 根据已读取的数值关键字创建数值约束对象。 + /// 该分组让调用方不必再维护一个超过 Sonar 默认阈值的长参数构造函数。 + /// + /// 最小值约束。 + /// 最大值约束。 + /// 开区间最小值约束。 + /// 开区间最大值约束。 + /// 数值步进约束。 + /// 数值约束对象;未声明任何数值约束时返回空。 + private static YamlConfigNumericConstraints? CreateNumericScalarConstraints( + double? minimum, + double? maximum, + double? exclusiveMinimum, + double? exclusiveMaximum, + double? multipleOf) + { + return !minimum.HasValue && + !maximum.HasValue && + !exclusiveMinimum.HasValue && + !exclusiveMaximum.HasValue && + !multipleOf.HasValue + ? null + : new YamlConfigNumericConstraints( + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + multipleOf); + } + + /// + /// 根据已读取的字符串关键字创建字符串约束对象。 + /// 正则会在 schema 解析阶段预编译,避免每次校验都重复实例化。 + /// + /// 最小长度约束。 + /// 最大长度约束。 + /// 正则模式约束。 + /// 字符串约束对象;未声明任何字符串约束时返回空。 + private static YamlConfigStringConstraints? CreateStringScalarConstraints( + int? minLength, + int? maxLength, + string? pattern) + { + return !minLength.HasValue && + !maxLength.HasValue && + pattern is null + ? null + : new YamlConfigStringConstraints( + minLength, + maxLength, + pattern, + pattern is null + ? null + : new Regex( + pattern, + SupportedPatternRegexOptions)); + } + + /// + /// 校验数值标量的区间与步进约束。 + /// 该方法把解析失败、闭区间、开区间和步进诊断集中到数值路径,避免主调度方法继续增长。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 归一化后的比较值。 + /// 标量 schema 节点。 + /// 数值约束对象。 + private static void ValidateNumericScalarConstraints( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + string normalizedValue, + YamlConfigSchemaNode schemaNode, + YamlConfigNumericConstraints? constraints) + { + if (constraints is null) + { + return; + } + + var numericValue = ParseComparableNumericValue( + tableName, + yamlPath, + displayPath, + rawValue, + normalizedValue, + schemaNode); + 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.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( + 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)}."); + } + + 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)}."); + } + + if (constraints.MultipleOf.HasValue && + !IsMultipleOf(numericValue, constraints.MultipleOf.Value)) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"Property '{displayPath}' in config file '{yamlPath}' must be a multiple of {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}, but the current YAML scalar value is '{rawValue}'.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Required numeric step: {constraints.MultipleOf.Value.ToString(CultureInfo.InvariantCulture)}."); + } + } + + /// + /// 将归一化后的数值文本还原为双精度值,用于统一后续区间比较。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 归一化后的比较值。 + /// 标量 schema 节点。 + /// 可比较的双精度值。 + private static double ParseComparableNumericValue( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + string normalizedValue, + YamlConfigSchemaNode schemaNode) + { + if (double.TryParse( + normalizedValue, + NumberStyles.Float | NumberStyles.AllowThousands, + CultureInfo.InvariantCulture, + out var numericValue)) + { + return 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); + } + + /// + /// 校验字符串标量的长度与模式约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 字段路径。 + /// 原始 YAML 标量值。 + /// 标量 schema 节点。 + /// 字符串约束对象。 + private static void ValidateStringScalarConstraints( + string tableName, + string yamlPath, + string displayPath, + string rawValue, + YamlConfigSchemaNode schemaNode, + YamlConfigStringConstraints? constraints) + { + if (constraints is null) + { + return; + } + + 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}."); + } + + 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}."); + } + } + /// /// 校验数组值是否满足元素数量约束。 /// @@ -1492,67 +1629,101 @@ internal static class YamlConfigSchemaValidator /// 可稳定比较的归一化键。 private static string BuildComparableNodeValue(YamlNode node, YamlConfigSchemaNode schemaNode) { - switch (schemaNode.NodeType) + return schemaNode.NodeType switch { - case YamlConfigSchemaPropertyType.Object: - if (node is not YamlMappingNode mappingNode) - { - throw new InvalidOperationException("Validated object nodes must be YAML mappings."); - } + YamlConfigSchemaPropertyType.Object => BuildComparableObjectValue(node, schemaNode), + YamlConfigSchemaPropertyType.Array => BuildComparableArrayValue(node, schemaNode), + YamlConfigSchemaPropertyType.Integer => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.Number => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.Boolean => BuildComparableScalarValue(node, schemaNode), + YamlConfigSchemaPropertyType.String => BuildComparableScalarValue(node, schemaNode), + _ => throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'.") + }; + } - var objectEntries = new List>(mappingNode.Children.Count); - foreach (var entry in mappingNode.Children) - { - if (entry.Key is not YamlScalarNode keyNode || - keyNode.Value is null || - schemaNode.Properties is null || - !schemaNode.Properties.TryGetValue(keyNode.Value, out var propertySchema)) - { - throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); - } - - objectEntries.Add( - new KeyValuePair( - keyNode.Value, - BuildComparableNodeValue(entry.Value, propertySchema))); - } - - objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); - return string.Join( - "|", - objectEntries.Select(static entry => - $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); - - case YamlConfigSchemaPropertyType.Array: - if (node is not YamlSequenceNode sequenceNode || - schemaNode.ItemNode is null) - { - throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); - } - - return "[" + - string.Join( - ",", - sequenceNode.Children.Select( - item => BuildComparableNodeValue(item, schemaNode.ItemNode))) + - "]"; - - case YamlConfigSchemaPropertyType.Integer: - case YamlConfigSchemaPropertyType.Number: - case YamlConfigSchemaPropertyType.Boolean: - case YamlConfigSchemaPropertyType.String: - if (node is not YamlScalarNode scalarNode || - scalarNode.Value is null) - { - throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); - } - - var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); - return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; - - default: - throw new InvalidOperationException($"Unsupported schema node type '{schemaNode.NodeType}'."); + /// + /// 构建对象节点的可比较键。 + /// 对象字段会先按属性名排序,避免 YAML 原始字段顺序影响 uniqueItems 的等价关系。 + /// + /// YAML 节点。 + /// 对象 schema 节点。 + /// 对象节点的稳定比较键。 + private static string BuildComparableObjectValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlMappingNode mappingNode) + { + throw new InvalidOperationException("Validated object nodes must be YAML mappings."); } + + var properties = schemaNode.Properties + ?? throw new InvalidOperationException("Validated object nodes must expose declared properties."); + var objectEntries = new List>(mappingNode.Children.Count); + foreach (var entry in mappingNode.Children) + { + if (entry.Key is not YamlScalarNode keyNode || + keyNode.Value is null || + !properties.TryGetValue(keyNode.Value, out var propertySchema)) + { + throw new InvalidOperationException("Validated object nodes must use declared scalar property names."); + } + + objectEntries.Add( + new KeyValuePair( + keyNode.Value, + BuildComparableNodeValue(entry.Value, propertySchema))); + } + + objectEntries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); + return string.Join( + "|", + objectEntries.Select(static entry => + $"{entry.Key.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Key}={entry.Value.Length.ToString(CultureInfo.InvariantCulture)}:{entry.Value}")); + } + + /// + /// 构建数组节点的可比较键。 + /// 数组仍保留元素顺序,因为 uniqueItems 只忽略对象字段顺序,不忽略数组顺序。 + /// + /// YAML 节点。 + /// 数组 schema 节点。 + /// 数组节点的稳定比较键。 + private static string BuildComparableArrayValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlSequenceNode sequenceNode || + schemaNode.ItemNode is null) + { + throw new InvalidOperationException("Validated array nodes must be YAML sequences with item schema."); + } + + return "[" + + string.Join( + ",", + sequenceNode.Children.Select( + item => + { + var comparableValue = BuildComparableNodeValue(item, schemaNode.ItemNode); + return $"{comparableValue.Length.ToString(CultureInfo.InvariantCulture)}:{comparableValue}"; + })) + + "]"; + } + + /// + /// 构建标量节点的可比较键。 + /// 标量会沿用与 enum / 引用校验一致的归一化规则,避免数字格式和引号形式导致伪差异。 + /// + /// YAML 节点。 + /// 标量 schema 节点。 + /// 标量节点的稳定比较键。 + private static string BuildComparableScalarValue(YamlNode node, YamlConfigSchemaNode schemaNode) + { + if (node is not YamlScalarNode scalarNode || + scalarNode.Value is null) + { + throw new InvalidOperationException("Validated scalar nodes must be YAML scalars."); + } + + var normalizedScalar = NormalizeScalarValue(schemaNode.NodeType, scalarNode.Value); + return $"{schemaNode.NodeType}:{normalizedScalar.Length.ToString(CultureInfo.InvariantCulture)}:{normalizedScalar}"; } /// @@ -1899,37 +2070,98 @@ internal sealed class YamlConfigSchema /// internal sealed class YamlConfigSchemaNode { + private readonly NodeChildren _children; + private readonly NodeValidation _validation; + /// - /// 初始化一个 schema 节点描述。 + /// 创建对象节点描述。 /// - /// 节点类型。 /// 对象属性集合。 /// 对象必填属性集合。 - /// 数组元素节点。 - /// 目标引用表名称。 - /// 标量允许值集合。 - /// 标量范围与长度约束。 - /// 数组元素数量约束。 /// 用于错误信息的 schema 文件路径提示。 - public YamlConfigSchemaNode( - YamlConfigSchemaPropertyType nodeType, + /// 对象节点模型。 + public static YamlConfigSchemaNode CreateObject( IReadOnlyDictionary? properties, IReadOnlyCollection? requiredProperties, - YamlConfigSchemaNode? itemNode, - string? referenceTableName, - IReadOnlyCollection? allowedValues, - YamlConfigScalarConstraints? constraints, + string schemaPathHint) + { + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Object, + new NodeChildren(properties, requiredProperties, itemNode: null), + NodeValidation.None, + schemaPathHint); + } + + /// + /// 创建数组节点描述。 + /// + /// 数组元素节点。 + /// 数组元素数量约束。 + /// 用于错误信息的 schema 文件路径提示。 + /// 数组节点模型。 + public static YamlConfigSchemaNode CreateArray( + YamlConfigSchemaNode itemNode, YamlConfigArrayConstraints? arrayConstraints, string schemaPathHint) { + return new YamlConfigSchemaNode( + YamlConfigSchemaPropertyType.Array, + new NodeChildren(properties: null, requiredProperties: null, itemNode), + new NodeValidation( + referenceTableName: null, + allowedValues: null, + constraints: null, + arrayConstraints), + schemaPathHint); + } + + /// + /// 创建标量节点描述。 + /// + /// 标量节点类型。 + /// 目标引用表名称。 + /// 标量允许值集合。 + /// 标量范围与长度约束。 + /// 用于错误信息的 schema 文件路径提示。 + /// 标量节点模型。 + public static YamlConfigSchemaNode CreateScalar( + YamlConfigSchemaPropertyType nodeType, + string? referenceTableName, + IReadOnlyCollection? allowedValues, + YamlConfigScalarConstraints? constraints, + string schemaPathHint) + { + return new YamlConfigSchemaNode( + nodeType, + NodeChildren.None, + new NodeValidation( + referenceTableName, + allowedValues, + constraints, + arrayConstraints: null), + schemaPathHint); + } + + private YamlConfigSchemaNode( + YamlConfigSchemaPropertyType nodeType, + NodeChildren children, + NodeValidation validation, + string schemaPathHint) + { + ArgumentNullException.ThrowIfNull(children); + ArgumentNullException.ThrowIfNull(validation); + ArgumentNullException.ThrowIfNull(schemaPathHint); + + _children = children; + _validation = validation; NodeType = nodeType; - Properties = properties; - RequiredProperties = requiredProperties; - ItemNode = itemNode; - ReferenceTableName = referenceTableName; - AllowedValues = allowedValues; - Constraints = constraints; - ArrayConstraints = arrayConstraints; + Properties = children.Properties; + RequiredProperties = children.RequiredProperties; + ItemNode = children.ItemNode; + ReferenceTableName = validation.ReferenceTableName; + AllowedValues = validation.AllowedValues; + Constraints = validation.Constraints; + ArrayConstraints = validation.ArrayConstraints; SchemaPathHint = schemaPathHint; } @@ -1989,55 +2221,123 @@ internal sealed class YamlConfigSchemaNode { return new YamlConfigSchemaNode( NodeType, - Properties, - RequiredProperties, - ItemNode, - referenceTableName, - AllowedValues, - Constraints, - ArrayConstraints, + _children, + _validation.WithReferenceTable(referenceTableName), SchemaPathHint); } + + private sealed class NodeChildren + { + public static NodeChildren None { get; } = new(properties: null, requiredProperties: null, itemNode: null); + + public NodeChildren( + IReadOnlyDictionary? properties, + IReadOnlyCollection? requiredProperties, + YamlConfigSchemaNode? itemNode) + { + Properties = properties; + RequiredProperties = requiredProperties; + ItemNode = itemNode; + } + + public IReadOnlyDictionary? Properties { get; } + + public IReadOnlyCollection? RequiredProperties { get; } + + public YamlConfigSchemaNode? ItemNode { get; } + } + + private sealed class NodeValidation + { + public static NodeValidation None { get; } = new( + referenceTableName: null, + allowedValues: null, + constraints: null, + arrayConstraints: null); + + public NodeValidation( + string? referenceTableName, + IReadOnlyCollection? allowedValues, + YamlConfigScalarConstraints? constraints, + YamlConfigArrayConstraints? arrayConstraints) + { + ReferenceTableName = referenceTableName; + AllowedValues = allowedValues; + Constraints = constraints; + ArrayConstraints = arrayConstraints; + } + + public string? ReferenceTableName { get; } + + public IReadOnlyCollection? AllowedValues { get; } + + public YamlConfigScalarConstraints? Constraints { get; } + + public YamlConfigArrayConstraints? ArrayConstraints { get; } + + public NodeValidation WithReferenceTable(string referenceTableName) + { + return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints); + } + } } /// -/// 表示一个标量节点上声明的数值范围、步进或字符串长度约束。 -/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。 +/// 聚合一个标量节点上声明的数值约束与字符串约束。 +/// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。 /// internal sealed class YamlConfigScalarConstraints { /// /// 初始化标量约束模型。 /// + /// 数值约束分组。 + /// 字符串约束分组。 + public YamlConfigScalarConstraints( + YamlConfigNumericConstraints? numericConstraints, + YamlConfigStringConstraints? stringConstraints) + { + NumericConstraints = numericConstraints; + StringConstraints = stringConstraints; + } + + /// + /// 获取数值约束分组。 + /// + public YamlConfigNumericConstraints? NumericConstraints { get; } + + /// + /// 获取字符串约束分组。 + /// + public YamlConfigStringConstraints? StringConstraints { get; } +} + +/// +/// 表示标量节点上声明的数值范围与步进约束。 +/// 该类型只覆盖整数 / 浮点共享的关键字,避免字符串字段继续暴露不相关的成员。 +/// +internal sealed class YamlConfigNumericConstraints +{ + /// + /// 初始化数值约束模型。 + /// /// 最小值约束。 /// 最大值约束。 /// 开区间最小值约束。 /// 开区间最大值约束。 /// 数值步进约束。 - /// 最小长度约束。 - /// 最大长度约束。 - /// 正则模式约束。 - /// 已编译的正则表达式。 - public YamlConfigScalarConstraints( + public YamlConfigNumericConstraints( double? minimum, double? maximum, double? exclusiveMinimum, double? exclusiveMaximum, - double? multipleOf, - int? minLength, - int? maxLength, - string? pattern, - Regex? patternRegex) + double? multipleOf) { Minimum = minimum; Maximum = maximum; ExclusiveMinimum = exclusiveMinimum; ExclusiveMaximum = exclusiveMaximum; MultipleOf = multipleOf; - MinLength = minLength; - MaxLength = maxLength; - Pattern = pattern; - PatternRegex = patternRegex; } /// @@ -2064,6 +2364,32 @@ internal sealed class YamlConfigScalarConstraints /// 获取数值步进约束。 /// public double? MultipleOf { get; } +} + +/// +/// 表示标量节点上声明的字符串长度与模式约束。 +/// 该模型将正则原文与预编译正则绑定保存,保证诊断内容与运行时匹配逻辑保持一致。 +/// +internal sealed class YamlConfigStringConstraints +{ + /// + /// 初始化字符串约束模型。 + /// + /// 最小长度约束。 + /// 最大长度约束。 + /// 正则模式约束原文。 + /// 已编译的正则表达式。 + public YamlConfigStringConstraints( + int? minLength, + int? maxLength, + string? pattern, + Regex? patternRegex) + { + MinLength = minLength; + MaxLength = maxLength; + Pattern = pattern; + PatternRegex = patternRegex; + } /// /// 获取最小长度约束。 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 7b4389d6..327f8c27 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -708,7 +708,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `multipleOf`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;当前按运行时与 JS 共用的浮点容差策略判断十进制步进 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 -- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS 默认分组语义解释,非法模式会在 schema 解析阶段直接报错 +- `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 @@ -807,7 +807,7 @@ var hotReload = loader.EnableHotReload( - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 -- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据 +- 在表单入口中显示 `title / description / default / enum / ref-table / multipleOf / uniqueItems` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 25a49b19..d9d7d0a8 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -6,6 +6,10 @@ const { } = require("./configPath"); const {ValidationMessageKeys} = require("./localizationKeys"); +const IntegerScalarPattern = /^[+-]?\d+$/u; +const NumberScalarPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u; +const BooleanScalarPattern = /^(true|false)$/iu; + /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the @@ -262,11 +266,11 @@ function isScalarCompatible(expectedType, scalarValue) { const value = unquoteScalar(String(scalarValue)); switch (expectedType) { case "integer": - return /^-?\d+$/u.test(value); + return IntegerScalarPattern.test(value); case "number": - return /^-?\d+(?:\.\d+)?$/u.test(value); + return NumberScalarPattern.test(value); case "boolean": - return /^(true|false)$/iu.test(value); + return BooleanScalarPattern.test(value); case "string": return true; default: @@ -407,7 +411,7 @@ function normalizeSchemaBoolean(value) { * @param {unknown} value Raw schema value. * @param {string} displayPath Logical property path used in diagnostics. * @throws {Error} Thrown when the pattern string cannot be compiled. - * @returns {string | undefined} Normalized pattern string. + * @returns {{source: string, regex: RegExp} | undefined} Normalized pattern metadata. */ function normalizeSchemaPattern(value, displayPath) { if (typeof value !== "string") { @@ -415,8 +419,10 @@ function normalizeSchemaPattern(value, displayPath) { } try { - void new RegExp(value); - return value; + return { + source: value, + regex: new RegExp(value, "u") + }; } catch (error) { throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); } @@ -454,24 +460,18 @@ function formatSchemaDefaultValue(value) { } /** - * Test one scalar value against one schema pattern string. + * Test one scalar value against one compiled schema pattern. * * @param {string} scalarValue Scalar value from YAML. - * @param {string | undefined} pattern Schema pattern string. - * @param {string} displayPath Logical property path used in diagnostics. - * @throws {Error} Thrown when the pattern string cannot be compiled. + * @param {RegExp | undefined} patternRegex Compiled schema pattern. * @returns {boolean} True when the value matches or no pattern is declared. */ -function matchesSchemaPattern(scalarValue, pattern, displayPath) { - if (typeof pattern !== "string") { +function matchesSchemaPattern(scalarValue, patternRegex) { + if (!(patternRegex instanceof RegExp)) { return true; } - try { - return new RegExp(pattern).test(scalarValue); - } catch (error) { - throw new Error(`Schema property '${displayPath}' declares an invalid 'pattern' regular expression: ${error.message}`); - } + return patternRegex.test(scalarValue); } /** @@ -500,7 +500,7 @@ function matchesSchemaMultipleOf(scalarValue, multipleOf) { * @returns {string} YAML-ready scalar. */ function formatYamlScalar(value) { - if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) { + if (NumberScalarPattern.test(value) || BooleanScalarPattern.test(value)) { return value; } @@ -536,6 +536,7 @@ function unquoteScalar(value) { function parseSchemaNode(rawNode, displayPath) { const value = rawNode && typeof rawNode === "object" ? rawNode : {}; const type = typeof value.type === "string" ? value.type : "object"; + const patternMetadata = normalizeSchemaPattern(value.pattern, displayPath); const metadata = { title: typeof value.title === "string" ? value.title : undefined, description: typeof value.description === "string" ? value.description : undefined, @@ -547,7 +548,8 @@ function parseSchemaNode(rawNode, displayPath) { multipleOf: normalizeSchemaPositiveNumber(value.multipleOf), minLength: normalizeSchemaNonNegativeInteger(value.minLength), maxLength: normalizeSchemaNonNegativeInteger(value.maxLength), - pattern: normalizeSchemaPattern(value.pattern, displayPath), + pattern: patternMetadata ? patternMetadata.source : undefined, + patternRegex: patternMetadata ? patternMetadata.regex : undefined, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), uniqueItems: normalizeSchemaBoolean(value.uniqueItems), @@ -622,6 +624,9 @@ function parseSchemaNode(rawNode, displayPath) { pattern: type === "string" ? metadata.pattern : undefined, + patternRegex: type === "string" + ? metadata.patternRegex + : undefined, enumValues: normalizeSchemaEnumValues(value.enum), refTable: metadata.refTable }; @@ -675,19 +680,27 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) }); } + const comparableItems = []; for (let index = 0; index < yamlNode.items.length; index += 1) { + const diagnosticsBeforeValidation = diagnostics.length; validateNode( schemaNode.items, yamlNode.items[index], joinArrayIndexPath(displayPath, index), diagnostics, localizer); + + // Keep uniqueItems focused on values that are otherwise valid so a + // shape/type error does not also surface as a misleading duplicate. + if (diagnostics.length === diagnosticsBeforeValidation) { + comparableItems.push({index, node: yamlNode.items[index]}); + } } if (schemaNode.uniqueItems === true) { const seenItems = new Map(); - for (let index = 0; index < yamlNode.items.length; index += 1) { - const comparableValue = buildComparableNodeValue(schemaNode.items, yamlNode.items[index]); + for (const {index, node} of comparableItems) { + const comparableValue = buildComparableNodeValue(schemaNode.items, node); if (seenItems.has(comparableValue)) { diagnostics.push({ severity: "error", @@ -696,7 +709,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) duplicatePath: joinArrayIndexPath(displayPath, seenItems.get(comparableValue)) }) }); - break; + continue; } seenItems.set(comparableValue, index); @@ -830,7 +843,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } if (supportsPatternConstraints && - !matchesSchemaPattern(scalarValue, schemaNode.pattern, schemaNode.displayPath)) { + !matchesSchemaPattern(scalarValue, schemaNode.patternRegex)) { diagnostics.push({ severity: "error", message: localizeValidationMessage(ValidationMessageKeys.patternViolation, localizer, { @@ -920,7 +933,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) { return Object.keys(schemaNode.properties) .filter((key) => yamlNode.map.has(key)) .sort((left, right) => left.localeCompare(right)) - .map((key) => `${key.length}:${key}=${buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key))}`) + .map((key) => { + const valueKey = buildComparableNodeValue(schemaNode.properties[key], yamlNode.map.get(key)); + return `${key.length}:${key}=${valueKey.length}:${valueKey}`; + }) .join("|"); } @@ -929,7 +945,10 @@ function buildComparableNodeValue(schemaNode, yamlNode) { return yamlNode.kind; } - return `[${yamlNode.items.map((item) => buildComparableNodeValue(schemaNode.items, item)).join(",")}]`; + return `[${yamlNode.items.map((item) => { + const valueKey = buildComparableNodeValue(schemaNode.items, item); + return `${valueKey.length}:${valueKey}`; + }).join(",")}]`; } if (yamlNode.kind !== "scalar") { @@ -942,7 +961,7 @@ function buildComparableNodeValue(schemaNode, yamlNode) { : schemaNode.type === "boolean" ? String(/^true$/iu.test(scalarValue)) : scalarValue; - return `${schemaNode.type}:${normalizedScalar}`; + return `${schemaNode.type}:${normalizedScalar.length}:${normalizedScalar}`; } /** @@ -1704,6 +1723,7 @@ module.exports = { * minLength?: number, * maxLength?: number, * pattern?: string, + * patternRegex?: RegExp, * enumValues?: string[], * refTable?: string * }} SchemaNode diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 19020fdf..ea105c9b 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -349,6 +349,135 @@ phases: assert.match(diagnostics[1].message, /phases\[1\]|uniqueItems|元素唯一/u); }); +test("validateParsedConfig should accept scientific-notation numbers", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "dropRate": { + "type": "number" + } + } + } + `); + const yaml = parseTopLevelYaml(` +dropRate: 1.5e10 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + +test("validateParsedConfig should apply schema patterns with Unicode semantics", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^\\\\p{L}+$" + } + } + } + `); + const yaml = parseTopLevelYaml(` +name: 测试 +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + +test("validateParsedConfig should skip uniqueItems checks for invalid array items", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "values": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +values: + - + id: 1 + - + id: 2 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /values\[0\]/u); + assert.match(diagnostics[1].message, /values\[1\]/u); + assert.ok(diagnostics.every((diagnostic) => !/uniqueItems|元素唯一/u.test(diagnostic.message))); +}); + +test("validateParsedConfig should report every uniqueItems duplicate in one pass", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +tags: + - alpha + - beta + - alpha + - beta +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /tags\[2\]/u); + assert.match(diagnostics[1].message, /tags\[3\]/u); +}); + +test("validateParsedConfig should avoid uniqueItems comparable-key collisions for distinct objects", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "entries": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "a": { "type": "string" }, + "b": { "type": "string" } + } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +entries: + - + a: "x|1:b=string:yz" + - + a: x + b: yz +`); + + assert.deepEqual(validateParsedConfig(schema, yaml), []); +}); + test("parseSchemaContent should capture scalar range and length metadata", () => { const schema = parseSchemaContent(` {