Files
sharp-basic/test/Config.Test/ConfigTests.cs
T
2025-12-30 09:17:25 +08:00

327 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 未按预期覆盖前序配置源");
}
}