Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions GFramework.Game.Tests/Config/YamlConfigLoaderNegationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,397 @@
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; }
}
}
Loading
Loading