diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs new file mode 100644 index 00000000..7d58b0db --- /dev/null +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs @@ -0,0 +1,376 @@ +using System.IO; +using GFramework.Game.Abstractions.Config; +using GFramework.Game.Config; + +namespace GFramework.Game.Tests.Config; + +/// +/// 验证 YAML 配置加载器对 not 约束的运行时行为。 +/// +[TestFixture] +public sealed class YamlConfigLoaderNegationTests +{ + private string _rootPath = null!; + + /// + /// 为每个测试创建隔离的临时目录,避免不同 not 用例互相污染。 + /// + [SetUp] + public void SetUp() + { + _rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootPath); + } + + /// + /// 清理当前测试创建的临时目录,避免本地文件残留影响后续执行。 + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, true); + } + } + + /// + /// 验证运行时会拒绝命中 not 子 schema 的标量值。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Value_Matches_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Deprecated + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + }, + "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.Message, Does.Contain("must not match the 'not' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证值未命中 not 子 schema 时,加载器不会误报禁用约束。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Value_Does_Not_Match_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": { + "type": "string", + "const": "Deprecated" + } + }, + "hp": { "type": "integer" } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); + Assert.That(table.Get(1).Hp, Is.EqualTo(10)); + }); + } + + /// + /// 验证对象完整命中禁用 schema 时,同样会触发 not 约束失败。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Object_Fully_Matches_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "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("reward")); + Assert.That(exception.Message, Does.Contain("must not match the 'not' schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 验证对象仅命中 not schema 的属性子集时,不会被误判为完整命中。 + /// + [Test] + public async Task LoadAsync_Should_Accept_When_Object_Does_Not_Strictly_Match_Not_Schema() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + reward: + gold: 10 + bonus: 5 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "reward"], + "properties": { + "id": { "type": "integer" }, + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + """); + + var loader = new YamlConfigLoader(_rootPath) + .RegisterTable("monster", "monster", "schemas/monster.schema.json", + static config => config.Id); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var table = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(table.Count, Is.EqualTo(1)); + Assert.That(table.Get(1).Reward.Gold, Is.EqualTo(10)); + Assert.That(table.Get(1).Reward.Bonus, Is.EqualTo(5)); + }); + } + + /// + /// 验证 schema 将 not 声明为非对象值时,会在解析阶段被拒绝。 + /// + [Test] + public void LoadAsync_Should_Throw_When_Not_Is_Not_An_Object() + { + CreateConfigFile( + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + CreateSchemaFile( + "schemas/monster.schema.json", + """ + { + "type": "object", + "required": ["id", "name", "hp"], + "properties": { + "id": { "type": "integer" }, + "name": { + "type": "string", + "not": "deprecated" + }, + "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.SchemaUnsupported)); + Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("must declare 'not' as an object-valued schema")); + Assert.That(registry.Count, Is.EqualTo(0)); + }); + } + + /// + /// 在测试目录下写入配置文件,并自动创建缺失目录。 + /// + /// 相对根目录的配置文件路径。 + /// 要写入的 YAML 或 schema 内容。 + private void CreateConfigFile(string relativePath, string content) + { + var filePath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var directoryPath = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + File.WriteAllText(filePath, content); + } + + /// + /// 写入测试 schema 文件,复用通用配置文件创建逻辑。 + /// + /// schema 相对路径。 + /// schema JSON 内容。 + private void CreateSchemaFile(string relativePath, string content) + { + CreateConfigFile(relativePath, content); + } + + /// + /// 用于标量 not 回归测试的最小配置类型。 + /// + private sealed class MonsterConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 获取或设置生命值。 + /// + public int Hp { get; set; } + } + + /// + /// 用于对象 not 回归测试的最小配置类型。 + /// + private sealed class MonsterRewardConfigStub + { + /// + /// 获取或设置主键。 + /// + public int Id { get; set; } + + /// + /// 获取或设置奖励对象。 + /// + public RewardConfigStub Reward { get; set; } = new(); + } + + /// + /// 表示对象 not 回归测试中的奖励节点。 + /// + private sealed class RewardConfigStub + { + /// + /// 获取或设置金币数量。 + /// + public int Gold { get; set; } + + /// + /// 获取或设置额外奖励数量。 + /// + public int Bonus { get; set; } + } +} diff --git a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs index 565ef1e9..e23217e9 100644 --- a/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs +++ b/GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs @@ -1085,103 +1085,6 @@ public class YamlConfigLoaderTests }); } - /// - /// 验证运行时会拒绝命中 not 子 schema 的标量值。 - /// - [Test] - public void LoadAsync_Should_Throw_When_Value_Matches_Not_Schema() - { - CreateConfigFile( - "monster/slime.yaml", - """ - id: 1 - name: Deprecated - hp: 10 - """); - CreateSchemaFile( - "schemas/monster.schema.json", - """ - { - "type": "object", - "required": ["id", "name", "hp"], - "properties": { - "id": { "type": "integer" }, - "name": { - "type": "string", - "not": { - "type": "string", - "const": "Deprecated" - } - }, - "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.Message, Does.Contain("must not match the 'not' schema")); - Assert.That(registry.Count, Is.EqualTo(0)); - }); - } - - /// - /// 验证 schema 将 not 声明为非对象值时,会在解析阶段被拒绝。 - /// - [Test] - public void LoadAsync_Should_Throw_When_Not_Is_Not_An_Object() - { - CreateConfigFile( - "monster/slime.yaml", - """ - id: 1 - name: Slime - hp: 10 - """); - CreateSchemaFile( - "schemas/monster.schema.json", - """ - { - "type": "object", - "required": ["id", "name", "hp"], - "properties": { - "id": { "type": "integer" }, - "name": { - "type": "string", - "not": "deprecated" - }, - "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.SchemaUnsupported)); - Assert.That(exception.Diagnostic.DisplayPath, Is.EqualTo("name")); - Assert.That(exception.Message, Does.Contain("must declare 'not' as an object-valued schema")); - Assert.That(registry.Count, Is.EqualTo(0)); - }); - } - /// /// 验证运行时 schema 校验与 JS 工具对反向引用模式保持一致。 /// diff --git a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs index 58d6b4b6..84a1cef6 100644 --- a/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs +++ b/GFramework.SourceGenerators/Config/SchemaConfigGenerator.cs @@ -670,6 +670,17 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator } } + if (element.TryGetProperty("not", out var notElement) && + notElement.ValueKind == JsonValueKind.Object && + !TryValidateStringFormatMetadataRecursively( + filePath, + $"{displayPath}[not]", + notElement, + out diagnostic)) + { + return false; + } + if (!string.Equals(schemaType, "array", StringComparison.Ordinal)) { return true; @@ -693,17 +704,6 @@ public sealed class SchemaConfigGenerator : IIncrementalGenerator return false; } - if (element.TryGetProperty("not", out var notElement) && - notElement.ValueKind == JsonValueKind.Object && - !TryValidateStringFormatMetadataRecursively( - filePath, - $"{displayPath}[not]", - notElement, - out diagnostic)) - { - return false; - } - return true; } diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index 39837c95..38eb14ec 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -1856,6 +1856,39 @@ reward: assert.deepEqual(diagnostics, []); }); +test("validateParsedConfig should reject objects that fully match a forbidden not schema", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "reward": { + "type": "object", + "not": { + "type": "object", + "required": ["gold"], + "properties": { + "gold": { "type": "integer" } + } + }, + "properties": { + "gold": { "type": "integer" }, + "bonus": { "type": "integer" } + } + } + } + } + `); + const yaml = parseTopLevelYaml(` +reward: + gold: 10 +`); + + const diagnostics = validateParsedConfig(schema, yaml); + + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /not/u); +}); + test("applyFormUpdates should update nested scalar and scalar-array paths", () => { const updated = applyFormUpdates( [