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 未按预期覆盖前序配置源");
}
}