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