diff --git a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs index 2f9f3fdd..54c5eca7 100644 --- a/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.Game.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -1357,9 +1357,19 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator diagnostic = null; var allOfEntryPath = BuildAllOfEntryPath(displayPath, allOfIndex); - if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement) && - allOfPropertiesElement.ValueKind == JsonValueKind.Object) + if (allOfSchema.TryGetProperty("properties", out var allOfPropertiesElement)) { + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'properties' as an object-valued map."); + return false; + } + foreach (var property in allOfPropertiesElement.EnumerateObject()) { if (declaredProperties.Contains(property.Name)) @@ -1377,12 +1387,22 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } - if (!allOfSchema.TryGetProperty("required", out var requiredElement) || - requiredElement.ValueKind != JsonValueKind.Array) + if (!allOfSchema.TryGetProperty("required", out var requiredElement)) { return true; } + if (requiredElement.ValueKind != JsonValueKind.Array) + { + diagnostic = Diagnostic.Create( + ConfigSchemaDiagnostics.InvalidAllOfMetadata, + CreateFileLocation(filePath), + Path.GetFileName(filePath), + allOfEntryPath, + $"Entry #{allOfIndex + 1} in 'allOf' must declare 'required' as an array of parent property names."); + return false; + } + foreach (var requiredProperty in requiredElement.EnumerateArray()) { if (requiredProperty.ValueKind != JsonValueKind.String) diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs index ed2c5e79..2e4edde7 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderAllOfTests.cs @@ -300,6 +300,86 @@ public sealed class YamlConfigLoaderAllOfTests }); } + /// + /// 验证 allOf 条目的 properties 必须声明为对象映射。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Properties_Is_Not_Object_Valued() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "properties": 1 + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'properties' as an object-valued map")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证 allOf 条目的 required 必须声明为字段名数组。 + /// + [Test] + public void LoadAsync_Should_Throw_When_AllOf_Entry_Required_Is_Not_An_Array() + { + CreateConfigFile( + "monster/slime.yaml", + BuildMonsterConfigYaml( + """ + itemCount: 3 + """)); + CreateSchemaFile( + "schemas/monster.schema.json", + BuildMonsterSchema( + DefaultRewardPropertiesJson, + """ + [ + { + "type": "object", + "required": {} + } + ] + """)); + + var loader = CreateMonsterRewardLoader(); + var registry = CreateRegistry(); + + 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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("reward[allOf[0]]")); + Assert.That(exception.Message, Does.Contain("must declare 'required' as an array of property names")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + /// /// 验证 allOf 条目不能要求父对象未声明的字段。 /// diff --git a/GFramework.Game/Config/YamlConfigSchemaValidator.cs b/GFramework.Game/Config/YamlConfigSchemaValidator.cs index 2f642d6d..08edbeb4 100644 --- a/GFramework.Game/Config/YamlConfigSchemaValidator.cs +++ b/GFramework.Game/Config/YamlConfigSchemaValidator.cs @@ -1975,9 +1975,18 @@ internal static class YamlConfigSchemaValidator JsonElement allOfSchemaElement, IReadOnlyDictionary properties) { - if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement) && - allOfPropertiesElement.ValueKind == JsonValueKind.Object) + if (allOfSchemaElement.TryGetProperty("properties", out var allOfPropertiesElement)) { + if (allOfPropertiesElement.ValueKind != JsonValueKind.Object) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'properties' as an object-valued map.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + foreach (var property in allOfPropertiesElement.EnumerateObject()) { if (properties.ContainsKey(property.Name)) @@ -1994,12 +2003,21 @@ internal static class YamlConfigSchemaValidator } } - if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement) || - allOfRequiredElement.ValueKind != JsonValueKind.Array) + if (!allOfSchemaElement.TryGetProperty("required", out var allOfRequiredElement)) { return; } + if (allOfRequiredElement.ValueKind != JsonValueKind.Array) + { + throw ConfigLoadExceptionFactory.Create( + ConfigLoadFailureKind.SchemaUnsupported, + tableName, + $"Entry #{allOfEntryNumber.ToString(CultureInfo.InvariantCulture)} in 'allOf' for {DescribeObjectSchemaTargetInClause(propertyPath)} of schema file '{schemaPath}' must declare 'required' as an array of property names.", + schemaPath: schemaPath, + displayPath: GetDiagnosticPath(allOfSchemaPath)); + } + foreach (var requiredProperty in allOfRequiredElement.EnumerateArray()) { if (requiredProperty.ValueKind != JsonValueKind.String) diff --git a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs index 314df325..030d12f1 100644 --- a/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs +++ b/GFramework.SourceGenerators.Tests/Config/SchemaConfigGeneratorTests.cs @@ -949,6 +949,110 @@ public class SchemaConfigGeneratorTests }); } + /// + /// 验证生成器会拒绝把 allOf.properties 声明为非对象映射。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Properties_Is_Not_Object_Valued() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "properties": 1 + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'properties' as an object-valued map")); + }); + } + + /// + /// 验证生成器会拒绝把 allOf.required 声明为非数组。 + /// + [Test] + public void Run_Should_Report_Diagnostic_When_AllOf_Entry_Required_Is_Not_An_Array() + { + const string source = """ + namespace TestApp + { + public sealed class Dummy + { + } + } + """; + + const string schema = """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "properties": { + "itemCount": { "type": "integer" } + }, + "allOf": [ + { + "type": "object", + "required": {} + } + ] + } + } + } + """; + + var result = SchemaGeneratorTestDriver.Run( + source, + ("monster.schema.json", schema)); + + var diagnostic = result.Results.Single().Diagnostics.Single(); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Id, Is.EqualTo("GF_ConfigSchema_012")); + Assert.That(diagnostic.Severity, Is.EqualTo(DiagnosticSeverity.Error)); + Assert.That(diagnostic.GetMessage(), Does.Contain("reward[allOf[0]]")); + Assert.That(diagnostic.GetMessage(), Does.Contain("must declare 'required' as an array of parent property names")); + }); + } + /// /// 验证生成器会拒绝在 allOf 中引入父对象未声明的字段。 ///