using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; using GFramework.Game.Config; using GFramework.Godot.Config; using NUnit.Framework; namespace GFramework.Godot.Tests.Config; /// /// 验证 Godot YAML 配置加载器能够在编辑器态直读项目目录,并在导出态同步运行时缓存。 /// [TestFixture] public sealed class GodotYamlConfigLoaderTests { /// /// 为每个测试准备独立的资源根目录与用户目录。 /// [SetUp] public void SetUp() { _testRoot = Path.Combine( Path.GetTempPath(), "GFramework.GodotYamlConfigLoaderTests", Guid.NewGuid().ToString("N")); _resourceRoot = Path.Combine(_testRoot, "res-root"); _userRoot = Path.Combine(_testRoot, "user-root"); Directory.CreateDirectory(_resourceRoot); Directory.CreateDirectory(_userRoot); } /// /// 清理测试期间创建的临时目录。 /// [TearDown] public void TearDown() { if (Directory.Exists(_testRoot)) { Directory.Delete(_testRoot, true); } } private string _resourceRoot = null!; private string _testRoot = null!; private string _userRoot = null!; /// /// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。 /// [Test] public async Task LoadAsync_Should_Copy_Registered_Text_Assets_Into_Runtime_Cache_When_Source_Is_Res_Path() { CreateMonsterFiles(_resourceRoot); var loader = CreateLoader(isEditor: false); var registry = new ConfigRegistry(); await loader.LoadAsync(registry); var table = registry.GetTable("monster"); var cacheRoot = Path.Combine(_userRoot, "config_cache"); Assert.Multiple(() => { Assert.That(loader.CanEnableHotReload, Is.False); Assert.That(loader.LoaderRootPath, Is.EqualTo(cacheRoot)); Assert.That(table.Count, Is.EqualTo(2)); Assert.That(table.Get(1).Name, Is.EqualTo("Slime")); Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "slime.yaml")), Is.True); Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "goblin.yml")), Is.True); Assert.That(File.Exists(Path.Combine(cacheRoot, "schemas", "monster.schema.json")), Is.True); Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "notes.txt")), Is.False); Assert.That(Directory.Exists(Path.Combine(cacheRoot, "monster", "nested")), Is.False); }); } /// /// 验证编辑器态会直接使用全局化后的项目目录,而不会额外创建运行时缓存副本。 /// [Test] public async Task LoadAsync_Should_Use_Globalized_Res_Directory_Directly_When_Running_In_Editor() { CreateMonsterFiles(_resourceRoot); var loader = CreateLoader(isEditor: true); var registry = new ConfigRegistry(); await loader.LoadAsync(registry); var table = registry.GetTable("monster"); Assert.Multiple(() => { Assert.That(loader.CanEnableHotReload, Is.True); Assert.That(loader.LoaderRootPath, Is.EqualTo(_resourceRoot)); Assert.That(table.Count, Is.EqualTo(2)); Assert.That(table.Get(2).Hp, Is.EqualTo(30)); Assert.That(Directory.Exists(Path.Combine(_userRoot, "config_cache")), Is.False); }); } /// /// 验证当实例必须依赖运行时缓存时,不允许再直接启用底层文件热重载。 /// [Test] public void EnableHotReload_Should_Throw_When_Source_Root_Cannot_Be_Used_Directly() { var loader = CreateLoader(isEditor: false); var exception = Assert.Throws(() => loader.EnableHotReload(new ConfigRegistry())); Assert.That(exception!.Message, Does.Contain("Hot reload")); } /// /// 验证导出态会按父目录优先同步缓存,避免父目录重置删掉先前复制到子目录的内容。 /// [Test] public async Task LoadAsync_Should_Synchronize_Parent_Directories_Before_Children() { WriteFile( _resourceRoot, "monster/slime.yaml", """ id: 1 name: Slime hp: 10 """); WriteFile( _resourceRoot, "monster/boss/dragon.yaml", """ id: 99 name: Dragon hp: 500 """); var loader = CreateLoader( isEditor: false, tableSources: [ new GodotYamlConfigTableSource("boss", "monster/boss"), new GodotYamlConfigTableSource("monster", "monster") ], configureLoader: loader => { loader.RegisterTable( "boss", "monster/boss", keySelector: static config => config.Id); loader.RegisterTable( "monster", "monster", keySelector: static config => config.Id); }); var registry = new ConfigRegistry(); await loader.LoadAsync(registry); var cacheRoot = Path.Combine(_userRoot, "config_cache"); var bossTable = registry.GetTable("boss"); var monsterTable = registry.GetTable("monster"); Assert.Multiple(() => { Assert.That(monsterTable.Count, Is.EqualTo(1)); Assert.That(bossTable.Count, Is.EqualTo(1)); Assert.That(bossTable.Get(99).Name, Is.EqualTo("Dragon")); Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "boss", "dragon.yaml")), Is.True); }); } /// /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 /// [Test] public void LoadAsync_Should_Reject_Invalid_Config_Relative_Path_When_Metadata_Is_Corrupted() { var corruptedSource = CreateUnsafeTableSource("monster", "../outside"); var loader = CreateLoader( isEditor: false, tableSources: [corruptedSource], configureLoader: static _ => { }); var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(new ConfigRegistry())); Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); } /// /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法 schema 路径,即使调用方绕过了公开构造约束。 /// [Test] public void LoadAsync_Should_Reject_Invalid_Schema_Relative_Path_When_Metadata_Is_Corrupted() { WriteFile( _resourceRoot, "monster/slime.yaml", """ id: 1 name: Slime hp: 10 """); var corruptedSource = CreateUnsafeTableSource("monster", "monster", "../schemas/monster.schema.json"); var loader = CreateLoader( isEditor: false, tableSources: [corruptedSource], configureLoader: static _ => { }); var exception = Assert.ThrowsAsync(async () => await loader.LoadAsync(new ConfigRegistry())); Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); } /// /// 创建一个基于临时目录映射的 Godot YAML 配置加载器。 /// /// 是否模拟编辑器环境。 /// 要同步的配置表来源集合;为空时使用默认 monster 表。 /// 底层 YAML 加载器注册逻辑;为空时使用默认 monster 表注册。 /// 已配置好的加载器实例。 private GodotYamlConfigLoader CreateLoader( bool isEditor, IReadOnlyCollection? tableSources = null, Action? configureLoader = null) { return new GodotYamlConfigLoader( new GodotYamlConfigLoaderOptions { SourceRootPath = "res://", RuntimeCacheRootPath = "user://config_cache", TableSources = tableSources ?? [ new GodotYamlConfigTableSource( "monster", "monster", "schemas/monster.schema.json") ], ConfigureLoader = configureLoader ?? (static loader => loader.RegisterTable( "monster", "monster", "schemas/monster.schema.json", static config => config.Id)) }, CreateEnvironment(isEditor)); } /// /// 创建一个把 res://user:// 映射到临时目录的测试环境。 /// /// 是否模拟编辑器环境。 /// 测试专用环境对象。 private GodotYamlConfigEnvironment CreateEnvironment(bool isEditor) { return new GodotYamlConfigEnvironment( () => isEditor, path => MapGodotPath(path), path => { var absolutePath = MapGodotPath(path); if (!Directory.Exists(absolutePath)) { return null; } return Directory .EnumerateFileSystemEntries(absolutePath, "*", SearchOption.TopDirectoryOnly) .Select(static entryPath => new GodotYamlConfigDirectoryEntry( Path.GetFileName(entryPath), Directory.Exists(entryPath))) .ToArray(); }, path => File.Exists(MapGodotPath(path)), path => File.ReadAllBytes(MapGodotPath(path))); } /// /// 创建一组最小可运行的 monster YAML 与 schema 文件。 /// /// 目标根目录。 private static void CreateMonsterFiles(string rootPath) { WriteFile( rootPath, "schemas/monster.schema.json", """ { "type": "object", "required": ["id", "name", "hp"], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "hp": { "type": "integer" } } } """); WriteFile( rootPath, "monster/slime.yaml", """ id: 1 name: Slime hp: 10 """); WriteFile( rootPath, "monster/goblin.yml", """ id: 2 name: Goblin hp: 30 """); WriteFile( rootPath, "monster/notes.txt", "ignored"); WriteFile( rootPath, "monster/nested/ghost.yaml", """ id: 3 name: Ghost hp: 99 """); } /// /// 把逻辑相对路径写入指定根目录。 /// /// 目标根目录。 /// 相对文件路径。 /// 文件内容。 private static void WriteFile(string rootPath, string relativePath, string content) { var fullPath = Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var directoryPath = Path.GetDirectoryName(fullPath); if (!string.IsNullOrWhiteSpace(directoryPath)) { Directory.CreateDirectory(directoryPath); } File.WriteAllText(fullPath, content); } /// /// 构造一个绕过公开构造校验的配置来源对象,用于验证加载器的防御式路径校验。 /// /// 伪造的表名称。 /// 伪造的配置目录路径。 /// 伪造的 schema 路径。 /// 已写入指定字段值的未初始化对象。 private static GodotYamlConfigTableSource CreateUnsafeTableSource( string tableName, string configRelativePath, string? schemaRelativePath = null) { var source = (GodotYamlConfigTableSource)RuntimeHelpers.GetUninitializedObject(typeof(GodotYamlConfigTableSource)); SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.TableName), tableName); SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.ConfigRelativePath), configRelativePath); SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.SchemaRelativePath), schemaRelativePath); return source; } /// /// 直接写入自动属性的编译器生成字段,用于构造损坏的测试对象。 /// /// 字段值类型。 /// 要写入字段的目标对象。 /// 对应的属性名称。 /// 要写入的字段值。 private static void SetAutoPropertyBackingField( object instance, string propertyName, TValue value) { var field = instance.GetType().GetField( $"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); if (field == null) { throw new InvalidOperationException( $"Backing field for property '{propertyName}' was not found on type '{instance.GetType().FullName}'."); } field.SetValue(instance, value); } /// /// 将测试中的 Godot 路径映射到本地临时目录。 /// /// Godot 路径或普通路径。 /// 映射后的绝对路径。 private string MapGodotPath(string path) { if (path.StartsWith("res://", StringComparison.Ordinal)) { return Path.Combine( _resourceRoot, path["res://".Length..].Replace('/', Path.DirectorySeparatorChar)); } if (path.StartsWith("user://", StringComparison.Ordinal)) { return Path.Combine( _userRoot, path["user://".Length..].Replace('/', Path.DirectorySeparatorChar)); } return path; } /// /// 最小 monster 配置桩类型。 /// private sealed class MonsterConfigStub { /// /// 主键。 /// public int Id { get; init; } /// /// 名称。 /// public string Name { get; init; } = string.Empty; /// /// 生命值。 /// public int Hp { get; init; } } }