diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 1757e22d..cc088e19 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1248,6 +1248,115 @@ public class YamlConfigLoaderTests }); } + /// + /// 验证对象字段不满足 minProperties 时会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Violates_MinProperties() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + reward: + gold: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "reward"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "reward": { + "type": "object", + "minProperties": 2, + "properties": { + "gold": { "type": "integer" }, + "currency": { "type": "string" } + } + } + } + } + """); + + 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("reward")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("1")); + Assert.That(exception.Message, Does.Contain("at least 2 properties")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象字段不满足 maxProperties 时会在运行时被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Violates_MaxProperties() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + reward: + gold: 10 + currency: coin + tier: epic + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "reward"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "reward": { + "type": "object", + "maxProperties": 2, + "properties": { + "gold": { "type": "integer" }, + "currency": { "type": "string" }, + "tier": { "type": "string" } + } + } + } + } + """); + + 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("reward")); + Assert.That(exception.Diagnostic.RawValue, Is.EqualTo("3")); + Assert.That(exception.Message, Does.Contain("at most 2 properties")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证对象数组中的嵌套字段也会按 schema 递归校验。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 7af85af0..8ed9a213 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -8,8 +8,9 @@ namespace GFramework.Game.Config; /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 /// 该校验器与当前配置生成器、VS Code 工具支持的 schema 子集保持一致, /// 并通过递归遍历方式覆盖嵌套对象、对象数组、标量数组与深层 enum / 引用约束。 -/// 当前共享子集额外支持 multipleOfuniqueItems, -/// 让数值步进和数组去重规则在运行时与生成器 / 工具侧保持一致。 +/// 当前共享子集额外支持 multipleOfuniqueItems、 +/// minPropertiesmaxProperties, +/// 让数值步进、数组去重和对象属性数量规则在运行时与生成器 / 工具侧保持一致。 /// internal static class YamlConfigSchemaValidator { @@ -321,7 +322,11 @@ internal static class YamlConfigSchemaValidator property.Value); } - return YamlConfigSchemaNode.CreateObject(properties, requiredProperties, schemaPath); + return YamlConfigSchemaNode.CreateObject( + properties, + requiredProperties, + ParseObjectConstraints(tableName, schemaPath, propertyPath, element), + schemaPath); } /// @@ -555,6 +560,66 @@ internal static class YamlConfigSchemaValidator schemaPath: schemaNode.SchemaPathHint, displayPath: requiredPath); } + + if (schemaNode.ObjectConstraints is not null) + { + ValidateObjectConstraints(tableName, yamlPath, displayPath, seenProperties.Count, schemaNode); + } + } + + /// + /// 校验对象节点声明的属性数量约束。 + /// + /// 所属配置表名称。 + /// YAML 文件路径。 + /// 对象字段路径;根对象时为空。 + /// 当前对象实际属性数量。 + /// 对象 schema 节点。 + private static void ValidateObjectConstraints( + string tableName, + string yamlPath, + string displayPath, + int propertyCount, + YamlConfigSchemaNode schemaNode) + { + var constraints = schemaNode.ObjectConstraints; + if (constraints is null) + { + return; + } + + var subject = string.IsNullOrWhiteSpace(displayPath) + ? "Root object" + : $"Property '{displayPath}'"; + var rawValue = propertyCount.ToString(CultureInfo.InvariantCulture); + + if (constraints.MinProperties.HasValue && + propertyCount < constraints.MinProperties.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must contain at least {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Minimum property count: {constraints.MinProperties.Value.ToString(CultureInfo.InvariantCulture)}."); + } + + if (constraints.MaxProperties.HasValue && + propertyCount > constraints.MaxProperties.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.ConstraintViolation, + tableName, + $"{subject} in config file '{yamlPath}' must contain at most {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)} properties.", + yamlPath: yamlPath, + schemaPath: schemaNode.SchemaPathHint, + displayPath: GetDiagnosticPath(displayPath), + rawValue: rawValue, + detail: $"Maximum property count: {constraints.MaxProperties.Value.ToString(CultureInfo.InvariantCulture)}."); + } } /// @@ -870,6 +935,48 @@ internal static class YamlConfigSchemaValidator : new YamlConfigArrayConstraints(minItems, maxItems, uniqueItems); } + /// + /// 解析对象节点支持的属性数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 对象约束模型;未声明时返回空。 + private static YamlConfigObjectConstraints? ParseObjectConstraints( + string tableName, + string schemaPath, + string propertyPath, + JsonElement element) + { + var minProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "minProperties"); + var maxProperties = TryParseObjectPropertyCountConstraint( + tableName, + schemaPath, + propertyPath, + element, + "maxProperties"); + + if (minProperties.HasValue && maxProperties.HasValue && minProperties.Value > maxProperties.Value) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Property '{propertyPath}' in schema file '{schemaPath}' declares 'minProperties' greater than 'maxProperties'.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(propertyPath)); + } + + return !minProperties.HasValue && !maxProperties.HasValue + ? null + : new YamlConfigObjectConstraints(minProperties, maxProperties); + } + /// /// 读取数值区间约束。 /// @@ -1100,6 +1207,42 @@ internal static class YamlConfigSchemaValidator return constraintValue; } + /// + /// 读取对象属性数量约束。 + /// + /// 所属配置表名称。 + /// Schema 文件路径。 + /// 对象字段路径。 + /// Schema 节点。 + /// 关键字名称。 + /// 属性数量约束;未声明时返回空。 + private static int? TryParseObjectPropertyCountConstraint( + 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; + } + /// /// 读取数组去重约束。 /// @@ -2201,17 +2344,24 @@ internal sealed class YamlConfigSchemaNode /// /// 对象属性集合。 /// 对象必填属性集合。 + /// 对象属性数量约束。 /// 用于错误信息的 schema 文件路径提示。 /// 对象节点模型。 public static YamlConfigSchemaNode CreateObject( IReadOnlyDictionary? properties, IReadOnlyCollection? requiredProperties, + YamlConfigObjectConstraints? objectConstraints, string schemaPathHint) { return new YamlConfigSchemaNode( YamlConfigSchemaPropertyType.Object, new NodeChildren(properties, requiredProperties, itemNode: null), - NodeValidation.None, + new NodeValidation( + referenceTableName: null, + allowedValues: null, + constraints: null, + arrayConstraints: null, + objectConstraints), schemaPathHint); } @@ -2234,7 +2384,8 @@ internal sealed class YamlConfigSchemaNode referenceTableName: null, allowedValues: null, constraints: null, - arrayConstraints), + arrayConstraints, + objectConstraints: null), schemaPathHint); } @@ -2261,7 +2412,8 @@ internal sealed class YamlConfigSchemaNode referenceTableName, allowedValues, constraints, - arrayConstraints: null), + arrayConstraints: null, + objectConstraints: null), schemaPathHint); } @@ -2285,6 +2437,7 @@ internal sealed class YamlConfigSchemaNode AllowedValues = validation.AllowedValues; Constraints = validation.Constraints; ArrayConstraints = validation.ArrayConstraints; + ObjectConstraints = validation.ObjectConstraints; SchemaPathHint = schemaPathHint; } @@ -2323,6 +2476,11 @@ internal sealed class YamlConfigSchemaNode /// public YamlConfigScalarConstraints? Constraints { get; } + /// + /// 获取对象属性数量约束;未声明时返回空。 + /// + public YamlConfigObjectConstraints? ObjectConstraints { get; } + /// /// 获取数组元素数量约束;未声明时返回空。 /// @@ -2376,18 +2534,21 @@ internal sealed class YamlConfigSchemaNode referenceTableName: null, allowedValues: null, constraints: null, - arrayConstraints: null); + arrayConstraints: null, + objectConstraints: null); public NodeValidation( string? referenceTableName, IReadOnlyCollection? allowedValues, YamlConfigScalarConstraints? constraints, - YamlConfigArrayConstraints? arrayConstraints) + YamlConfigArrayConstraints? arrayConstraints, + YamlConfigObjectConstraints? objectConstraints) { ReferenceTableName = referenceTableName; AllowedValues = allowedValues; Constraints = constraints; ArrayConstraints = arrayConstraints; + ObjectConstraints = objectConstraints; } public string? ReferenceTableName { get; } @@ -2398,13 +2559,44 @@ internal sealed class YamlConfigSchemaNode public YamlConfigArrayConstraints? ArrayConstraints { get; } + public YamlConfigObjectConstraints? ObjectConstraints { get; } + public NodeValidation WithReferenceTable(string referenceTableName) { - return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints); + return new NodeValidation(referenceTableName, AllowedValues, Constraints, ArrayConstraints, + ObjectConstraints); } } } +/// +/// 表示一个对象节点上声明的属性数量约束。 +/// 该模型将对象级约束与数组 / 标量约束拆开保存,避免运行时节点继续暴露无关成员。 +/// +internal sealed class YamlConfigObjectConstraints +{ + /// + /// 初始化对象约束模型。 + /// + /// 最小属性数量约束。 + /// 最大属性数量约束。 + public YamlConfigObjectConstraints(int? minProperties, int? maxProperties) + { + MinProperties = minProperties; + MaxProperties = maxProperties; + } + + /// + /// 获取最小属性数量约束。 + /// + public int? MinProperties { get; } + + /// + /// 获取最大属性数量约束。 + /// + public int? MaxProperties { get; } +} + /// /// 聚合一个标量节点上声明的数值约束与字符串约束。 /// 该包装层保留“标量字段有约束”的统一入口,同时把不同语义的约束分成更小的专用模型。 diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs index dc2a36b5..631740f9 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorSnapshotTests.cs @@ -69,6 +69,8 @@ public class SchemaConfigGeneratorSnapshotTests "title": "Monster Config", "description": "Represents one monster entry generated from schema metadata.", "type": "object", + "minProperties": 4, + "maxProperties": 8, "required": ["id", "name", "reward", "phases"], "properties": { "id": { @@ -113,6 +115,8 @@ public class SchemaConfigGeneratorSnapshotTests "reward": { "type": "object", "description": "Reward payload.", + "minProperties": 2, + "maxProperties": 2, "required": ["gold", "currency"], "properties": { "gold": { diff --git a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt index d5b5f909..c06078eb 100644 --- a/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt +++ b/GFramework.SourceGenerators.Tests/Config/snapshots/SchemaConfigGenerator/MonsterConfig.g.txt @@ -7,6 +7,9 @@ namespace GFramework.Game.Config.Generated; /// Auto-generated config type for schema file 'monster.schema.json'. /// Represents one monster entry generated from schema metadata. /// +/// +/// Constraints: minProperties = 4, maxProperties = 8. +/// public sealed partial class MonsterConfig { /// @@ -74,6 +77,9 @@ public sealed partial class MonsterConfig /// Auto-generated nested config type for schema property path 'reward'. /// Reward payload. /// + /// + /// Constraints: minProperties = 2, maxProperties = 2. + /// public sealed partial class RewardConfig { /// @@ -124,4 +130,4 @@ public sealed partial class MonsterConfig public string MonsterId { get; set; } = string.Empty; } -} +} \ No newline at end of file diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 5890cc1f..9cf5d672 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -6,7 +6,8 @@ namespace GFramework.SourceGenerators.Config; /// 根据 AdditionalFiles 中的 JSON schema 生成配置类型和配置表包装。 /// 当前实现聚焦 AI-First 配置系统共享的最小 schema 子集, /// 支持嵌套对象、对象数组、标量数组,以及可映射的 default / enum / ref-table 元数据。 -/// 当前共享子集也会把 multipleOfuniqueItems 写入生成代码文档, +/// 当前共享子集也会把 multipleOfuniqueItems、 +/// minPropertiesmaxProperties 写入生成代码文档, /// 让消费者能直接在强类型 API 上看到运行时生效的约束。 /// [Generator] @@ -258,6 +259,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator className, TryGetMetadataString(element, "title"), TryGetMetadataString(element, "description"), + TryBuildConstraintDocumentation(element, "object"), properties)); } @@ -1876,6 +1878,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } builder.AppendLine($"{indent}/// "); + if (!string.IsNullOrWhiteSpace(objectSpec.ConstraintDocumentation)) + { + builder.AppendLine($"{indent}/// "); + builder.AppendLine( + $"{indent}/// Constraints: {EscapeXmlDocumentation(objectSpec.ConstraintDocumentation!)}."); + builder.AppendLine($"{indent}/// "); + } + builder.AppendLine($"{indent}public sealed partial class {objectSpec.ClassName}"); builder.AppendLine($"{indent}{{"); @@ -2432,7 +2442,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } /// - /// 将 shared schema 子集中的范围、步进、长度、模式与数组数量 / 去重约束整理成 XML 文档可读字符串。 + /// 将 shared schema 子集中的范围、步进、长度、数组数量 / 去重与对象属性数量约束整理成 XML 文档可读字符串。 /// /// Schema 节点。 /// 标量类型。 @@ -2510,6 +2520,18 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator parts.Add("uniqueItems = true"); } + if (schemaType == "object" && + TryGetNonNegativeInt32(element, "minProperties", out var minProperties)) + { + parts.Add($"minProperties = {minProperties.ToString(CultureInfo.InvariantCulture)}"); + } + + if (schemaType == "object" && + TryGetNonNegativeInt32(element, "maxProperties", out var maxProperties)) + { + parts.Add($"maxProperties = {maxProperties.ToString(CultureInfo.InvariantCulture)}"); + } + return parts.Count > 0 ? string.Join(", ", parts) : null; } @@ -2654,12 +2676,14 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator /// 要生成的 CLR 类型名。 /// 对象标题元数据。 /// 对象描述元数据。 + /// 对象约束说明。 /// 对象属性集合。 private sealed record SchemaObjectSpec( string DisplayPath, string ClassName, string? Title, string? Description, + string? ConstraintDocumentation, IReadOnlyList Properties); /// diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index e156bfd2..809bb205 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`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems` +- Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`multipleOf`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems`、`uniqueItems`、`minProperties`、`maxProperties` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 @@ -657,6 +657,7 @@ var loader = new YamlConfigLoader("config-root") - 字符串字段违反 `pattern` - 数组字段违反 `minItems` / `maxItems` - 数组字段违反 `uniqueItems` +- 对象字段违反 `minProperties` / `maxProperties` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 @@ -711,6 +712,7 @@ if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var re - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用;当前按 C# `CultureInvariant` 与 JS Unicode `u` 模式解释,非法模式会在 schema 解析阶段直接报错 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `uniqueItems`:供运行时校验、VS Code 校验、表单 hint 和生成代码 XML 文档复用;对象数组会按 schema 归一化后的结构比较重复项,而不是依赖 YAML 字段顺序 +- `minProperties` / `maxProperties`:供运行时校验、VS Code 校验、对象 section 表单 hint 和生成代码 XML 文档复用;根对象与嵌套对象都会按实际属性数量执行同一套约束 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 @@ -807,7 +809,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 / minProperties / maxProperties` 元数据;批量编辑入口当前只暴露顶层可批量改写字段所需的基础信息 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 39dc7786..9c339f17 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -20,7 +20,9 @@ const BooleanScalarPattern = /^(true|false)$/iu; * @returns {{ * type: "object", * required: string[], - * properties: Record + * properties: Record, + * minProperties?: number, + * maxProperties?: number * }} Parsed schema info. */ function parseSchemaContent(content) { @@ -639,6 +641,8 @@ function parseSchemaNode(rawNode, displayPath) { patternRegex: patternMetadata ? patternMetadata.regex : undefined, minItems: normalizeSchemaNonNegativeInteger(value.minItems), maxItems: normalizeSchemaNonNegativeInteger(value.maxItems), + minProperties: normalizeSchemaNonNegativeInteger(value.minProperties), + maxProperties: normalizeSchemaNonNegativeInteger(value.maxProperties), uniqueItems: normalizeSchemaBoolean(value.uniqueItems), refTable: typeof value["x-gframework-ref-table"] === "string" ? value["x-gframework-ref-table"] @@ -659,6 +663,8 @@ function parseSchemaNode(rawNode, displayPath) { displayPath, required, properties, + minProperties: metadata.minProperties, + maxProperties: metadata.maxProperties, title: metadata.title, description: metadata.description, defaultValue: metadata.defaultValue @@ -969,6 +975,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca return; } + const propertyCount = Array.isArray(yamlNode.entries) ? yamlNode.entries.length : 0; + for (const requiredProperty of schemaNode.required) { if (!yamlNode.map.has(requiredProperty)) { diagnostics.push({ @@ -998,6 +1006,28 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca diagnostics, localizer); } + + if (typeof schemaNode.minProperties === "number" && + propertyCount < schemaNode.minProperties) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.minPropertiesViolation, localizer, { + displayPath, + value: String(schemaNode.minProperties) + }) + }); + } + + if (typeof schemaNode.maxProperties === "number" && + propertyCount > schemaNode.maxProperties) { + diagnostics.push({ + severity: "error", + message: localizeValidationMessage(ValidationMessageKeys.maxPropertiesViolation, localizer, { + displayPath, + value: String(schemaNode.maxProperties) + }) + }); + } } /** @@ -1060,6 +1090,22 @@ function buildComparableNodeValue(schemaNode, yamlNode) { * @returns {string} Localized validation message. */ function localizeValidationMessage(key, localizer, params) { + if (key === ValidationMessageKeys.minPropertiesViolation) { + return formatObjectPropertyCountMessage( + params.displayPath, + params.value, + "min", + Boolean(localizer && localizer.isChinese)); + } + + if (key === ValidationMessageKeys.maxPropertiesViolation) { + return formatObjectPropertyCountMessage( + params.displayPath, + params.value, + "max", + Boolean(localizer && localizer.isChinese)); + } + if (localizer && typeof localizer.t === "function") { return localizer.t(key, params); } @@ -1084,6 +1130,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”最多只能包含 ${params.value} 个元素。`; case ValidationMessageKeys.maxLengthViolation: return `属性“${params.displayPath}”长度必须不超过 ${params.value} 个字符。`; + case ValidationMessageKeys.maxPropertiesViolation: + return formatObjectPropertyCountMessage(params.displayPath, params.value, "max", true); case ValidationMessageKeys.minimumViolation: return `属性“${params.displayPath}”必须大于或等于 ${params.value}。`; case ValidationMessageKeys.multipleOfViolation: @@ -1092,6 +1140,8 @@ function localizeValidationMessage(key, localizer, params) { return `属性“${params.displayPath}”至少需要包含 ${params.value} 个元素。`; case ValidationMessageKeys.minLengthViolation: return `属性“${params.displayPath}”长度必须至少为 ${params.value} 个字符。`; + case ValidationMessageKeys.minPropertiesViolation: + return formatObjectPropertyCountMessage(params.displayPath, params.value, "min", true); case ValidationMessageKeys.patternViolation: return `属性“${params.displayPath}”必须匹配正则模式“${params.value}”。`; case ValidationMessageKeys.uniqueItemsViolation: @@ -1126,6 +1176,8 @@ function localizeValidationMessage(key, localizer, params) { 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.maxPropertiesViolation: + return formatObjectPropertyCountMessage(params.displayPath, params.value, "max", false); case ValidationMessageKeys.minimumViolation: return `Property '${params.displayPath}' must be greater than or equal to ${params.value}.`; case ValidationMessageKeys.multipleOfViolation: @@ -1134,6 +1186,8 @@ function localizeValidationMessage(key, localizer, params) { 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.minPropertiesViolation: + return formatObjectPropertyCountMessage(params.displayPath, params.value, "min", false); case ValidationMessageKeys.patternViolation: return `Property '${params.displayPath}' must match pattern '${params.value}'.`; case ValidationMessageKeys.uniqueItemsViolation: @@ -1149,6 +1203,40 @@ function localizeValidationMessage(key, localizer, params) { } } +/** + * Format one object-property-count validation message. + * + * @param {string} displayPath Logical object path, or empty for the root object. + * @param {string} value Constraint value. + * @param {"min" | "max"} mode Whether the message describes a minimum or maximum. + * @param {boolean} isChinese Whether Chinese text should be produced. + * @returns {string} Formatted message. + */ +function formatObjectPropertyCountMessage(displayPath, value, mode, isChinese) { + const isRoot = !displayPath; + if (isChinese) { + if (mode === "min") { + return isRoot + ? `根对象至少需要包含 ${value} 个属性。` + : `对象属性“${displayPath}”至少需要包含 ${value} 个子属性。`; + } + + return isRoot + ? `根对象最多只能包含 ${value} 个属性。` + : `对象属性“${displayPath}”最多只能包含 ${value} 个子属性。`; + } + + if (mode === "min") { + return isRoot + ? `Root object must contain at least ${value} properties.` + : `Property '${displayPath}' must contain at least ${value} properties.`; + } + + return isRoot + ? `Root object must contain at most ${value} properties.` + : `Property '${displayPath}' must contain at most ${value} properties.`; +} + /** * Tokenize YAML lines into indentation-aware units. * @@ -1782,6 +1870,8 @@ module.exports = { * displayPath: string, * required: string[], * properties: Record, + * minProperties?: number, + * maxProperties?: number, * title?: string, * description?: string, * defaultValue?: string diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index c78c8aa4..02519140 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -1095,6 +1095,7 @@ function renderFormField(field) {
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)} ${field.description ? `${escapeHtml(field.description)}` : ""} + ${field.schema ? renderFieldHint(field.schema, false, false) : ""} ${renderCommentEditor(field)} `; @@ -1302,6 +1303,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns path: propertyPath, label, description: propertySchema.description, + schema: propertySchema, comment: commentLookup[propertyPath] || "", required: requiredSet.has(key), depth @@ -1468,6 +1470,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa displayPath: itemDisplayPath, label, description: propertySchema.description, + schema: propertySchema, comment: commentLookup[itemDisplayPath] || "", required: requiredSet.has(key), depth @@ -1574,14 +1577,15 @@ function getScalarArrayValue(yamlNode) { /** * Render human-facing metadata hints for one schema field. * - * @param {{description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. + * @param {{type?: string, description?: string, defaultValue?: string, minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string, minItems?: number, maxItems?: number, minProperties?: number, maxProperties?: number, uniqueItems?: boolean, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, exclusiveMinimum?: number, maximum?: number, exclusiveMaximum?: number, multipleOf?: number, minLength?: number, maxLength?: number, pattern?: string}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. + * @param {boolean} includeDescription Whether description text should be included in the hint output. * @returns {string} HTML fragment. */ -function renderFieldHint(propertySchema, isArrayField) { +function renderFieldHint(propertySchema, isArrayField, includeDescription = true) { const hints = []; - if (propertySchema.description) { + if (includeDescription && propertySchema.description) { hints.push(escapeHtml(propertySchema.description)); } @@ -1630,6 +1634,14 @@ function renderFieldHint(propertySchema, isArrayField) { hints.push(escapeHtml(localizer.t("webview.hint.pattern", {value: propertySchema.pattern}))); } + if (propertySchema.type === "object" && typeof propertySchema.minProperties === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.minProperties", {value: propertySchema.minProperties}))); + } + + if (propertySchema.type === "object" && typeof propertySchema.maxProperties === "number") { + hints.push(escapeHtml(localizer.t("webview.hint.maxProperties", {value: propertySchema.maxProperties}))); + } + if (isArrayField && typeof propertySchema.minItems === "number") { hints.push(escapeHtml(localizer.t("webview.hint.minItems", {value: propertySchema.minItems}))); } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index f58bddf2..23aaf326 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -124,6 +124,8 @@ const enMessages = { "webview.hint.itemMinLength": "Item min length: {value}", "webview.hint.itemMaxLength": "Item max length: {value}", "webview.hint.itemPattern": "Item pattern: {value}", + "webview.hint.minProperties": "Min properties: {value}", + "webview.hint.maxProperties": "Max properties: {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.", @@ -134,10 +136,12 @@ const enMessages = { [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.maxPropertiesViolation]: "Property '{displayPath}' must contain at most {value} properties.", [ValidationMessageKeys.minimumViolation]: "Property '{displayPath}' must be greater than or equal to {value}.", [ValidationMessageKeys.multipleOfViolation]: "Property '{displayPath}' must be a multiple of {value}.", [ValidationMessageKeys.minItemsViolation]: "Property '{displayPath}' must contain at least {value} items.", [ValidationMessageKeys.minLengthViolation]: "Property '{displayPath}' must be at least {value} characters long.", + [ValidationMessageKeys.minPropertiesViolation]: "Property '{displayPath}' must contain at least {value} properties.", [ValidationMessageKeys.patternViolation]: "Property '{displayPath}' must match pattern '{value}'.", [ValidationMessageKeys.uniqueItemsViolation]: "Property '{displayPath}' duplicates earlier array item '{duplicatePath}', but uniqueItems is required.", [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", @@ -228,6 +232,8 @@ const zhCnMessages = { "webview.hint.itemMinLength": "元素最小长度:{value}", "webview.hint.itemMaxLength": "元素最大长度:{value}", "webview.hint.itemPattern": "元素正则模式:{value}", + "webview.hint.minProperties": "最少属性数:{value}", + "webview.hint.maxProperties": "最多属性数:{value}", "webview.hint.refTable": "引用表:{refTable}", "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", @@ -238,10 +244,12 @@ const zhCnMessages = { [ValidationMessageKeys.maximumViolation]: "属性“{displayPath}”必须小于或等于 {value}。", [ValidationMessageKeys.maxItemsViolation]: "属性“{displayPath}”最多只能包含 {value} 个元素。", [ValidationMessageKeys.maxLengthViolation]: "属性“{displayPath}”长度必须不超过 {value} 个字符。", + [ValidationMessageKeys.maxPropertiesViolation]: "对象属性“{displayPath}”最多只能包含 {value} 个子属性。", [ValidationMessageKeys.minimumViolation]: "属性“{displayPath}”必须大于或等于 {value}。", [ValidationMessageKeys.multipleOfViolation]: "属性“{displayPath}”必须是 {value} 的整数倍。", [ValidationMessageKeys.minItemsViolation]: "属性“{displayPath}”至少需要包含 {value} 个元素。", [ValidationMessageKeys.minLengthViolation]: "属性“{displayPath}”长度必须至少为 {value} 个字符。", + [ValidationMessageKeys.minPropertiesViolation]: "对象属性“{displayPath}”至少需要包含 {value} 个子属性。", [ValidationMessageKeys.patternViolation]: "属性“{displayPath}”必须匹配正则模式“{value}”。", [ValidationMessageKeys.uniqueItemsViolation]: "属性“{displayPath}”与更早的数组元素“{duplicatePath}”重复;该数组要求元素唯一。", [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js index 3e315ff9..622743ce 100644 --- a/tools/gframework-config-tool/src/localizationKeys.js +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -9,10 +9,12 @@ const ValidationMessageKeys = Object.freeze({ maximumViolation: "validation.maximumViolation", maxItemsViolation: "validation.maxItemsViolation", maxLengthViolation: "validation.maxLengthViolation", + maxPropertiesViolation: "validation.maxPropertiesViolation", minimumViolation: "validation.minimumViolation", multipleOfViolation: "validation.multipleOfViolation", minItemsViolation: "validation.minItemsViolation", minLengthViolation: "validation.minLengthViolation", + minPropertiesViolation: "validation.minPropertiesViolation", missingRequired: "validation.missingRequired", patternViolation: "validation.patternViolation", uniqueItemsViolation: "validation.uniqueItemsViolation", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 3b793650..e8b1518e 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -308,6 +308,41 @@ tags: assert.match(diagnostics[1].message, /at most 3 items|最多只能包含 3 个元素/u); }); +test("validateParsedConfig should report object property-count mismatches", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "minProperties": 2, + "maxProperties": 3, + "properties": { + "reward": { + "type": "object", + "minProperties": 2, + "maxProperties": 2, + "properties": { + "gold": { "type": "integer" }, + "currency": { "type": "string" }, + "tier": { "type": "string" } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + gold: 10 + currency: coin + tier: epic +`); + + const diagnostics = validateParsedConfig(schema, yaml); + const messages = diagnostics.map((diagnostic) => diagnostic.message); + + assert.equal(diagnostics.length, 2); + assert.ok(messages.some((message) => /at least 2 properties|至少需要包含 2 个属性/u.test(message))); + assert.ok(messages.some((message) => /reward.*at most 2 properties|reward.*最多只能包含 2 个子属性/u.test(message))); +}); + test("validateParsedConfig should report multipleOf and uniqueItems violations", () => { const schema = parseSchemaContent(` { @@ -615,6 +650,31 @@ test("parseSchemaContent should capture multipleOf and uniqueItems metadata", () assert.equal(schema.properties.dropRates.items.multipleOf, 0.5); }); +test("parseSchemaContent should capture object property-count metadata", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "minProperties": 2, + "maxProperties": 4, + "properties": { + "reward": { + "type": "object", + "minProperties": 1, + "maxProperties": 2, + "properties": { + "gold": { "type": "integer" } + } + } + } + } + `); + + assert.equal(schema.minProperties, 2); + assert.equal(schema.maxProperties, 4); + assert.equal(schema.properties.reward.minProperties, 1); + assert.equal(schema.properties.reward.maxProperties, 2); +}); + test("parseSchemaContent should reject invalid pattern declarations instead of dropping them", () => { assert.throws( () => parseSchemaContent(`