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 校验后,未知字段不会再被静默忽略。 /// [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)); }); } /// /// 验证启用热重载后,配置文件内容变更会刷新已注册配置表。 /// [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(); } } /// /// 创建测试用配置文件。 /// /// 相对根目录的文件路径。 /// 文件内容。 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 record ExistingConfigStub(int Id, string Name); }