mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-06 16:16:44 +08:00
fix(review-followup): 修复Godot安装顺序与日志工厂防御
- 修复 AbstractArchitecture 在锚点未初始化时先执行模块安装的顺序问题,并收紧 GodotYamlConfigEnvironment 的目录枚举异常处理 - 修复 ConfigurableLoggerFactory 对 null 集合、调用方 minLevel 与 AsyncLogAppender 释放路径的处理 - 补充 WeakTypePairCache 与 GodotLocalizationSettingsTests 的 XML 文档,并新增日志工厂回归测试
This commit is contained in:
parent
e3652db030
commit
05de6d1e15
117
GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs
Normal file
117
GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs
Normal file
@ -0,0 +1,117 @@
|
||||
using System.Reflection;
|
||||
using GFramework.Core.Abstractions.Logging;
|
||||
using GFramework.Core.Logging;
|
||||
using GFramework.Core.Logging.Appenders;
|
||||
|
||||
namespace GFramework.Core.Tests.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// 验证可配置 Logger 工厂在配置归一化、级别合并与释放路径上的行为契约。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class ConfigurableLoggerFactoryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证当反序列化结果把集合字段写成 <see langword="null" /> 时,工厂会将其归一化为空集合而不是抛出空引用异常。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void CreateFactory_ShouldNormalizeNullCollectionsFromConfiguration()
|
||||
{
|
||||
var config = LoggingConfigurationLoader.LoadFromJsonString(
|
||||
"""
|
||||
{
|
||||
"minLevel": "Warning",
|
||||
"appenders": null,
|
||||
"loggerLevels": null
|
||||
}
|
||||
""");
|
||||
|
||||
var factory = LoggingConfigurationLoader.CreateFactory(config);
|
||||
var logger = factory.GetLogger("TestLogger");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(config.Appenders, Is.Not.Null);
|
||||
Assert.That(config.LoggerLevels, Is.Not.Null);
|
||||
Assert.That(logger.IsInfoEnabled(), Is.False);
|
||||
Assert.That(logger.IsWarnEnabled(), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证调用方传入的默认最小级别会作为配置级别的下限参与最终 logger 级别计算。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetLogger_ShouldHonorStricterCallerMinLevelWhenNoOverrideMatches()
|
||||
{
|
||||
var config = LoggingConfigurationLoader.LoadFromJsonString(
|
||||
"""
|
||||
{
|
||||
"minLevel": "Info",
|
||||
"appenders": [
|
||||
{
|
||||
"type": "Console",
|
||||
"formatter": "Default",
|
||||
"useColors": false
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var factory = LoggingConfigurationLoader.CreateFactory(config);
|
||||
var logger = factory.GetLogger("TestLogger", LogLevel.Warning);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(logger.IsInfoEnabled(), Is.False);
|
||||
Assert.That(logger.IsWarnEnabled(), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证工厂释放时会兼容释放未实现 <see cref="IDisposable" /> 的 <see cref="AsyncLogAppender" />。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Dispose_ShouldDisposeAsyncLogAppenderCreatedFromConfiguration()
|
||||
{
|
||||
var config = LoggingConfigurationLoader.LoadFromJsonString(
|
||||
"""
|
||||
{
|
||||
"appenders": [
|
||||
{
|
||||
"type": "Async",
|
||||
"bufferSize": 8,
|
||||
"innerAppender": {
|
||||
"type": "Console",
|
||||
"formatter": "Default",
|
||||
"useColors": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var factory = LoggingConfigurationLoader.CreateFactory(config);
|
||||
var logger = factory.GetLogger("AsyncLogger");
|
||||
var asyncAppender = GetSingleAsyncAppender(factory);
|
||||
|
||||
logger.Info("dispose-path");
|
||||
|
||||
((IDisposable)factory).Dispose();
|
||||
|
||||
Assert.That(asyncAppender.IsCompleted, Is.True);
|
||||
}
|
||||
|
||||
private static AsyncLogAppender GetSingleAsyncAppender(ILoggerFactory factory)
|
||||
{
|
||||
var appendersField = factory.GetType().GetField("_appenders", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.That(appendersField, Is.Not.Null);
|
||||
|
||||
var appenders = appendersField!.GetValue(factory) as ILogAppender[];
|
||||
Assert.That(appenders, Is.Not.Null);
|
||||
Assert.That(appenders, Has.Length.EqualTo(1));
|
||||
Assert.That(appenders![0], Is.TypeOf<AsyncLogAppender>());
|
||||
|
||||
return (AsyncLogAppender)appenders[0];
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,11 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable
|
||||
public ConfigurableLoggerFactory(LoggingConfiguration config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_appenders = config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray();
|
||||
|
||||
// 反序列化输入可能显式把集合写成 null,这里统一归一化为可安全枚举的空集合。
|
||||
_config.Appenders ??= [];
|
||||
_config.LoggerLevels ??= new Dictionary<string, LogLevel>(StringComparer.Ordinal);
|
||||
_appenders = _config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -34,23 +38,31 @@ internal sealed class ConfigurableLoggerFactory : ILoggerFactory, IDisposable
|
||||
|
||||
foreach (var appender in _appenders)
|
||||
{
|
||||
if (appender is IDisposable disposable)
|
||||
switch (appender)
|
||||
{
|
||||
disposable.Dispose();
|
||||
case AsyncLogAppender asyncLogAppender:
|
||||
asyncLogAppender.Dispose();
|
||||
break;
|
||||
case IDisposable disposable:
|
||||
disposable.Dispose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定名称创建日志记录器,并应用最匹配的命名空间级别配置。
|
||||
/// </summary>
|
||||
/// <param name="name">日志记录器名称。</param>
|
||||
/// <param name="minLevel">调用方传入的默认最小级别。</param>
|
||||
/// <param name="minLevel">调用方要求的最小日志级别下限;最终级别不会低于该值。</param>
|
||||
/// <returns>可写入日志的记录器实例。</returns>
|
||||
/// <remarks>
|
||||
/// 当配置文件与调用方同时提供默认级别时,会取两者中更严格的那一个;
|
||||
/// 若命中更具体的命名空间级别覆盖,则以该覆盖配置为准。
|
||||
/// </remarks>
|
||||
public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
|
||||
{
|
||||
var effectiveLevel = _config.MinLevel;
|
||||
var effectiveLevel = _config.MinLevel > minLevel ? _config.MinLevel : minLevel;
|
||||
var bestMatchLength = -1;
|
||||
|
||||
foreach (var kvp in _config.LoggerLevels)
|
||||
|
||||
@ -81,6 +81,9 @@ internal sealed class WeakTypePairCache<TValue>
|
||||
/// <param name="primaryType">第一段类型键。</param>
|
||||
/// <param name="secondaryType">第二段类型键。</param>
|
||||
/// <returns>当前缓存对象,或 <see langword="null" />。</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="primaryType" /> 或 <paramref name="secondaryType" /> 为 <see langword="null" />。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 该入口仅用于测试通过反射观察缓存状态,不应用于运行时代码路径。
|
||||
/// </remarks>
|
||||
|
||||
@ -12,6 +12,10 @@ namespace GFramework.Game.Tests.Setting;
|
||||
[TestFixture]
|
||||
public sealed class GodotLocalizationSettingsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证应用英文设置时,会同时同步 Godot locale 与框架语言管理器。
|
||||
/// </summary>
|
||||
/// <returns>表示异步断言完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task ApplyAsync_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage()
|
||||
{
|
||||
@ -27,6 +31,10 @@ public sealed class GodotLocalizationSettingsTests
|
||||
manager.Verify(it => it.SetLanguage("eng"), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证应用简体中文设置时,会同时同步 Godot locale 与框架语言管理器。
|
||||
/// </summary>
|
||||
/// <returns>表示异步断言完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task ApplyAsync_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage()
|
||||
{
|
||||
@ -42,6 +50,10 @@ public sealed class GodotLocalizationSettingsTests
|
||||
manager.Verify(it => it.SetLanguage("zhs"), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证未知语言会回退到英文 locale,并同步默认框架语言代码。
|
||||
/// </summary>
|
||||
/// <returns>表示异步断言完成的任务。</returns>
|
||||
[Test]
|
||||
public async Task ApplyAsync_ShouldFallbackUnknownLanguageToEnglish()
|
||||
{
|
||||
|
||||
@ -106,17 +106,16 @@ public abstract class AbstractArchitecture(
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(module);
|
||||
|
||||
// 先确认锚点可用,避免模块安装产生副作用后再因架构未绑定场景树而失败。
|
||||
var anchor = _anchor ?? throw new InvalidOperationException("Anchor not initialized");
|
||||
|
||||
module.Install(this);
|
||||
|
||||
// 检查锚点是否已初始化,未初始化则抛出异常
|
||||
if (_anchor == null)
|
||||
throw new InvalidOperationException("Anchor not initialized");
|
||||
|
||||
// 等待锚点准备就绪,并保持 Godot 同步上下文,以便后续附加逻辑安全访问节点 API。
|
||||
await _anchor.WaitUntilReadyAsync();
|
||||
await anchor.WaitUntilReadyAsync();
|
||||
|
||||
// 延迟调用将扩展节点添加为锚点的子节点
|
||||
_anchor.CallDeferred(Node.MethodName.AddChild, module.Node);
|
||||
anchor.CallDeferred(Node.MethodName.AddChild, module.Node);
|
||||
|
||||
// 调用扩展的附加回调方法
|
||||
module.OnAttach(this);
|
||||
|
||||
@ -106,17 +106,37 @@ internal sealed class GodotYamlConfigEnvironment
|
||||
{
|
||||
if (!path.IsGodotPath())
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Directory
|
||||
.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly)
|
||||
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
|
||||
Path.GetFileName(entryPath),
|
||||
Directory.Exists(entryPath)))
|
||||
.ToArray();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// 非 Godot 路径分支与公开契约保持一致:宿主无法访问目录时返回 null,而不是泄漏底层异常。
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Directory
|
||||
.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly)
|
||||
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
|
||||
Path.GetFileName(entryPath),
|
||||
Directory.Exists(entryPath)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
using var directory = DirAccess.Open(path);
|
||||
@ -132,18 +152,24 @@ internal sealed class GodotYamlConfigEnvironment
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true)
|
||||
try
|
||||
{
|
||||
var name = directory.GetNext();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
while (true)
|
||||
{
|
||||
break;
|
||||
var name = directory.GetNext();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir()));
|
||||
}
|
||||
|
||||
entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir()));
|
||||
}
|
||||
|
||||
directory.ListDirEnd();
|
||||
finally
|
||||
{
|
||||
// 目录枚举句柄必须成对结束,避免未来循环体扩展后在异常路径上遗留引擎状态。
|
||||
directory.ListDirEnd();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user