diff --git a/GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs b/GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs new file mode 100644 index 00000000..d712cad4 --- /dev/null +++ b/GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs @@ -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; + +/// +/// 验证可配置 Logger 工厂在配置归一化、级别合并与释放路径上的行为契约。 +/// +[TestFixture] +public sealed class ConfigurableLoggerFactoryTests +{ + /// + /// 验证当反序列化结果把集合字段写成 时,工厂会将其归一化为空集合而不是抛出空引用异常。 + /// + [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); + }); + } + + /// + /// 验证调用方传入的默认最小级别会作为配置级别的下限参与最终 logger 级别计算。 + /// + [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); + }); + } + + /// + /// 验证工厂释放时会兼容释放未实现 。 + /// + [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()); + + return (AsyncLogAppender)appenders[0]; + } +} diff --git a/GFramework.Core/Logging/ConfigurableLoggerFactory.cs b/GFramework.Core/Logging/ConfigurableLoggerFactory.cs index 18c3400c..e1c5ab6f 100644 --- a/GFramework.Core/Logging/ConfigurableLoggerFactory.cs +++ b/GFramework.Core/Logging/ConfigurableLoggerFactory.cs @@ -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(StringComparer.Ordinal); + _appenders = _config.Appenders.Select(LoggingConfigurationLoader.CreateAppender).ToArray(); } /// @@ -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; } } - } /// /// 为指定名称创建日志记录器,并应用最匹配的命名空间级别配置。 /// /// 日志记录器名称。 - /// 调用方传入的默认最小级别。 + /// 调用方要求的最小日志级别下限;最终级别不会低于该值。 /// 可写入日志的记录器实例。 + /// + /// 当配置文件与调用方同时提供默认级别时,会取两者中更严格的那一个; + /// 若命中更具体的命名空间级别覆盖,则以该覆盖配置为准。 + /// 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) diff --git a/GFramework.Cqrs/Internal/WeakTypePairCache.cs b/GFramework.Cqrs/Internal/WeakTypePairCache.cs index 10aff72b..d3dd5501 100644 --- a/GFramework.Cqrs/Internal/WeakTypePairCache.cs +++ b/GFramework.Cqrs/Internal/WeakTypePairCache.cs @@ -81,6 +81,9 @@ internal sealed class WeakTypePairCache /// 第一段类型键。 /// 第二段类型键。 /// 当前缓存对象,或 + /// + /// 。 + /// /// /// 该入口仅用于测试通过反射观察缓存状态,不应用于运行时代码路径。 /// diff --git a/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs b/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs index b431867b..cb452c3d 100644 --- a/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs +++ b/GFramework.Game.Tests/Setting/GodotLocalizationSettingsTests.cs @@ -12,6 +12,10 @@ namespace GFramework.Game.Tests.Setting; [TestFixture] public sealed class GodotLocalizationSettingsTests { + /// + /// 验证应用英文设置时,会同时同步 Godot locale 与框架语言管理器。 + /// + /// 表示异步断言完成的任务。 [Test] public async Task ApplyAsync_ShouldSyncEnglishToGodotLocaleAndFrameworkLanguage() { @@ -27,6 +31,10 @@ public sealed class GodotLocalizationSettingsTests manager.Verify(it => it.SetLanguage("eng"), Times.Once); } + /// + /// 验证应用简体中文设置时,会同时同步 Godot locale 与框架语言管理器。 + /// + /// 表示异步断言完成的任务。 [Test] public async Task ApplyAsync_ShouldSyncChineseToGodotLocaleAndFrameworkLanguage() { @@ -42,6 +50,10 @@ public sealed class GodotLocalizationSettingsTests manager.Verify(it => it.SetLanguage("zhs"), Times.Once); } + /// + /// 验证未知语言会回退到英文 locale,并同步默认框架语言代码。 + /// + /// 表示异步断言完成的任务。 [Test] public async Task ApplyAsync_ShouldFallbackUnknownLanguageToEnglish() { diff --git a/GFramework.Godot/Architectures/AbstractArchitecture.cs b/GFramework.Godot/Architectures/AbstractArchitecture.cs index 621e6a9a..d92ea1ef 100644 --- a/GFramework.Godot/Architectures/AbstractArchitecture.cs +++ b/GFramework.Godot/Architectures/AbstractArchitecture.cs @@ -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); diff --git a/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs b/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs index ec98e9b7..e77d325e 100644 --- a/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs +++ b/GFramework.Godot/Config/GodotYamlConfigEnvironment.cs @@ -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; }