using System.IO;
using GFramework.Game.Config;
namespace GFramework.Game.Tests.Config;
///
/// 验证 YAML 配置加载器的目录扫描与注册行为。
///
[TestFixture]
public class YamlConfigLoaderTests
{
///
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
///
[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);
}
}
private string _rootPath = null!;
///
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
///
[Test]
public async Task LoadAsync_Should_Register_Table_From_Yaml_Files()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateConfigFile(
"monster/goblin.yml",
"""
id: 2
name: Goblin
hp: 30
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("monster", "monster", 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(2));
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
});
}
///
/// 验证注册的配置目录不存在时会抛出清晰错误。
///
[Test]
public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist()
{
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("monster", "monster", 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!.Message, Does.Contain("monster"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。
///
[Test]
public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
var registry = new ConfigRegistry();
registry.RegisterTable(
"existing",
new InMemoryConfigTable(
new[]
{
new ExistingConfigStub(100, "Original")
},
static config => config.Id));
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("monster", "monster", static config => config.Id)
.RegisterTable("broken", "broken", static config => config.Id);
Assert.ThrowsAsync(async () => await loader.LoadAsync(registry));
Assert.Multiple(() =>
{
Assert.That(registry.Count, Is.EqualTo(1));
Assert.That(registry.HasTable("monster"), Is.False);
Assert.That(registry.GetTable("existing").Get(100).Name, Is.EqualTo("Original"));
});
}
///
/// 验证非法 YAML 会被包装成带文件路径的反序列化错误。
///
[Test]
public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: [1
name: Slime
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("monster", "monster", 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!.Message, Does.Contain("slime.yaml"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证启用 schema 校验后,缺失必填字段会在反序列化前被拒绝。
///
[Test]
public void LoadAsync_Should_Throw_When_Required_Property_Is_Missing_According_To_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"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!.Message, Does.Contain("name"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证启用 schema 校验后,类型不匹配的标量字段会被拒绝。
///
[Test]
public void LoadAsync_Should_Throw_When_Property_Type_Does_Not_Match_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: high
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"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!.Message, Does.Contain("hp"));
Assert.That(exception!.Message, Does.Contain("integer"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证启用 schema 校验后,标量 enum 限制会在运行时被拒绝。
///
[Test]
public void LoadAsync_Should_Throw_When_Scalar_Value_Is_Not_Declared_In_Schema_Enum()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
rarity: epic
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "rarity"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"rarity": {
"type": "string",
"enum": ["common", "rare"]
}
}
}
""");
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!.Message, Does.Contain("common"));
Assert.That(exception!.Message, Does.Contain("rare"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证启用 schema 校验后,未知字段不会再被静默忽略。
///
[Test]
public void LoadAsync_Should_Throw_When_Unknown_Property_Is_Present_In_Schema_Bound_Mode()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
attackPower: 2
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"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!.Message, Does.Contain("attackPower"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证数组字段的元素类型会按 schema 校验。
///
[Test]
public void LoadAsync_Should_Throw_When_Array_Item_Type_Does_Not_Match_Schema()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropRates:
- 1
- potion
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropRates": {
"type": "array",
"items": { "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!.Message, Does.Contain("dropRates"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证数组元素上的 enum 限制会按 schema 在运行时生效。
///
[Test]
public void LoadAsync_Should_Throw_When_Array_Item_Is_Not_Declared_In_Schema_Enum()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
tags:
- fire
- poison
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"tags": {
"type": "array",
"items": {
"type": "string",
"enum": ["fire", "ice"]
}
}
}
}
""");
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!.Message, Does.Contain("fire"));
Assert.That(exception!.Message, Does.Contain("ice"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证绑定跨表引用 schema 时,存在的目标行可以通过加载校验。
///
[Test]
public async Task LoadAsync_Should_Accept_Existing_Cross_Table_Reference()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
Assert.Multiple(() =>
{
Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable("monster").Get(1).DropItemId,
Is.EqualTo("potion"));
});
}
///
/// 验证缺失的跨表引用会阻止整批配置写入注册表。
///
[Test]
public void LoadAsync_Should_Throw_When_Cross_Table_Reference_Target_Is_Missing()
{
CreateConfigFile(
"item/slime-gel.yaml",
"""
id: slime_gel
name: Slime Gel
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("item", "item", "schemas/item.schema.json",
static config => config.Id)
.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!.Message, Does.Contain("dropItemId"));
Assert.That(exception!.Message, Does.Contain("potion"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证跨表引用同样支持标量数组中的每个元素。
///
[Test]
public void LoadAsync_Should_Throw_When_Array_Reference_Item_Is_Missing()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"item/slime-gel.yaml",
"""
id: slime_gel
name: Slime Gel
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemIds:
- potion
- missing_item
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemIds"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemIds": {
"type": "array",
"items": { "type": "string" },
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("item", "item", "schemas/item.schema.json",
static config => config.Id)
.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!.Message, Does.Contain("dropItemIds[1]"));
Assert.That(exception!.Message, Does.Contain("missing_item"));
Assert.That(registry.Count, Is.EqualTo(0));
});
}
///
/// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。
///
[Test]
public async Task EnableHotReload_Should_Update_Registered_Table_When_Config_File_Changes()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"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 reloadTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
onTableReloaded: tableName => reloadTaskSource.TrySetResult(tableName),
debounceDelay: TimeSpan.FromMilliseconds(150));
try
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 25
""");
var tableName = await WaitForTaskWithinAsync(reloadTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(tableName, Is.EqualTo("monster"));
Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(25));
});
}
finally
{
hotReload.UnRegister();
}
}
///
/// 验证热重载失败时会保留旧表状态,并通过失败回调暴露诊断信息。
///
[Test]
public async Task EnableHotReload_Should_Keep_Previous_Table_When_Schema_Change_Makes_Reload_Fail()
{
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
hp: 10
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"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 reloadFailureTaskSource =
new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
onTableReloadFailed: (tableName, exception) =>
reloadFailureTaskSource.TrySetResult((tableName, exception)),
debounceDelay: TimeSpan.FromMilliseconds(150));
try
{
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "rarity"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"hp": { "type": "integer" },
"rarity": { "type": "string" }
}
}
""");
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(failure.TableName, Is.EqualTo("monster"));
Assert.That(failure.Exception.Message, Does.Contain("rarity"));
Assert.That(registry.GetTable("monster").Get(1).Hp, Is.EqualTo(10));
});
}
finally
{
hotReload.UnRegister();
}
}
///
/// 验证当被引用表变更导致依赖表引用失效时,热重载会整体回滚受影响表。
///
[Test]
public async Task EnableHotReload_Should_Keep_Previous_State_When_Dependency_Table_Breaks_Cross_Table_Reference()
{
CreateConfigFile(
"item/potion.yaml",
"""
id: potion
name: Potion
""");
CreateConfigFile(
"monster/slime.yaml",
"""
id: 1
name: Slime
dropItemId: potion
""");
CreateSchemaFile(
"schemas/item.schema.json",
"""
{
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
}
""");
CreateSchemaFile(
"schemas/monster.schema.json",
"""
{
"type": "object",
"required": ["id", "name", "dropItemId"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"dropItemId": {
"type": "string",
"x-gframework-ref-table": "item"
}
}
}
""");
var loader = new YamlConfigLoader(_rootPath)
.RegisterTable("item", "item", "schemas/item.schema.json",
static config => config.Id)
.RegisterTable("monster", "monster", "schemas/monster.schema.json",
static config => config.Id);
var registry = new ConfigRegistry();
await loader.LoadAsync(registry);
var reloadFailureTaskSource =
new TaskCompletionSource<(string TableName, Exception Exception)>(TaskCreationOptions
.RunContinuationsAsynchronously);
var hotReload = loader.EnableHotReload(
registry,
onTableReloadFailed: (tableName, exception) =>
reloadFailureTaskSource.TrySetResult((tableName, exception)),
debounceDelay: TimeSpan.FromMilliseconds(150));
try
{
CreateConfigFile(
"item/potion.yaml",
"""
id: elixir
name: Elixir
""");
var failure = await WaitForTaskWithinAsync(reloadFailureTaskSource.Task, TimeSpan.FromSeconds(5));
Assert.Multiple(() =>
{
Assert.That(failure.TableName, Is.EqualTo("item"));
Assert.That(failure.Exception.Message, Does.Contain("dropItemId"));
Assert.That(registry.GetTable("item").ContainsKey("potion"), Is.True);
Assert.That(registry.GetTable("monster").Get(1).DropItemId,
Is.EqualTo("potion"));
});
}
finally
{
hotReload.UnRegister();
}
}
///
/// 创建测试用配置文件。
///
/// 相对根目录的文件路径。
/// 文件内容。
private void CreateConfigFile(string relativePath, string content)
{
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(fullPath, content);
}
///
/// 创建测试用 schema 文件。
///
/// 相对根目录的文件路径。
/// 文件内容。
private void CreateSchemaFile(string relativePath, string content)
{
CreateConfigFile(relativePath, content);
}
///
/// 在限定时间内等待异步任务完成,避免文件监听测试无限挂起。
///
/// 任务结果类型。
/// 要等待的任务。
/// 超时时间。
/// 任务结果。
private static async Task WaitForTaskWithinAsync(Task task, TimeSpan timeout)
{
var completedTask = await Task.WhenAny(task, Task.Delay(timeout));
if (!ReferenceEquals(completedTask, task))
{
Assert.Fail($"Timed out after {timeout} while waiting for file watcher notification.");
}
return await task;
}
///
/// 用于 YAML 加载测试的最小怪物配置类型。
///
private sealed class MonsterConfigStub
{
///
/// 获取或设置主键。
///
public int Id { get; set; }
///
/// 获取或设置名称。
///
public string Name { get; set; } = string.Empty;
///
/// 获取或设置生命值。
///
public int Hp { get; set; }
}
///
/// 用于数组 schema 校验测试的最小怪物配置类型。
///
private sealed class MonsterConfigIntegerArrayStub
{
///
/// 获取或设置主键。
///
public int Id { get; set; }
///
/// 获取或设置名称。
///
public string Name { get; set; } = string.Empty;
///
/// 获取或设置掉落率列表。
///
public IReadOnlyList DropRates { get; set; } = Array.Empty();
}
///
/// 用于跨表引用测试的最小物品配置类型。
///
private sealed class ItemConfigStub
{
///
/// 获取或设置主键。
///
public string Id { get; set; } = string.Empty;
///
/// 获取或设置名称。
///
public string Name { get; set; } = string.Empty;
}
///
/// 用于单值跨表引用测试的怪物配置类型。
///
private sealed class MonsterDropConfigStub
{
///
/// 获取或设置主键。
///
public int Id { get; set; }
///
/// 获取或设置名称。
///
public string Name { get; set; } = string.Empty;
///
/// 获取或设置掉落物品主键。
///
public string DropItemId { get; set; } = string.Empty;
}
///
/// 用于数组跨表引用测试的怪物配置类型。
///
private sealed class MonsterDropArrayConfigStub
{
///
/// 获取或设置主键。
///
public int Id { get; set; }
///
/// 获取或设置名称。
///
public string Name { get; set; } = string.Empty;
///
/// 获取或设置掉落物品主键列表。
///
public List DropItemIds { get; set; } = new();
}
///
/// 用于验证注册表一致性的现有配置类型。
///
/// 配置主键。
/// 配置名称。
private sealed record ExistingConfigStub(int Id, string Name);
}