mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
Merge pull request #170 from GeWuYou/test/game-integration-tests
Test/game integration tests
This commit is contained in:
commit
ec5153f452
@ -77,6 +77,11 @@ public enum ConfigLoadFailureKind
|
||||
/// </summary>
|
||||
EnumValueNotAllowed,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 标量值违反了 schema 声明的最小值、最大值或长度约束。
|
||||
/// </summary>
|
||||
ConstraintViolation,
|
||||
|
||||
/// <summary>
|
||||
/// YAML 可被读取,但无法成功反序列化到目标 CLR 类型。
|
||||
/// </summary>
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
using System.IO;
|
||||
using GFramework.Game.Config;
|
||||
using GFramework.Game.Config.Generated;
|
||||
|
||||
namespace GFramework.Game.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证消费者项目通过 `schemas/**/*.schema.json` 自动拾取 schema 后,
|
||||
/// 可以直接编译并使用生成的注册辅助、强类型访问入口与运行时加载链路。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GeneratedConfigConsumerIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 为每个端到端测试准备独立的配置根目录,避免编译期 schema 资产与运行时写入互相污染。
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.GeneratedConfigTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理测试过程中创建的临时消费者目录。
|
||||
/// </summary>
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_rootPath))
|
||||
{
|
||||
Directory.Delete(_rootPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
private string _rootPath = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 验证生成器自动拾取消费者项目的 schema 后,
|
||||
/// 可以用生成的注册辅助完成加载,并通过强类型表包装访问运行时数据。
|
||||
/// </summary>
|
||||
[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" }));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在临时消费者根目录中创建测试文件。
|
||||
/// </summary>
|
||||
/// <param name="relativePath">相对根目录的文件路径。</param>
|
||||
/// <param name="content">要写入的文件内容。</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -301,6 +301,104 @@ public class YamlConfigLoaderTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证数值最小值与最大值约束会在运行时被统一拒绝。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证字符串最小长度与最大长度约束会在运行时被统一拒绝。
|
||||
/// </summary>
|
||||
[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<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
|
||||
static config => config.Id);
|
||||
var registry = new ConfigRegistry();
|
||||
|
||||
var exception = Assert.ThrowsAsync<ConfigLoadException>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
|
||||
/// </summary>
|
||||
|
||||
@ -19,6 +19,21 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GFramework.Game\GFramework.Game.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"/>
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"/>
|
||||
<ProjectReference Include="..\GFramework.SourceGenerators\GFramework.SourceGenerators.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
通过仓库内的 targets 复用消费者默认约定,确保测试项目与真实消费项目一样
|
||||
自动拾取 schemas/**/*.schema.json 作为 Source Generator 的 AdditionalFiles。
|
||||
-->
|
||||
<Import Project="..\GFramework.SourceGenerators\GeWuYou.GFramework.SourceGenerators.targets"/>
|
||||
|
||||
</Project>
|
||||
|
||||
24
GFramework.Game.Tests/schemas/monster.schema.json
Normal file
24
GFramework.Game.Tests/schemas/monster.schema.json
Normal file
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析标量字段支持的范围与长度约束。
|
||||
/// 当前共享子集只支持 `integer/number` 上的 `minimum/maximum` 和 `string` 上的 `minLength/maxLength`。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="nodeType">标量类型。</param>
|
||||
/// <returns>解析后的约束模型;未声明时返回空。</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取数值区间约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="nodeType">字段类型。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <returns>数值约束;未声明时返回空。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取字符串长度约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="schemaPath">Schema 文件路径。</param>
|
||||
/// <param name="propertyPath">字段路径。</param>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="nodeType">字段类型。</param>
|
||||
/// <param name="keywordName">关键字名称。</param>
|
||||
/// <returns>长度约束;未声明时返回空。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验标量值是否满足范围与长度约束。
|
||||
/// </summary>
|
||||
/// <param name="tableName">所属配置表名称。</param>
|
||||
/// <param name="yamlPath">YAML 文件路径。</param>
|
||||
/// <param name="displayPath">字段路径。</param>
|
||||
/// <param name="rawValue">原始 YAML 标量值。</param>
|
||||
/// <param name="normalizedValue">归一化后的比较值。</param>
|
||||
/// <param name="schemaNode">标量 schema 节点。</param>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析跨表引用目标表名称。
|
||||
/// </summary>
|
||||
@ -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
|
||||
/// <param name="itemNode">数组元素节点。</param>
|
||||
/// <param name="referenceTableName">目标引用表名称。</param>
|
||||
/// <param name="allowedValues">标量允许值集合。</param>
|
||||
/// <param name="constraints">标量范围与长度约束。</param>
|
||||
/// <param name="schemaPathHint">用于错误信息的 schema 文件路径提示。</param>
|
||||
public YamlConfigSchemaNode(
|
||||
YamlConfigSchemaPropertyType nodeType,
|
||||
@ -1045,6 +1331,7 @@ internal sealed class YamlConfigSchemaNode
|
||||
YamlConfigSchemaNode? itemNode,
|
||||
string? referenceTableName,
|
||||
IReadOnlyCollection<string>? 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
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string>? AllowedValues { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取标量范围与长度约束;未声明时返回空。
|
||||
/// </summary>
|
||||
public YamlConfigScalarConstraints? Constraints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于诊断显示的 schema 路径提示。
|
||||
/// 当前节点本身不记录独立路径,因此对象校验会回退到所属根 schema 路径。
|
||||
@ -1107,10 +1400,57 @@ internal sealed class YamlConfigSchemaNode
|
||||
ItemNode,
|
||||
referenceTableName,
|
||||
AllowedValues,
|
||||
Constraints,
|
||||
SchemaPathHint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个标量节点上声明的数值范围或字符串长度约束。
|
||||
/// 该模型让运行时、热重载和跨文件诊断都能复用同一份最小约束信息。
|
||||
/// </summary>
|
||||
internal sealed class YamlConfigScalarConstraints
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化标量约束模型。
|
||||
/// </summary>
|
||||
/// <param name="minimum">最小值约束。</param>
|
||||
/// <param name="maximum">最大值约束。</param>
|
||||
/// <param name="minLength">最小长度约束。</param>
|
||||
/// <param name="maxLength">最大长度约束。</param>
|
||||
public YamlConfigScalarConstraints(
|
||||
double? minimum,
|
||||
double? maximum,
|
||||
int? minLength,
|
||||
int? maxLength)
|
||||
{
|
||||
Minimum = minimum;
|
||||
Maximum = maximum;
|
||||
MinLength = minLength;
|
||||
MaxLength = maxLength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取最小值约束。
|
||||
/// </summary>
|
||||
public double? Minimum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取最大值约束。
|
||||
/// </summary>
|
||||
public double? Maximum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取最小长度约束。
|
||||
/// </summary>
|
||||
public int? MinLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取最大长度约束。
|
||||
/// </summary>
|
||||
public int? MaxLength { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示单个 YAML 文件中提取出的跨表引用。
|
||||
/// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
/// </remarks>
|
||||
public string Name { get; set; } = "Slime";
|
||||
@ -33,6 +34,7 @@ public sealed partial class MonsterConfig
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Schema property path: 'hp'.
|
||||
/// Constraints: minimum = 1, maximum = 999.
|
||||
/// Generated default initializer: = 10;
|
||||
/// </remarks>
|
||||
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" };
|
||||
/// </remarks>
|
||||
public global::System.Collections.Generic.IReadOnlyList<string> DropItems { get; set; } = new string[] { "potion" };
|
||||
@ -77,6 +80,7 @@ public sealed partial class MonsterConfig
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Schema property path: 'reward.gold'.
|
||||
/// Constraints: minimum = 0.
|
||||
/// Generated default initializer: = 10;
|
||||
/// </remarks>
|
||||
public int Gold { get; set; } = 10;
|
||||
@ -112,6 +116,7 @@ public sealed partial class MonsterConfig
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Schema property path: 'phases[].monsterId'.
|
||||
/// Constraints: minLength = 2, maxLength = 32.
|
||||
/// Generated default initializer: = string.Empty;
|
||||
/// </remarks>
|
||||
public string MonsterId { get; set; } = string.Empty;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 shared schema 子集中的范围与长度约束整理成 XML 文档可读字符串。
|
||||
/// </summary>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="schemaType">标量类型。</param>
|
||||
/// <returns>格式化后的约束说明。</returns>
|
||||
private static string? TryBuildConstraintDocumentation(JsonElement element, string schemaType)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取有限数值元数据。
|
||||
/// </summary>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="propertyName">元数据名称。</param>
|
||||
/// <param name="value">读取到的数值。</param>
|
||||
/// <returns>是否读取成功。</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取非负整数元数据。
|
||||
/// </summary>
|
||||
/// <param name="element">Schema 节点。</param>
|
||||
/// <param name="propertyName">元数据名称。</param>
|
||||
/// <param name="value">读取到的整数值。</param>
|
||||
/// <returns>是否读取成功。</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组合逻辑字段路径。
|
||||
/// </summary>
|
||||
@ -1221,6 +1320,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
/// <param name="ClrType">CLR 类型名。</param>
|
||||
/// <param name="Initializer">属性初始化器。</param>
|
||||
/// <param name="EnumDocumentation">枚举文档说明。</param>
|
||||
/// <param name="ConstraintDocumentation">范围或长度约束说明。</param>
|
||||
/// <param name="RefTableName">目标引用表名称。</param>
|
||||
/// <param name="NestedObject">对象节点对应的嵌套类型。</param>
|
||||
/// <param name="ItemTypeSpec">数组元素类型模型。</param>
|
||||
@ -1230,6 +1330,7 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator
|
||||
string ClrType,
|
||||
string? Initializer,
|
||||
string? EnumDocumentation,
|
||||
string? ConstraintDocumentation,
|
||||
string? RefTableName,
|
||||
SchemaObjectSpec? NestedObject,
|
||||
SchemaTypeSpec? ItemTypeSpec);
|
||||
|
||||
@ -12,9 +12,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"/>
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"/>
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"/>
|
||||
<!--
|
||||
仅在 NuGet 打包布局存在时自动注入 analyzer。
|
||||
仓库内项目引用场景会通过 ProjectReference(OutputItemType=Analyzer) 提供生成器,
|
||||
因此这里需要避免对不存在的打包路径做无效引用。
|
||||
-->
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll"
|
||||
Condition="Exists('$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.dll')"/>
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll"
|
||||
Condition="Exists('$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Abstractions.dll')"/>
|
||||
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll"
|
||||
Condition="Exists('$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/GFramework.SourceGenerators.Common.dll')"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/$(GFrameworkConfigSchemaDirectory)')">
|
||||
|
||||
@ -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` 静默吞掉。
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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})));
|
||||
}
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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"
|
||||
});
|
||||
|
||||
@ -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(`
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user