327 lines
11 KiB
C#
327 lines
11 KiB
C#
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:顶层键直接映射
|
||
"<settings>\n" +
|
||
" <ValidPort>100</ValidPort>\n" +
|
||
"</settings>", 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<AppSettings>(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"),
|
||
"<settings><ValidPort>8080</ValidPort></settings>",
|
||
new UTF8Encoding(false));
|
||
|
||
File.WriteAllText(scope.PathOf("feature.toml"),
|
||
"RequiredField = \"fromToml\"\n",
|
||
new UTF8Encoding(false));
|
||
|
||
var settings = ConfigDefaults.Load<AppSettings>(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<ConfigurationValidationException>(() => { cm.Get<AppSettings>(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<AppSettings>();
|
||
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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
using var sub = cm.OnReload(() => tcs.TrySetResult(true));
|
||
|
||
// 初始读取
|
||
var s1 = cm.Get<AppSettings>();
|
||
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<AppSettings>();
|
||
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<AppSettings>(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<AppSettings>(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<AppSettings>(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<NotSupportedException>(() =>
|
||
{
|
||
_ = cm.LoadFromFile<AppSettings>(toml, ConfigFormat.Toml);
|
||
});
|
||
|
||
var cmToml = ConfigBuilder.BuildFromFiles(
|
||
new[] { "a.toml" },
|
||
basePath: scope.Dir,
|
||
includeEnvironment: false);
|
||
var tomlBack = cmToml.Get<AppSettings>();
|
||
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<string, string?>("Name", "hookName") };
|
||
builder.AddInMemoryCollection(kv);
|
||
return builder;
|
||
};
|
||
});
|
||
|
||
var s = cm.Get<AppSettings>();
|
||
Assert.AreEqual("hookName", s.Name, "Hook 未按预期覆盖前序配置源");
|
||
}
|
||
} |