GFramework/GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs
GeWuYou a14e736fb9 test(config): 添加YAML配置加载器的not约束运行时行为验证测试
- 实现标量值命中not子schema时的异常抛出验证
- 验证值未命中not子schema时的正常加载行为
- 测试对象完整命中禁用schema时的约束失败触发
- 验证对象仅命中not schema属性子集时的正确处理
- 添加not声明为非对象值时的解析阶段拒绝验证
- 创建临时目录隔离测试环境避免用例间污染
- 实现配置文件和schema文件的动态创建功能
- 提供标量和对象两种not约束场景的测试加载器
2026-04-16 11:11:52 +08:00

398 lines
12 KiB
C#

using System.IO;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 YAML 配置加载器对 <c>not</c> 约束的运行时行为。
/// </summary>
[TestFixture]
public sealed class YamlConfigLoaderNegationTests
{
private string _rootPath = null!;
/// <summary>
/// 为每个测试创建隔离的临时目录,避免不同 <c>not</c> 用例互相污染。
/// </summary>
[SetUp]
public void SetUp()
{
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_rootPath);
}
/// <summary>
/// 清理当前测试创建的临时目录,避免本地文件残留影响后续执行。
/// </summary>
[TearDown]
public void TearDown()
{
if (Directory.Exists(_rootPath))
{
Directory.Delete(_rootPath, true);
}
}
/// <summary>
/// 验证运行时会拒绝命中 <c>not</c> 子 schema 的标量值。
/// </summary>
[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<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.Message, Does.Contain("must not match the 'not' schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证值未命中 <c>not</c> 子 schema 时,加载器不会误报禁用约束。
/// </summary>
[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);
var table = registry.GetTable<int, MonsterConfigStub>("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));
});
}
/// <summary>
/// 验证对象完整命中禁用 schema 时,同样会触发 <c>not</c> 约束失败。
/// </summary>
[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<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("reward"));
Assert.That(exception.Message, Does.Contain("must not match the 'not' schema"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
/// <summary>
/// 验证对象仅命中 <c>not</c> schema 的属性子集时,不会被误判为完整命中。
/// </summary>
[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);
var table = registry.GetTable<int, MonsterRewardConfigStub>("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));
});
}
/// <summary>
/// 验证 schema 将 <c>not</c> 声明为非对象值时,会在解析阶段被拒绝。
/// </summary>
[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<ConfigLoadException>(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));
});
}
/// <summary>
/// 在测试目录下写入配置文件,并自动创建缺失目录。
/// </summary>
/// <param name="relativePath">相对根目录的配置文件路径。</param>
/// <param name="content">要写入的 YAML 或 schema 内容。</param>
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);
}
/// <summary>
/// 写入测试 schema 文件,复用通用配置文件创建逻辑。
/// </summary>
/// <param name="relativePath">schema 相对路径。</param>
/// <param name="content">schema JSON 内容。</param>
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
/// <summary>
/// 创建用于标量 <c>not</c> 场景的加载器,统一测试夹具中的注册方式。
/// </summary>
/// <returns>已注册怪物表与 schema 路径的加载器。</returns>
private YamlConfigLoader CreateMonsterLoader()
{
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
}
/// <summary>
/// 创建用于对象 <c>not</c> 场景的加载器,避免重复维护同一注册参数。
/// </summary>
/// <returns>已注册奖励对象测试表的加载器。</returns>
private YamlConfigLoader CreateMonsterRewardLoader()
{
return new YamlConfigLoader(_rootPath)
.RegisterTable<int, MonsterRewardConfigStub>("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
}
/// <summary>
/// 创建新的配置注册表,明确每个用例都从干净状态开始。
/// </summary>
/// <returns>空的配置注册表。</returns>
private static ConfigRegistry CreateRegistry()
{
return new ConfigRegistry();
}
/// <summary>
/// 用于标量 <c>not</c> 回归测试的最小配置类型。
/// </summary>
private sealed class MonsterConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 获取或设置生命值。
/// </summary>
public int Hp { get; set; }
}
/// <summary>
/// 用于对象 <c>not</c> 回归测试的最小配置类型。
/// </summary>
private sealed class MonsterRewardConfigStub
{
/// <summary>
/// 获取或设置主键。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 获取或设置奖励对象。
/// </summary>
public RewardConfigStub Reward { get; set; } = new();
}
/// <summary>
/// 表示对象 <c>not</c> 回归测试中的奖励节点。
/// </summary>
private sealed class RewardConfigStub
{
/// <summary>
/// 获取或设置金币数量。
/// </summary>
public int Gold { get; set; }
/// <summary>
/// 获取或设置额外奖励数量。
/// </summary>
public int Bonus { get; set; }
}
}