From 05de6d1e15d4453fd6f939a0548f0955309e3698 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 18 Apr 2026 20:07:07 +0800
Subject: [PATCH] =?UTF-8?q?fix(review-followup):=20=E4=BF=AE=E5=A4=8DGodot?=
=?UTF-8?q?=E5=AE=89=E8=A3=85=E9=A1=BA=E5=BA=8F=E4=B8=8E=E6=97=A5=E5=BF=97?=
=?UTF-8?q?=E5=B7=A5=E5=8E=82=E9=98=B2=E5=BE=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 修复 AbstractArchitecture 在锚点未初始化时先执行模块安装的顺序问题,并收紧 GodotYamlConfigEnvironment 的目录枚举异常处理
- 修复 ConfigurableLoggerFactory 对 null 集合、调用方 minLevel 与 AsyncLogAppender 释放路径的处理
- 补充 WeakTypePairCache 与 GodotLocalizationSettingsTests 的 XML 文档,并新增日志工厂回归测试
---
.../Logging/ConfigurableLoggerFactoryTests.cs | 117 ++++++++++++++++++
.../Logging/ConfigurableLoggerFactory.cs | 24 +++-
GFramework.Cqrs/Internal/WeakTypePairCache.cs | 3 +
.../Setting/GodotLocalizationSettingsTests.cs | 12 ++
.../Architectures/AbstractArchitecture.cs | 11 +-
.../Config/GodotYamlConfigEnvironment.cs | 58 ++++++---
6 files changed, 197 insertions(+), 28 deletions(-)
create mode 100644 GFramework.Core.Tests/Logging/ConfigurableLoggerFactoryTests.cs
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;
}