// Copyright (c) 2025-2026 GeWuYou // SPDX-License-Identifier: Apache-2.0 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 = CreateMonsterLoader(); var registry = CreateRegistry(); var exception = Assert.ThrowsAsync(() => 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 = CreateMonsterLoader(); var registry = CreateRegistry(); await loader.LoadAsync(registry).ConfigureAwait(false); 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 = CreateMonsterRewardLoader(); var registry = CreateRegistry(); var exception = Assert.ThrowsAsync(() => 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 = CreateMonsterRewardLoader(); var registry = CreateRegistry(); await loader.LoadAsync(registry).ConfigureAwait(false); 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 = CreateMonsterLoader(); var registry = CreateRegistry(); var exception = Assert.ThrowsAsync(() => 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 场景的加载器,统一测试夹具中的注册方式。 /// /// 已注册怪物表与 schema 路径的加载器。 private YamlConfigLoader CreateMonsterLoader() { return new YamlConfigLoader(_rootPath) .RegisterTable("monster", "monster", "schemas/monster.schema.json", static config => config.Id); } /// /// 创建用于对象 not 场景的加载器,避免重复维护同一注册参数。 /// /// 已注册奖励对象测试表的加载器。 private YamlConfigLoader CreateMonsterRewardLoader() { return new YamlConfigLoader(_rootPath) .RegisterTable("monster", "monster", "schemas/monster.schema.json", static config => config.Id); } /// /// 创建新的配置注册表,明确每个用例都从干净状态开始。 /// /// 空的配置注册表。 private static ConfigRegistry CreateRegistry() { return new ConfigRegistry(); } /// /// 用于标量 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; } } }