GFramework/GFramework.Game.Tests/Config/ArchitectureConfigIntegrationTests.cs
GeWuYou 0104537615 refactor(config): 优化架构配置集成测试中的警告处理
- 在初始化方法中添加了针对GF_ContextRegistration_003警告的禁用指令
- 通过#pragma warning disable和#pragma warning restore临时禁用了特定警告
- 保持了原有的配置注册表获取和怪物表格数据读取逻辑
- 确保了测试代码的编译清洁性同时维持功能完整性
2026-04-17 19:19:25 +08:00

559 lines
21 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 System.IO;
using System.Threading;
using GFramework.Core.Architectures;
using GFramework.Core.Extensions;
using GFramework.Core.Utility;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Game.Config.Generated;
namespace GFramework.Game.Tests.Config;
/// <summary>
/// 验证 <see cref="Architecture" /> 场景下的官方配置模块接入链路。
/// 这些测试覆盖模块安装、utility 初始化顺序以及生成表访问,确保模块化入口能够替代手写 bootstrap 模板。
/// </summary>
[TestFixture]
public class ArchitectureConfigIntegrationTests
{
/// <summary>
/// 清理全局架构上下文,避免测试之间残留同类型架构绑定。
/// </summary>
[SetUp]
[TearDown]
public void ResetGlobalArchitectureContext()
{
GameContext.Clear();
}
/// <summary>
/// 架构初始化期间,通过 <see cref="GameConfigModule" /> 收敛生成表注册、加载与注册表暴露,
/// 并将 <see cref="IConfigRegistry" /> 作为 utility 暴露给架构上下文读取。
/// </summary>
[Test]
public async Task ConfigModuleCanRunDuringArchitectureInitialization()
{
var rootPath = CreateTempConfigRoot();
ConsumerArchitecture? architecture = null;
var initialized = false;
try
{
architecture = new ConsumerArchitecture(rootPath);
await architecture.InitializeAsync();
initialized = true;
var table = architecture.MonsterTable;
Assert.Multiple(() =>
{
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
Assert.That(table.FindByFaction("dungeon").Select(static config => config.Name),
Is.EquivalentTo(new[] { "Slime", "Goblin" }));
Assert.That(architecture.Registry.TryGetMonsterTable(out var retrieved), Is.True);
Assert.That(retrieved, Is.Not.Null);
Assert.That(retrieved!.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(architecture.Registry.TryGetItemTable(out _), Is.False);
Assert.That(architecture.Context.GetUtility<IConfigRegistry>(), Is.SameAs(architecture.Registry));
Assert.That(architecture.ConfigModule.IsInitialized, Is.True);
Assert.That(architecture.ConfigModule.IsHotReloadEnabled, Is.False);
});
}
finally
{
if (architecture is not null && initialized)
{
await architecture.DestroyAsync();
}
DeleteDirectoryIfExists(rootPath);
}
}
/// <summary>
/// 验证配置模块会在其他 utility 初始化之前完成首次加载,
/// 这样依赖配置的 utility 无需再自行阻塞等待配置系统完成启动。
/// </summary>
[Test]
public async Task ConfigModuleShouldLoadConfigBeforeDependentUtilityInitialization()
{
var rootPath = CreateTempConfigRoot();
ConsumerArchitecture? architecture = null;
var initialized = false;
try
{
architecture = new ConsumerArchitecture(rootPath);
await architecture.InitializeAsync();
initialized = true;
Assert.Multiple(() =>
{
Assert.That(architecture.ProbeUtility.InitializedWithLoadedConfig, Is.True);
Assert.That(architecture.ProbeUtility.ObservedMonsterName, Is.EqualTo("Slime"));
Assert.That(architecture.ProbeUtility.ObservedDungeonMonsterCount, Is.EqualTo(2));
});
}
finally
{
if (architecture is not null && initialized)
{
await architecture.DestroyAsync();
}
DeleteDirectoryIfExists(rootPath);
}
}
/// <summary>
/// 验证同一个模块实例不会被重复安装到多个架构中,
/// 避免共享内部 bootstrap 状态导致跨架构生命周期混淆。
/// </summary>
[Test]
public async Task GameConfigModuleShouldRejectReusingTheSameModuleInstance()
{
var rootPath = CreateTempConfigRoot();
ModuleOnlyArchitecture? firstArchitecture = null;
var firstDestroyed = false;
try
{
var module = CreateModule(rootPath);
firstArchitecture = new ModuleOnlyArchitecture(module);
await firstArchitecture.InitializeAsync();
var wasInitializedBeforeDestroy = module.IsInitialized;
await firstArchitecture.DestroyAsync();
firstDestroyed = true;
firstArchitecture = null;
GameContext.Clear();
var secondArchitecture = new ModuleOnlyArchitecture(module);
var exception =
Assert.ThrowsAsync<InvalidOperationException>(async () => await secondArchitecture.InitializeAsync());
Assert.Multiple(() =>
{
Assert.That(wasInitializedBeforeDestroy, Is.True);
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("cannot be installed more than once"));
});
}
finally
{
if (firstArchitecture is not null && !firstDestroyed)
{
await firstArchitecture.DestroyAsync();
}
DeleteDirectoryIfExists(rootPath);
}
}
/// <summary>
/// 验证配置模块在同步阻塞且存在 <see cref="SynchronizationContext" /> 的线程上仍可完成架构初始化,
/// 直接覆盖 <see cref="GameConfigModule" /> 通过生命周期钩子执行同步桥接的真实路径。
/// </summary>
[Test]
public void GameConfigModuleShouldSupportSynchronousBridgeOnBlockingSynchronizationContext()
{
var rootPath = CreateTempConfigRoot();
ConsumerArchitecture? architecture = null;
var initialized = false;
try
{
architecture = new ConsumerArchitecture(rootPath);
RunBlockingOnSynchronizationContext(
() => architecture.InitializeAsync(),
TimeSpan.FromSeconds(5));
initialized = true;
var monsterTable = architecture.Registry.GetMonsterTable();
Assert.Multiple(() =>
{
Assert.That(architecture.ConfigModule.IsInitialized, Is.True);
Assert.That(monsterTable.Get(1).Name, Is.EqualTo("Slime"));
Assert.That(monsterTable.FindByFaction("dungeon").Count(), Is.EqualTo(2));
});
}
finally
{
if (architecture is not null && initialized)
{
architecture.DestroyAsync().GetAwaiter().GetResult();
}
DeleteDirectoryIfExists(rootPath);
}
}
/// <summary>
/// 验证模块在架构已经越过安装窗口时会拒绝安装,
/// 并且失败不会消耗模块实例,便于后续在新的架构上重试安装。
/// </summary>
[Test]
public async Task GameConfigModuleShouldRejectLateInstallationWithoutConsumingTheModuleInstance()
{
var rootPath = CreateTempConfigRoot();
ReadyOnlyArchitecture? readyArchitecture = null;
ModuleOnlyArchitecture? retryArchitecture = null;
var readyArchitectureInitialized = false;
var retryArchitectureInitialized = false;
try
{
var module = CreateModule(rootPath);
readyArchitecture = new ReadyOnlyArchitecture();
await readyArchitecture.InitializeAsync();
readyArchitectureInitialized = true;
var exception = Assert.Throws<InvalidOperationException>(() => readyArchitecture.InstallModule(module));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Message, Does.Contain("BeforeUtilityInit"));
Assert.That(readyArchitecture.Context.GetUtilities<IConfigRegistry>(), Is.Empty);
Assert.That(module.IsInitialized, Is.False);
});
await readyArchitecture.DestroyAsync();
readyArchitectureInitialized = false;
readyArchitecture = null;
GameContext.Clear();
retryArchitecture = new ModuleOnlyArchitecture(module);
await retryArchitecture.InitializeAsync();
retryArchitectureInitialized = true;
Assert.Multiple(() =>
{
Assert.That(module.IsInitialized, Is.True);
Assert.That(retryArchitecture.Registry.GetMonsterTable().Get(1).Name, Is.EqualTo("Slime"));
});
}
finally
{
if (retryArchitecture is not null && retryArchitectureInitialized)
{
await retryArchitecture.DestroyAsync();
}
if (readyArchitecture is not null && readyArchitectureInitialized)
{
await readyArchitecture.DestroyAsync();
}
DeleteDirectoryIfExists(rootPath);
}
}
private static string CreateTempConfigRoot()
{
var rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigArchitecture", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
Directory.CreateDirectory(Path.Combine(rootPath, "schemas"));
Directory.CreateDirectory(Path.Combine(rootPath, "monster"));
File.WriteAllText(Path.Combine(rootPath, "schemas", "monster.schema.json"), MonsterSchemaJson);
File.WriteAllText(Path.Combine(rootPath, "monster", "slime.yaml"), MonsterSlimeYaml);
File.WriteAllText(Path.Combine(rootPath, "monster", "goblin.yaml"), MonsterGoblinYaml);
return rootPath;
}
/// <summary>
/// 最佳努力尝试删除临时目录。
/// </summary>
private static void DeleteDirectoryIfExists(string path)
{
if (!Directory.Exists(path))
{
return;
}
try
{
Directory.Delete(path, true);
}
catch (IOException)
{
// Ignored: cleanup is best effort and should not fail the test.
}
catch (UnauthorizedAccessException)
{
// Ignored: cleanup is best effort and can transiently fail when files are still being released.
}
}
/// <summary>
/// 在不处理消息队列的同步上下文线程上执行阻塞等待,
/// 用于回归验证初始化异步链不会依赖原上下文恢复 continuation。
/// </summary>
/// <param name="action">要同步阻塞执行的异步操作。</param>
/// <param name="timeout">等待线程结束的超时时间。</param>
private static void RunBlockingOnSynchronizationContext(Func<Task> action, TimeSpan timeout)
{
ArgumentNullException.ThrowIfNull(action);
Exception? capturedException = null;
var workerThread = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext());
try
{
action().GetAwaiter().GetResult();
}
catch (Exception exception)
{
capturedException = exception;
}
})
{
IsBackground = true
};
workerThread.Start();
if (!workerThread.Join(timeout))
{
Assert.Fail("The blocking synchronization-context bridge did not complete within the expected timeout.");
}
if (capturedException != null)
{
throw new AssertionException(
$"The blocking synchronization-context bridge failed: {capturedException}");
}
}
/// <summary>
/// 创建一个使用配置模块的模块实例。
/// </summary>
/// <param name="configRoot">测试配置根目录。</param>
/// <returns>已配置的模块实例。</returns>
private static GameConfigModule CreateModule(string configRoot)
{
return new GameConfigModule(CreateBootstrapOptions(configRoot));
}
/// <summary>
/// 创建供测试复用的配置启动选项。
/// </summary>
/// <param name="configRoot">测试配置根目录。</param>
/// <returns>可用于模块或直接 bootstrap 的启动选项。</returns>
private static GameConfigBootstrapOptions CreateBootstrapOptions(string configRoot)
{
return new GameConfigBootstrapOptions
{
RootPath = configRoot,
ConfigureLoader = static loader =>
loader.RegisterAllGeneratedConfigTables(
new GeneratedConfigRegistrationOptions
{
IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain }
})
};
}
private const string MonsterSchemaJson = @"{
""title"": ""Monster Config"",
""description"": ""Defines one monster entry for the generated consumer integration test."",
""type"": ""object"",
""required"": [
""id"",
""name"",
""hp"",
""faction""
],
""properties"": {
""id"": {
""type"": ""integer"",
""description"": ""Monster identifier.""
},
""name"": {
""type"": ""string"",
""description"": ""Monster display name.""
},
""hp"": {
""type"": ""integer"",
""description"": ""Monster base health.""
},
""faction"": {
""type"": ""string"",
""description"": ""Used by the integration test to validate generated non-unique queries.""
}
}
}";
private const string MonsterSlimeYaml =
"id: 1\nname: Slime\nhp: 10\nfaction: dungeon\n";
private const string MonsterGoblinYaml =
"id: 2\nname: Goblin\nhp: 30\nfaction: dungeon\n";
private sealed class ConsumerArchitecture : Architecture
{
private readonly GameConfigModule _configModule;
private readonly ConfigAwareProbeUtility _probeUtility = new();
/// <summary>
/// 使用指定配置根目录创建一个消费者测试架构。
/// </summary>
/// <param name="configRoot">测试配置根目录。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="configRoot" /> 为空时抛出。</exception>
public ConsumerArchitecture(string configRoot)
{
if (configRoot == null)
{
throw new ArgumentNullException(nameof(configRoot));
}
_configModule = CreateModule(configRoot);
}
/// <summary>
/// 获取当前架构安装的配置模块。
/// </summary>
/// <remarks>
/// 该模块会在 <see cref="OnInitialize" /> 中安装,并在架构进入
/// <see cref="GFramework.Core.Abstractions.Enums.ArchitecturePhase.BeforeUtilityInit" /> 时完成首次加载。
/// 调用方可通过该属性观察模块初始化状态,但不应在架构初始化完成前假定加载已经成功。
/// </remarks>
public GameConfigModule ConfigModule => _configModule;
/// <summary>
/// 获取由配置模块暴露到架构上下文中的注册表。
/// </summary>
/// <remarks>
/// 该属性在模块安装后即可访问同一个注册表实例,但只有在模块首次加载完成后,
/// 其中的生成配置表读取才具备成功契约。
/// </remarks>
public IConfigRegistry Registry => _configModule.Registry;
/// <summary>
/// 获取测试使用的怪物配置表。
/// </summary>
/// <returns>已经从 <see cref="Registry" /> 中解析出的怪物表包装。</returns>
/// <exception cref="InvalidOperationException">当模块首次加载尚未完成时抛出。</exception>
/// <remarks>
/// 该属性用于断言生成访问器在架构初始化完成后可直接读取;
/// 它依赖模块在 utility 初始化阶段之前已经完成首次加载。
/// </remarks>
public MonsterTable MonsterTable => Registry.GetMonsterTable();
/// <summary>
/// 获取用于观测 utility 初始化阶段配置可见性的探针 utility。
/// </summary>
/// <remarks>
/// 该 utility 会在 <see cref="OnInitialize" /> 中注册,并在 utility 初始化阶段读取配置表,
/// 用于验证配置模块是否按约定在更早的生命周期阶段完成首载。
/// </remarks>
public ConfigAwareProbeUtility ProbeUtility => _probeUtility;
/// <summary>
/// 在用户初始化阶段安装配置模块,并注册一个依赖配置的测试 utility
/// 以验证模块会在 utility 初始化前完成首次加载。
/// </summary>
protected override void OnInitialize()
{
InstallModule(_configModule);
RegisterUtility(_probeUtility);
}
}
/// <summary>
/// 用于验证模块复用限制的最小架构。
/// </summary>
private sealed class ModuleOnlyArchitecture(GameConfigModule configModule) : Architecture
{
/// <summary>
/// 获取安装到当前测试架构中的配置模块。
/// </summary>
/// <remarks>
/// 该属性直接暴露传入的模块实例,便于测试验证同一模块实例跨架构复用时的生命周期约束。
/// </remarks>
public GameConfigModule ConfigModule => configModule;
/// <summary>
/// 获取当前模块共享的配置注册表。
/// </summary>
/// <remarks>
/// 该注册表实例在模块安装后即与架构绑定,但只有在架构完成配置首载后,
/// 其中的强类型配置表访问才应被视为可用。
/// </remarks>
public IConfigRegistry Registry => configModule.Registry;
/// <summary>
/// 安装外部传入的配置模块。
/// </summary>
protected override void OnInitialize()
{
InstallModule(configModule);
}
}
/// <summary>
/// 仅用于把架构推进到 Ready 阶段的空壳架构。
/// </summary>
private sealed class ReadyOnlyArchitecture : Architecture
{
/// <summary>
/// 该测试架构不注册任何组件,仅验证模块的安装窗口约束。
/// </summary>
protected override void OnInitialize()
{
}
}
/// <summary>
/// 在 utility 初始化阶段直接读取配置表的探针工具。
/// 如果模块没有在 utility 阶段开始前完成首次加载,这个探针会在初始化时失败。
/// </summary>
private sealed class ConfigAwareProbeUtility : AbstractContextUtility
{
/// <summary>
/// 获取一个值,指示初始化时是否已经读取到有效配置。
/// </summary>
public bool InitializedWithLoadedConfig { get; private set; }
/// <summary>
/// 获取初始化期间读取到的怪物名称。
/// </summary>
public string? ObservedMonsterName { get; private set; }
/// <summary>
/// 获取初始化期间读取到的 dungeon 阵营怪物数量。
/// </summary>
public int ObservedDungeonMonsterCount { get; private set; }
/// <summary>
/// 读取架构上下文中的配置注册表并验证目标表已经可用。
/// </summary>
protected override void OnInit()
{
#pragma warning disable GF_ContextRegistration_003
var registry = this.GetUtility<IConfigRegistry>();
#pragma warning restore GF_ContextRegistration_003
var monsterTable = registry.GetMonsterTable();
ObservedMonsterName = monsterTable.Get(1).Name;
ObservedDungeonMonsterCount = monsterTable.FindByFaction("dungeon").Count();
InitializedWithLoadedConfig = true;
}
}
/// <summary>
/// 模拟一个不会主动处理 <see cref="SynchronizationContext.Post" /> 回调的阻塞线程上下文。
/// 如果初始化链错误地捕获该上下文continuation 会永久悬挂,从而暴露同步桥接死锁。
/// </summary>
private sealed class NonPumpingSynchronizationContext : SynchronizationContext
{
/// <summary>
/// 丢弃异步投递的 continuation模拟被同步阻塞且未泵消息的宿主线程。
/// </summary>
/// <param name="d">要执行的回调。</param>
/// <param name="state">回调状态。</param>
public override void Post(SendOrPostCallback d, object? state)
{
}
}
}