using Microsoft.Extensions.Configuration; using System.ComponentModel.DataAnnotations; using System.Text; // - 本文件覆盖 Config 模块的核心能力测试:多格式读写、强类型绑定、验证、环境变量、热更新、Hook 扩展。 // - 使用 MSTest。请确保已在测试项目中添加对 src\Sharp\Config\Config.csproj 的 ProjectReference。 // - 约定:配置项的“后添加覆盖先添加”(Configuration 的默认行为)。环境变量位于最末,优先级最高。 namespace Config.Test; // 为避免文件监听与并发写入导致的互相影响,禁用并行执行 [TestClass] [DoNotParallelize] public class ConfigTests { // 简单的配置模型,涵盖:基础字段、嵌套对象、DataAnnotations 验证 public class AppSettings { public string? Name { get; set; } public int Port { get; set; } [Range(1, 65535)] public int ValidPort { get; set; } [Required] public string? RequiredField { get; set; } public DatabaseSettings Database { get; set; } = new(); } public class DatabaseSettings { [Required] public string? Host { get; set; } public string? User { get; set; } public string? Password { get; set; } } // 创建临时目录的轻量封装,便于测试隔离 private sealed class TempDirScope : IDisposable { public string Dir { get; } public TempDirScope() { Dir = Path.Combine(Path.GetTempPath(), "ConfigTests_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Dir); } public string PathOf(string file) { return Path.Combine(Dir, file); } public void Dispose() { try { Directory.Delete(Dir, true); } catch { /* 文件监视可能暂占用,忽略 */ } } } [TestMethod] public void Build_FromFiles_Binds_Json_Ini_Xml_Toml_WithValidation() { using var scope = new TempDirScope(); // 1. 构造多格式配置文件,分别写入不同字段 var jsonPath = scope.PathOf("appsettings.json"); var iniPath = scope.PathOf("settings.ini"); var xmlPath = scope.PathOf("extra.xml"); var tomlPath = scope.PathOf("feature.toml"); File.WriteAllText(jsonPath, /* UTF-8 无 BOM */ "{\n" + " \"Name\": \"fromJson\",\n" + " \"Port\": 10\n" + "}", new UTF8Encoding(false)); File.WriteAllText(iniPath, // INI 文件:根键与一层嵌套(Database 节) "User=iniUser\n" + "[Database]\n" + "Host=iniHost\n", new UTF8Encoding(false)); File.WriteAllText(xmlPath, // XML:顶层键直接映射 "\n" + " 100\n" + "", new UTF8Encoding(false)); File.WriteAllText(tomlPath, "RequiredField = \"fromToml\"\n", new UTF8Encoding(false)); // 2. 构建配置(顺序:JSON -> INI -> XML -> TOML),最后添加环境变量 var cm = ConfigBuilder.BuildFromFiles( new[] { "appsettings.json", "settings.ini", "extra.xml", "feature.toml" }, true, true, "APP_", scope.Dir ); // 3. 强类型绑定 + 验证(DataAnnotations) var settings = cm.Get(validate: true); Assert.AreEqual("fromJson", settings.Name); Assert.AreEqual(10, settings.Port); Assert.AreEqual(100, settings.ValidPort); Assert.AreEqual("iniHost", settings.Database.Host); Assert.AreEqual("iniUser", settings.Database.User); Assert.AreEqual("fromToml", settings.RequiredField); } [TestMethod] public void ConfigDefaults_Fluent_WithToml_AndOtherFormats_Works() { using var scope = new TempDirScope(); File.WriteAllText(scope.PathOf("appsettings.json"), "{ \"Name\": \"default\", \"Port\": 20 }", new UTF8Encoding(false)); File.WriteAllText(scope.PathOf("settings.ini"), "[Database]\nHost=iniHost\nUser=iniUser\n", new UTF8Encoding(false)); File.WriteAllText(scope.PathOf("extra.xml"), "8080", new UTF8Encoding(false)); File.WriteAllText(scope.PathOf("feature.toml"), "RequiredField = \"fromToml\"\n", new UTF8Encoding(false)); var settings = ConfigDefaults.Load(defaults => { defaults.BasePath = scope.Dir; defaults.IncludeEnvironmentVariables = false; defaults.WithIni("settings.ini") .WithXml("extra.xml") .WithToml("feature.toml"); }); Assert.AreEqual("default", settings.Name); Assert.AreEqual(20, settings.Port); Assert.AreEqual(8080, settings.ValidPort); Assert.AreEqual("iniHost", settings.Database.Host); Assert.AreEqual("iniUser", settings.Database.User); Assert.AreEqual("fromToml", settings.RequiredField); } [TestMethod] public void Validation_Fails_On_Missing_Required() { using var scope = new TempDirScope(); // 缺少 RequiredField 与 Database.Host,期望验证失败 File.WriteAllText(scope.PathOf("appsettings.json"), "{ \"Name\": \"x\", \"Port\": 5, \"ValidPort\": 200 }", new UTF8Encoding(false)); var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, basePath: scope.Dir); Assert.ThrowsException(() => { cm.Get(validate: true); }); } [TestMethod] public void EnvironmentVariables_Override_FileValues() { using var scope = new TempDirScope(); File.WriteAllText(scope.PathOf("appsettings.json"), "{ \"Name\": \"fromFile\", \"Database\": { \"Host\": \"fileHost\" } }", new UTF8Encoding(false)); // 使用前缀 APP_,配置键用双下划线分隔层级 Environment.SetEnvironmentVariable("APP__Name", "fromEnv"); Environment.SetEnvironmentVariable("APP__Database__Host", "envHost"); try { var cm = ConfigBuilder.BuildFromFiles( new[] { "appsettings.json" }, basePath: scope.Dir, includeEnvironment: true, environmentPrefix: "APP_" ); var settings = cm.Get(); Assert.AreEqual("fromEnv", settings.Name); Assert.AreEqual("envHost", settings.Database.Host); } finally { // 清理环境变量,避免影响其他测试 Environment.SetEnvironmentVariable("APP__Name", null); Environment.SetEnvironmentVariable("APP__Database__Host", null); } } [TestMethod] [Timeout(15000)] public async Task HotReload_FileChange_Triggers_Callback_And_Rebind() { using var scope = new TempDirScope(); var json = scope.PathOf("hot.json"); File.WriteAllText(json, "{ \"Port\": 1 }", new UTF8Encoding(false)); var cm = ConfigBuilder.BuildFromFiles( new[] { "hot.json" }, true, basePath: scope.Dir ); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = cm.OnReload(() => tcs.TrySetResult(true)); // 初始读取 var s1 = cm.Get(); Assert.AreEqual(1, s1.Port); // 修改内容,触发文件监控 await Task.Delay(250); // 稍等,确保监控挂上 File.WriteAllText(json, "{ \"Port\": 2 }", new UTF8Encoding(false)); // 等待回调触发 var fired = await Task.WhenAny(tcs.Task, Task.Delay(5000)) == tcs.Task; Assert.IsTrue(fired, "未在期望时间内触发热更新回调"); // 变更后再取配置 var s2 = cm.Get(); Assert.AreEqual(2, s2.Port); } [TestMethod] public void Save_And_Load_Json_Xml_Ini_Toml() { using var scope = new TempDirScope(); var settings = new AppSettings { Name = "save", Port = 1234, ValidPort = 80, RequiredField = "req", Database = new DatabaseSettings { Host = "db", User = "u" } }; var cm = ConfigManager.Build(new ConfigOptions()); // 用于调用 Save/Load // JSON var json = scope.PathOf("a.json"); cm.Save(settings, json, ConfigFormat.Json); var jsonBack = cm.LoadFromFile(json, ConfigFormat.Json); Assert.AreEqual("save", jsonBack!.Name); Assert.AreEqual(1234, jsonBack.Port); Assert.AreEqual("db", jsonBack.Database.Host); // XML var xml = scope.PathOf("a.xml"); cm.Save(settings, xml, ConfigFormat.Xml); var xmlBack = cm.LoadFromFile(xml, ConfigFormat.Xml); Assert.AreEqual(80, xmlBack!.ValidPort); // INI(支持简单与一层嵌套的序列化回读) var ini = scope.PathOf("a.ini"); cm.Save(settings, ini, ConfigFormat.Ini); var iniBack = cm.LoadFromFile(ini, ConfigFormat.Ini); Assert.AreEqual("u", iniBack!.Database.User); Assert.AreEqual("db", iniBack.Database.Host); // TOML:当前不支持直接泛型反序列化(LoadFromFile),需通过配置管线加载 var toml = scope.PathOf("a.toml"); cm.Save(settings, toml, ConfigFormat.Toml); Assert.ThrowsException(() => { _ = cm.LoadFromFile(toml, ConfigFormat.Toml); }); var cmToml = ConfigBuilder.BuildFromFiles( new[] { "a.toml" }, basePath: scope.Dir, includeEnvironment: false); var tomlBack = cmToml.Get(); Assert.AreEqual("save", tomlBack.Name); Assert.AreEqual("db", tomlBack.Database.Host); } [TestMethod] public void ConfigureBuilderHook_Appends_Providers_And_Overrides() { using var scope = new TempDirScope(); File.WriteAllText(scope.PathOf("appsettings.json"), "{ \"Name\": \"fileName\" }", new UTF8Encoding(false)); var cm = ConfigBuilder.Build(opt => { opt.BasePath = scope.Dir; opt.Sources.Add(new ConfigSource { Path = "appsettings.json", Format = ConfigFormat.Json, Optional = false }); // 通过 Hook 追加内存配置,位于最后,覆盖前面的值 opt.ConfigureBuilderHook = builder => { var kv = new[] { new KeyValuePair("Name", "hookName") }; builder.AddInMemoryCollection(kv); return builder; }; }); var s = cm.Get(); Assert.AreEqual("hookName", s.Name, "Hook 未按预期覆盖前序配置源"); } }