diff --git a/GFramework.Core.Abstractions/logging/ILogAppender.cs b/GFramework.Core.Abstractions/logging/ILogAppender.cs
new file mode 100644
index 00000000..a667d172
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/ILogAppender.cs
@@ -0,0 +1,18 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 日志输出器接口,负责将日志条目写入特定目标
+///
+public interface ILogAppender
+{
+ ///
+ /// 追加日志条目
+ ///
+ /// 日志条目
+ void Append(LogEntry entry);
+
+ ///
+ /// 刷新缓冲区,确保所有日志已写入
+ ///
+ void Flush();
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/ILogFilter.cs b/GFramework.Core.Abstractions/logging/ILogFilter.cs
new file mode 100644
index 00000000..c5e8e5c5
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/ILogFilter.cs
@@ -0,0 +1,14 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 日志过滤器接口,用于决定是否应该记录某条日志
+///
+public interface ILogFilter
+{
+ ///
+ /// 判断是否应该记录该日志条目
+ ///
+ /// 日志条目
+ /// 如果应该记录返回 true,否则返回 false
+ bool ShouldLog(LogEntry entry);
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/ILogFormatter.cs b/GFramework.Core.Abstractions/logging/ILogFormatter.cs
new file mode 100644
index 00000000..00792e16
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/ILogFormatter.cs
@@ -0,0 +1,14 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 日志格式化器接口,用于将日志条目格式化为字符串
+///
+public interface ILogFormatter
+{
+ ///
+ /// 将日志条目格式化为字符串
+ ///
+ /// 日志条目
+ /// 格式化后的日志字符串
+ string Format(LogEntry entry);
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/ILogger.cs b/GFramework.Core.Abstractions/logging/ILogger.cs
index 185d6041..72efefc2 100644
--- a/GFramework.Core.Abstractions/logging/ILogger.cs
+++ b/GFramework.Core.Abstractions/logging/ILogger.cs
@@ -309,4 +309,48 @@ public interface ILogger
void Fatal(string msg, Exception t);
#endregion
+
+ #region Generic Log Methods
+
+ ///
+ /// 使用指定的日志级别记录消息
+ ///
+ /// 日志级别
+ /// 要记录的消息字符串
+ void Log(LogLevel level, string message);
+
+ ///
+ /// 使用指定的日志级别根据格式和参数记录消息
+ ///
+ /// 日志级别
+ /// 格式字符串
+ /// 参数
+ void Log(LogLevel level, string format, object arg);
+
+ ///
+ /// 使用指定的日志级别根据格式和参数记录消息
+ ///
+ /// 日志级别
+ /// 格式字符串
+ /// 第一个参数
+ /// 第二个参数
+ void Log(LogLevel level, string format, object arg1, object arg2);
+
+ ///
+ /// 使用指定的日志级别根据格式和参数数组记录消息
+ ///
+ /// 日志级别
+ /// 格式字符串
+ /// 参数数组
+ void Log(LogLevel level, string format, params object[] arguments);
+
+ ///
+ /// 使用指定的日志级别记录消息和异常
+ ///
+ /// 日志级别
+ /// 伴随异常的消息
+ /// 要记录的异常
+ void Log(LogLevel level, string message, Exception exception);
+
+ #endregion
}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/IStructuredLogger.cs b/GFramework.Core.Abstractions/logging/IStructuredLogger.cs
new file mode 100644
index 00000000..eee7092d
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/IStructuredLogger.cs
@@ -0,0 +1,24 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 支持结构化日志的日志记录器接口
+///
+public interface IStructuredLogger : ILogger
+{
+ ///
+ /// 使用指定的日志级别记录消息和结构化属性
+ ///
+ /// 日志级别
+ /// 日志消息
+ /// 结构化属性键值对
+ void Log(LogLevel level, string message, params (string Key, object? Value)[] properties);
+
+ ///
+ /// 使用指定的日志级别记录消息、异常和结构化属性
+ ///
+ /// 日志级别
+ /// 日志消息
+ /// 异常对象
+ /// 结构化属性键值对
+ void Log(LogLevel level, string message, Exception? exception, params (string Key, object? Value)[] properties);
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/LogContext.cs b/GFramework.Core.Abstractions/logging/LogContext.cs
new file mode 100644
index 00000000..6effb1d4
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/LogContext.cs
@@ -0,0 +1,117 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 日志上下文,用于在异步流中传递结构化属性
+///
+public sealed class LogContext : IDisposable
+{
+ private static readonly AsyncLocal?> _context = new();
+ private readonly bool _hadPreviousValue;
+ private readonly string _key;
+ private readonly object? _previousValue;
+
+ private LogContext(string key, object? value)
+ {
+ _key = key;
+
+ var current = _context.Value;
+ if (current?.TryGetValue(key, out var prev) == true)
+ {
+ _previousValue = prev;
+ _hadPreviousValue = true;
+ }
+
+ EnsureContext();
+ _context.Value![key] = value;
+ }
+
+ ///
+ /// 获取当前上下文中的所有属性
+ ///
+ public static IReadOnlyDictionary Current
+ {
+ get
+ {
+ var context = _context.Value;
+ return context ??
+ (IReadOnlyDictionary)new Dictionary(StringComparer.Ordinal);
+ }
+ }
+
+ ///
+ /// 释放上下文,恢复之前的值
+ ///
+ public void Dispose()
+ {
+ var current = _context.Value;
+ if (current == null) return;
+
+ if (_hadPreviousValue)
+ {
+ current[_key] = _previousValue;
+ }
+ else
+ {
+ current.Remove(_key);
+ if (current.Count == 0)
+ {
+ _context.Value = null;
+ }
+ }
+ }
+
+ ///
+ /// 向当前上下文添加一个属性
+ ///
+ /// 属性键
+ /// 属性值
+ /// 可释放的上下文对象,释放时会恢复之前的值
+ public static IDisposable Push(string key, object? value)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
+
+ return new LogContext(key, value);
+ }
+
+ ///
+ /// 向当前上下文添加多个属性
+ ///
+ /// 属性键值对
+ /// 可释放的上下文对象,释放时会恢复之前的值
+ public static IDisposable PushProperties(params (string Key, object? Value)[] properties)
+ {
+ if (properties == null || properties.Length == 0)
+ throw new ArgumentException("Properties cannot be null or empty.", nameof(properties));
+
+ return new CompositeDisposable(properties.Select(p => Push(p.Key, p.Value)).ToArray());
+ }
+
+ ///
+ /// 清除当前上下文中的所有属性
+ ///
+ public static void Clear()
+ {
+ _context.Value = null;
+ }
+
+ private static void EnsureContext()
+ {
+ _context.Value ??= new Dictionary(StringComparer.Ordinal);
+ }
+
+ ///
+ /// 组合多个可释放对象
+ ///
+ private sealed class CompositeDisposable(IDisposable[] disposables) : IDisposable
+ {
+ public void Dispose()
+ {
+ // 按相反顺序释放
+ for (int i = disposables.Length - 1; i >= 0; i--)
+ {
+ disposables[i].Dispose();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/logging/LogEntry.cs b/GFramework.Core.Abstractions/logging/LogEntry.cs
new file mode 100644
index 00000000..740353e8
--- /dev/null
+++ b/GFramework.Core.Abstractions/logging/LogEntry.cs
@@ -0,0 +1,37 @@
+namespace GFramework.Core.Abstractions.logging;
+
+///
+/// 日志条目,包含完整的日志信息
+///
+public sealed record LogEntry(
+ DateTime Timestamp,
+ LogLevel Level,
+ string LoggerName,
+ string Message,
+ Exception? Exception,
+ IReadOnlyDictionary? Properties)
+{
+ ///
+ /// 获取合并了上下文属性的所有属性
+ ///
+ /// 包含日志属性和上下文属性的字典
+ public IReadOnlyDictionary GetAllProperties()
+ {
+ var contextProps = LogContext.Current;
+
+ if (Properties == null || Properties.Count == 0)
+ return contextProps;
+
+ if (contextProps.Count == 0)
+ return Properties;
+
+ // 合并属性,日志属性优先
+ var merged = new Dictionary(contextProps, StringComparer.Ordinal);
+ foreach (var prop in Properties)
+ {
+ merged[prop.Key] = prop.Value;
+ }
+
+ return merged;
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs b/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs
new file mode 100644
index 00000000..eb5bd3df
--- /dev/null
+++ b/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs
@@ -0,0 +1,219 @@
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.appenders;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 AsyncLogAppender 的功能和行为
+///
+[TestFixture]
+public class AsyncLogAppenderTests
+{
+ [Test]
+ public void Constructor_WithNullInnerAppender_ShouldThrowArgumentNullException()
+ {
+ Assert.Throws(() => new AsyncLogAppender(null!));
+ }
+
+ [Test]
+ public void Constructor_WithInvalidBufferSize_ShouldThrowArgumentException()
+ {
+ var innerAppender = new TestAppender();
+ Assert.Throws(() => new AsyncLogAppender(innerAppender, bufferSize: 0));
+ Assert.Throws(() => new AsyncLogAppender(innerAppender, bufferSize: -1));
+ }
+
+ [Test]
+ public void Append_ShouldNotBlock()
+ {
+ var innerAppender = new SlowAppender(delayMs: 100);
+ using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
+
+ var startTime = DateTime.UtcNow;
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+
+ var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
+
+ // 异步写入应该非常快(< 100ms),不应该等待内部 Appender
+ Assert.That(elapsed, Is.LessThan(100));
+ }
+
+ [Test]
+ public void Append_ShouldEventuallyWriteToInnerAppender()
+ {
+ var innerAppender = new TestAppender();
+ using (var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000))
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+
+ asyncAppender.Flush();
+ }
+
+ Assert.That(innerAppender.Entries.Count, Is.EqualTo(10));
+ }
+
+ [Test]
+ public void Flush_ShouldWaitForAllEntriesToBeProcessed()
+ {
+ var innerAppender = new TestAppender();
+ using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
+
+ for (int i = 0; i < 100; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+
+ asyncAppender.Flush();
+
+ Assert.That(innerAppender.Entries.Count, Is.EqualTo(100));
+ }
+
+ [Test]
+ public void Dispose_ShouldProcessRemainingEntries()
+ {
+ var innerAppender = new TestAppender();
+ using (var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000))
+ {
+ for (int i = 0; i < 50; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+ } // Dispose 会等待所有日志处理完成
+
+ Assert.That(innerAppender.Entries.Count, Is.EqualTo(50));
+ }
+
+ [Test]
+ public void Append_AfterDispose_ShouldThrowObjectDisposedException()
+ {
+ var innerAppender = new TestAppender();
+ var asyncAppender = new AsyncLogAppender(innerAppender);
+ asyncAppender.Dispose();
+
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, null);
+
+ Assert.Throws(() => asyncAppender.Append(entry));
+ }
+
+ [Test]
+ public void PendingCount_ShouldReflectQueuedEntries()
+ {
+ var innerAppender = new SlowAppender(delayMs: 50);
+ using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
+
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+
+ // 应该有一些待处理的条目
+ Assert.That(asyncAppender.PendingCount, Is.GreaterThanOrEqualTo(0));
+ }
+
+ [Test]
+ public async Task Append_FromMultipleThreads_ShouldHandleConcurrency()
+ {
+ var innerAppender = new TestAppender();
+ using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 10000);
+
+ var tasks = new Task[10];
+ for (int t = 0; t < 10; t++)
+ {
+ int threadId = t;
+ tasks[t] = Task.Run(() =>
+ {
+ for (int i = 0; i < 100; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger",
+ $"Thread {threadId} Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+ });
+ }
+
+ await Task.WhenAll(tasks);
+ asyncAppender.Flush();
+
+ Assert.That(innerAppender.Entries.Count, Is.EqualTo(1000));
+ }
+
+ [Test]
+ public void Append_WhenInnerAppenderThrows_ShouldNotCrash()
+ {
+ var innerAppender = new ThrowingAppender();
+ using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
+
+ // 即使内部 Appender 抛出异常,也不应该影响调用线程
+ Assert.DoesNotThrow(() =>
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+ });
+
+ Thread.Sleep(100); // 等待后台处理
+ }
+
+ // 辅助测试类
+ private class TestAppender : ILogAppender
+ {
+ public List Entries { get; } = new();
+
+ public void Append(LogEntry entry)
+ {
+ lock (Entries)
+ {
+ Entries.Add(entry);
+ }
+ }
+
+ public void Flush()
+ {
+ }
+ }
+
+ private class SlowAppender : ILogAppender
+ {
+ private readonly int _delayMs;
+
+ public SlowAppender(int delayMs)
+ {
+ _delayMs = delayMs;
+ }
+
+ public void Append(LogEntry entry)
+ {
+ Thread.Sleep(_delayMs);
+ }
+
+ public void Flush()
+ {
+ }
+ }
+
+ private class ThrowingAppender : ILogAppender
+ {
+ public void Append(LogEntry entry)
+ {
+ throw new InvalidOperationException("Test exception");
+ }
+
+ public void Flush()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs b/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs
new file mode 100644
index 00000000..18a6bac5
--- /dev/null
+++ b/GFramework.Core.Tests/logging/CachedLoggerFactoryTests.cs
@@ -0,0 +1,95 @@
+using System.IO;
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 CachedLoggerFactory 的功能和行为
+///
+[TestFixture]
+public class CachedLoggerFactoryTests
+{
+ [Test]
+ public void Constructor_WithNullInnerFactory_ShouldThrowArgumentNullException()
+ {
+ Assert.Throws(() => new CachedLoggerFactory(null!));
+ }
+
+ [Test]
+ public void GetLogger_WithSameNameAndLevel_ShouldReturnSameInstance()
+ {
+ var innerFactory = new ConsoleLoggerFactory();
+ var cachedFactory = new CachedLoggerFactory(innerFactory);
+
+ var logger1 = cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+ var logger2 = cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+
+ Assert.That(logger1, Is.SameAs(logger2));
+ }
+
+ [Test]
+ public void GetLogger_WithDifferentNames_ShouldReturnDifferentInstances()
+ {
+ var innerFactory = new ConsoleLoggerFactory();
+ var cachedFactory = new CachedLoggerFactory(innerFactory);
+
+ var logger1 = cachedFactory.GetLogger("Logger1", LogLevel.Info);
+ var logger2 = cachedFactory.GetLogger("Logger2", LogLevel.Info);
+
+ Assert.That(logger1, Is.Not.SameAs(logger2));
+ }
+
+ [Test]
+ public void GetLogger_WithDifferentLevels_ShouldReturnDifferentInstances()
+ {
+ var innerFactory = new ConsoleLoggerFactory();
+ var cachedFactory = new CachedLoggerFactory(innerFactory);
+
+ var logger1 = cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+ var logger2 = cachedFactory.GetLogger("TestLogger", LogLevel.Debug);
+
+ Assert.That(logger1, Is.Not.SameAs(logger2));
+ }
+
+ [Test]
+ public void GetLogger_MultipleCalls_ShouldOnlyCreateOnce()
+ {
+ var trackingFactory = new TrackingLoggerFactory();
+ var cachedFactory = new CachedLoggerFactory(trackingFactory);
+
+ cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+ cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+ cachedFactory.GetLogger("TestLogger", LogLevel.Info);
+
+ Assert.That(trackingFactory.CreateCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void GetLogger_WithMultipleNamesAndLevels_ShouldCacheCorrectly()
+ {
+ var trackingFactory = new TrackingLoggerFactory();
+ var cachedFactory = new CachedLoggerFactory(trackingFactory);
+
+ cachedFactory.GetLogger("Logger1", LogLevel.Info);
+ cachedFactory.GetLogger("Logger1", LogLevel.Debug);
+ cachedFactory.GetLogger("Logger2", LogLevel.Info);
+ cachedFactory.GetLogger("Logger1", LogLevel.Info); // 缓存命中
+ cachedFactory.GetLogger("Logger2", LogLevel.Info); // 缓存命中
+
+ Assert.That(trackingFactory.CreateCount, Is.EqualTo(3));
+ }
+
+ // 辅助测试类
+ private class TrackingLoggerFactory : ILoggerFactory
+ {
+ public int CreateCount { get; private set; }
+
+ public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info)
+ {
+ CreateCount++;
+ return new ConsoleLogger(name, minLevel, new StringWriter(), false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/CompositeFilterTests.cs b/GFramework.Core.Tests/logging/CompositeFilterTests.cs
new file mode 100644
index 00000000..4e7416dc
--- /dev/null
+++ b/GFramework.Core.Tests/logging/CompositeFilterTests.cs
@@ -0,0 +1,102 @@
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.filters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 CompositeFilter 的功能和行为
+///
+[TestFixture]
+public class CompositeFilterTests
+{
+ [Test]
+ public void Constructor_WithNullFilters_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new CompositeFilter(null!));
+ }
+
+ [Test]
+ public void Constructor_WithEmptyFilters_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new CompositeFilter(Array.Empty()));
+ }
+
+ [Test]
+ public void ShouldLog_WithAllFiltersReturningTrue_ShouldReturnTrue()
+ {
+ var filter1 = new LogLevelFilter(LogLevel.Info);
+ var filter2 = new NamespaceFilter("GFramework");
+ var compositeFilter = new CompositeFilter(filter1, filter2);
+
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null);
+
+ Assert.That(compositeFilter.ShouldLog(entry), Is.True);
+ }
+
+ [Test]
+ public void ShouldLog_WithOneFilterReturningFalse_ShouldReturnFalse()
+ {
+ var filter1 = new LogLevelFilter(LogLevel.Warning); // 要求 Warning 以上
+ var filter2 = new NamespaceFilter("GFramework");
+ var compositeFilter = new CompositeFilter(filter1, filter2);
+
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null);
+
+ Assert.That(compositeFilter.ShouldLog(entry), Is.False);
+ }
+
+ [Test]
+ public void ShouldLog_WithAllFiltersReturningFalse_ShouldReturnFalse()
+ {
+ var filter1 = new LogLevelFilter(LogLevel.Warning);
+ var filter2 = new NamespaceFilter("MyApp");
+ var compositeFilter = new CompositeFilter(filter1, filter2);
+
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null);
+
+ Assert.That(compositeFilter.ShouldLog(entry), Is.False);
+ }
+
+ [Test]
+ public void ShouldLog_WithMultipleFilters_ShouldApplyAndLogic()
+ {
+ var levelFilter = new LogLevelFilter(LogLevel.Info);
+ var namespaceFilter = new NamespaceFilter("GFramework");
+ var compositeFilter = new CompositeFilter(levelFilter, namespaceFilter);
+
+ // 满足所有条件
+ var entry1 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry1), Is.True);
+
+ // 级别不满足
+ var entry2 = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "GFramework.Core", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry2), Is.False);
+
+ // 命名空间不满足
+ var entry3 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "OtherNamespace", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry3), Is.False);
+
+ // 都不满足
+ var entry4 = new LogEntry(DateTime.UtcNow, LogLevel.Debug, "OtherNamespace", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry4), Is.False);
+ }
+
+ [Test]
+ public void ShouldLog_WithNestedCompositeFilters_ShouldWork()
+ {
+ var filter1 = new LogLevelFilter(LogLevel.Info);
+ var filter2 = new NamespaceFilter("GFramework");
+ var innerComposite = new CompositeFilter(filter1, filter2);
+
+ var filter3 = new LogLevelFilter(LogLevel.Warning);
+ var outerComposite = new CompositeFilter(innerComposite, filter3);
+
+ // 需要同时满足:Info 以上 AND GFramework 命名空间 AND Warning 以上
+ var entry1 = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "GFramework.Core", "Test", null, null);
+ Assert.That(outerComposite.ShouldLog(entry1), Is.True);
+
+ var entry2 = new LogEntry(DateTime.UtcNow, LogLevel.Info, "GFramework.Core", "Test", null, null);
+ Assert.That(outerComposite.ShouldLog(entry2), Is.False); // 不满足 Warning
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/CompositeLoggerTests.cs b/GFramework.Core.Tests/logging/CompositeLoggerTests.cs
new file mode 100644
index 00000000..cbf355c4
--- /dev/null
+++ b/GFramework.Core.Tests/logging/CompositeLoggerTests.cs
@@ -0,0 +1,182 @@
+using System.IO;
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging;
+using GFramework.Core.logging.appenders;
+using GFramework.Core.logging.formatters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 CompositeLogger 的功能和行为
+///
+[TestFixture]
+public class CompositeLoggerTests
+{
+ [Test]
+ public void Constructor_WithNullAppenders_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new CompositeLogger("Test", LogLevel.Info, null!));
+ }
+
+ [Test]
+ public void Constructor_WithEmptyAppenders_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new CompositeLogger("Test", LogLevel.Info, Array.Empty()));
+ }
+
+ [Test]
+ public void Write_ShouldWriteToAllAppenders()
+ {
+ var writer1 = new StringWriter();
+ var writer2 = new StringWriter();
+ var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false);
+ var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false);
+
+ using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2);
+
+ logger.Info("Test message");
+
+ var output1 = writer1.ToString();
+ var output2 = writer2.ToString();
+
+ Assert.That(output1, Does.Contain("Test message"));
+ Assert.That(output2, Does.Contain("Test message"));
+
+ writer1.Dispose();
+ writer2.Dispose();
+ }
+
+ [Test]
+ public void Log_WithStructuredProperties_ShouldWriteToAllAppenders()
+ {
+ var writer1 = new StringWriter();
+ var writer2 = new StringWriter();
+ var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false);
+ var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false);
+
+ using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2);
+
+ logger.Log(LogLevel.Info, "User action", ("UserId", 12345), ("Action", "Login"));
+
+ var output1 = writer1.ToString();
+ var output2 = writer2.ToString();
+
+ Assert.That(output1, Does.Contain("User action"));
+ Assert.That(output1, Does.Contain("UserId=12345"));
+ Assert.That(output2, Does.Contain("User action"));
+ Assert.That(output2, Does.Contain("UserId=12345"));
+
+ writer1.Dispose();
+ writer2.Dispose();
+ }
+
+ [Test]
+ public void Log_WithException_ShouldWriteToAllAppenders()
+ {
+ var writer1 = new StringWriter();
+ var writer2 = new StringWriter();
+ var appender1 = new ConsoleAppender(new DefaultLogFormatter(), writer1, useColors: false);
+ var appender2 = new ConsoleAppender(new DefaultLogFormatter(), writer2, useColors: false);
+
+ using var logger = new CompositeLogger("TestLogger", LogLevel.Info, appender1, appender2);
+
+ var exception = new InvalidOperationException("Test exception");
+ logger.Log(LogLevel.Error, "Error occurred", exception, ("ErrorCode", 500));
+
+ var output1 = writer1.ToString();
+ var output2 = writer2.ToString();
+
+ Assert.That(output1, Does.Contain("Error occurred"));
+ Assert.That(output1, Does.Contain("InvalidOperationException"));
+ Assert.That(output2, Does.Contain("Error occurred"));
+ Assert.That(output2, Does.Contain("InvalidOperationException"));
+
+ writer1.Dispose();
+ writer2.Dispose();
+ }
+
+ [Test]
+ public void Flush_ShouldFlushAllAppenders()
+ {
+ var testAppender1 = new TestFlushAppender();
+ var testAppender2 = new TestFlushAppender();
+
+ using var logger = new CompositeLogger("TestLogger", LogLevel.Info, testAppender1, testAppender2);
+
+ logger.Info("Test message");
+ logger.Flush();
+
+ Assert.That(testAppender1.FlushCalled, Is.True);
+ Assert.That(testAppender2.FlushCalled, Is.True);
+ }
+
+ [Test]
+ public void Dispose_ShouldDisposeAllAppenders()
+ {
+ var testAppender1 = new TestDisposableAppender();
+ var testAppender2 = new TestDisposableAppender();
+
+ var logger = new CompositeLogger("TestLogger", LogLevel.Info, testAppender1, testAppender2);
+ logger.Dispose();
+
+ Assert.That(testAppender1.DisposeCalled, Is.True);
+ Assert.That(testAppender2.DisposeCalled, Is.True);
+ }
+
+ [Test]
+ public void Write_WithLevelFiltering_ShouldRespectMinLevel()
+ {
+ var writer = new StringWriter();
+ var appender = new ConsoleAppender(new DefaultLogFormatter(), writer, useColors: false);
+
+ using var logger = new CompositeLogger("TestLogger", LogLevel.Warning, appender);
+
+ logger.Debug("Debug message");
+ logger.Info("Info message");
+ logger.Warn("Warning message");
+ logger.Error("Error message");
+
+ var output = writer.ToString();
+
+ Assert.That(output, Does.Not.Contain("Debug message"));
+ Assert.That(output, Does.Not.Contain("Info message"));
+ Assert.That(output, Does.Contain("Warning message"));
+ Assert.That(output, Does.Contain("Error message"));
+
+ writer.Dispose();
+ }
+
+ // 辅助测试类
+ private class TestFlushAppender : ILogAppender
+ {
+ public bool FlushCalled { get; private set; }
+
+ public void Append(LogEntry entry)
+ {
+ }
+
+ public void Flush()
+ {
+ FlushCalled = true;
+ }
+ }
+
+ private class TestDisposableAppender : ILogAppender, IDisposable
+ {
+ public bool DisposeCalled { get; private set; }
+
+ public void Dispose()
+ {
+ DisposeCalled = true;
+ }
+
+ public void Append(LogEntry entry)
+ {
+ }
+
+ public void Flush()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs b/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs
new file mode 100644
index 00000000..c170fd28
--- /dev/null
+++ b/GFramework.Core.Tests/logging/ConsoleAppenderTests.cs
@@ -0,0 +1,109 @@
+using System.IO;
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.appenders;
+using GFramework.Core.logging.filters;
+using GFramework.Core.logging.formatters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 ConsoleAppender 的功能和行为
+///
+[TestFixture]
+public class ConsoleAppenderTests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ _stringWriter = new StringWriter();
+ _appender = new ConsoleAppender(new DefaultLogFormatter(), _stringWriter, useColors: false);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _appender?.Dispose();
+ _stringWriter?.Dispose();
+ }
+
+ private StringWriter _stringWriter = null!;
+ private ConsoleAppender _appender = null!;
+
+ [Test]
+ public void Constructor_WithNullFormatter_ShouldThrowArgumentNullException()
+ {
+ Assert.Throws(() => new ConsoleAppender(null!));
+ }
+
+ [Test]
+ public void Append_ShouldWriteToWriter()
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ _appender.Append(entry);
+
+ var output = _stringWriter.ToString();
+ Assert.That(output, Does.Contain("Test message"));
+ Assert.That(output, Does.Contain("INFO"));
+ }
+
+ [Test]
+ public void Append_WithFilter_ShouldRespectFilter()
+ {
+ var filter = new LogLevelFilter(LogLevel.Warning);
+ var appender = new ConsoleAppender(new DefaultLogFormatter(), _stringWriter, useColors: false, filter: filter);
+
+ var infoEntry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Info message", null, null);
+ var warningEntry = new LogEntry(DateTime.UtcNow, LogLevel.Warning, "TestLogger", "Warning message", null, null);
+
+ appender.Append(infoEntry);
+ appender.Append(warningEntry);
+
+ var output = _stringWriter.ToString();
+ Assert.That(output, Does.Not.Contain("Info message"));
+ Assert.That(output, Does.Contain("Warning message"));
+
+ appender.Dispose();
+ }
+
+ [Test]
+ public void Flush_ShouldFlushWriter()
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ _appender.Append(entry);
+ _appender.Flush();
+
+ var output = _stringWriter.ToString();
+ Assert.That(output, Does.Contain("Test message"));
+ }
+
+ [Test]
+ public void Dispose_ShouldFlushWriter()
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ _appender.Append(entry);
+ _appender.Dispose();
+
+ var output = _stringWriter.ToString();
+ Assert.That(output, Does.Contain("Test message"));
+ }
+
+ [Test]
+ public void Append_MultipleEntries_ShouldWriteAll()
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ _appender.Append(entry);
+ }
+
+ var output = _stringWriter.ToString();
+ for (int i = 0; i < 10; i++)
+ {
+ Assert.That(output, Does.Contain($"Message {i}"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs b/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs
new file mode 100644
index 00000000..19aec517
--- /dev/null
+++ b/GFramework.Core.Tests/logging/DefaultLogFormatterTests.cs
@@ -0,0 +1,117 @@
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.formatters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 DefaultLogFormatter 的功能和行为
+///
+[TestFixture]
+public class DefaultLogFormatterTests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ _formatter = new DefaultLogFormatter();
+ }
+
+ private DefaultLogFormatter _formatter = null!;
+
+ [Test]
+ public void Format_WithBasicEntry_ShouldFormatCorrectly()
+ {
+ var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123);
+ var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain("[2026-02-26 10:30:45.123]"));
+ Assert.That(result, Does.Contain("INFO"));
+ Assert.That(result, Does.Contain("[TestLogger]"));
+ Assert.That(result, Does.Contain("Test message"));
+ }
+
+ [Test]
+ public void Format_WithException_ShouldIncludeException()
+ {
+ var exception = new InvalidOperationException("Test exception");
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Error, "TestLogger", "Error occurred", exception, null);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain("Error occurred"));
+ Assert.That(result, Does.Contain("InvalidOperationException"));
+ Assert.That(result, Does.Contain("Test exception"));
+ }
+
+ [Test]
+ public void Format_WithProperties_ShouldIncludeProperties()
+ {
+ var properties = new Dictionary
+ {
+ ["UserId"] = 12345,
+ ["UserName"] = "TestUser"
+ };
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "User action", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain("User action"));
+ Assert.That(result, Does.Contain("|"));
+ Assert.That(result, Does.Contain("UserId=12345"));
+ Assert.That(result, Does.Contain("UserName=TestUser"));
+ }
+
+ [Test]
+ public void Format_WithNullProperty_ShouldHandleNull()
+ {
+ var properties = new Dictionary
+ {
+ ["Key1"] = null
+ };
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain("Key1="));
+ }
+
+ [Test]
+ public void Format_WithAllLogLevels_ShouldFormatCorrectly()
+ {
+ var levels = new[]
+ { LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal };
+ var expectedStrings = new[] { "TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "FATAL" };
+
+ for (int i = 0; i < levels.Length; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, levels[i], "TestLogger", "Test", null, null);
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain(expectedStrings[i]));
+ }
+ }
+
+ [Test]
+ public void Format_WithLongMessage_ShouldNotTruncate()
+ {
+ var longMessage = new string('A', 1000);
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", longMessage, null, null);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain(longMessage));
+ }
+
+ [Test]
+ public void Format_WithSpecialCharacters_ShouldPreserveCharacters()
+ {
+ var message = "Test\nNew\tLine\r\nSpecial: <>&\"'";
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", message, null, null);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(result, Does.Contain(message));
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/FileAppenderTests.cs b/GFramework.Core.Tests/logging/FileAppenderTests.cs
new file mode 100644
index 00000000..9906d778
--- /dev/null
+++ b/GFramework.Core.Tests/logging/FileAppenderTests.cs
@@ -0,0 +1,175 @@
+using System.IO;
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.appenders;
+using GFramework.Core.logging.formatters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 FileAppender 的功能和行为
+///
+[TestFixture]
+public class FileAppenderTests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ _testFilePath = Path.Combine(Path.GetTempPath(), $"test_log_{Guid.NewGuid()}.log");
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (File.Exists(_testFilePath))
+ {
+ try
+ {
+ File.Delete(_testFilePath);
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private string _testFilePath = null!;
+
+ [Test]
+ public void Constructor_WithNullFilePath_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new FileAppender(null!));
+ }
+
+ [Test]
+ public void Constructor_WithEmptyFilePath_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => new FileAppender(""));
+ }
+
+ [Test]
+ public void Constructor_WhenDirectoryDoesNotExist_ShouldCreateIt()
+ {
+ var dirPath = Path.Combine(Path.GetTempPath(), $"test_dir_{Guid.NewGuid()}");
+ var filePath = Path.Combine(dirPath, "test.log");
+
+ try
+ {
+ using (new FileAppender(filePath))
+ {
+ Assert.That(Directory.Exists(dirPath), Is.True);
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(dirPath))
+ {
+ Directory.Delete(dirPath, true);
+ }
+ }
+ }
+
+ [Test]
+ public void Append_ShouldWriteToFile()
+ {
+ using (var appender = new FileAppender(_testFilePath))
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+ appender.Append(entry);
+ appender.Flush();
+ }
+
+ var content = File.ReadAllText(_testFilePath);
+ Assert.That(content, Does.Contain("Test message"));
+ Assert.That(content, Does.Contain("INFO"));
+ }
+
+ [Test]
+ public void Append_MultipleEntries_ShouldWriteAll()
+ {
+ using (var appender = new FileAppender(_testFilePath))
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ appender.Append(entry);
+ }
+
+ appender.Flush();
+ }
+
+ var lines = File.ReadAllLines(_testFilePath);
+ Assert.That(lines.Length, Is.EqualTo(10));
+ for (int i = 0; i < 10; i++)
+ {
+ Assert.That(lines[i], Does.Contain($"Message {i}"));
+ }
+ }
+
+ [Test]
+ public void Append_WithJsonFormatter_ShouldWriteJson()
+ {
+ using (var appender = new FileAppender(_testFilePath, new JsonLogFormatter()))
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+ appender.Append(entry);
+ appender.Flush();
+ }
+
+ var content = File.ReadAllText(_testFilePath);
+ Assert.That(content, Does.Contain("\"message\""));
+ Assert.That(content, Does.Contain("\"level\""));
+ }
+
+ [Test]
+ public void Append_AfterDispose_ShouldThrowObjectDisposedException()
+ {
+ var appender = new FileAppender(_testFilePath);
+ appender.Dispose();
+
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ Assert.Throws(() => appender.Append(entry));
+ }
+
+ [Test]
+ public void Append_WithAppendMode_ShouldAppendToExistingFile()
+ {
+ // 第一次写入
+ using (var appender1 = new FileAppender(_testFilePath))
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "First message", null, null);
+ appender1.Append(entry);
+ appender1.Flush();
+ }
+
+ // 第二次写入
+ using (var appender2 = new FileAppender(_testFilePath))
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Second message", null, null);
+ appender2.Append(entry);
+ appender2.Flush();
+ }
+
+ var lines = File.ReadAllLines(_testFilePath);
+ Assert.That(lines.Length, Is.EqualTo(2));
+ Assert.That(lines[0], Does.Contain("First message"));
+ Assert.That(lines[1], Does.Contain("Second message"));
+ }
+
+ [Test]
+ public void Flush_ShouldEnsureDataWritten()
+ {
+ using (var appender = new FileAppender(_testFilePath))
+ {
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ appender.Append(entry);
+ appender.Flush();
+ }
+
+ // 立即读取文件应该能看到内容
+ var content = File.ReadAllText(_testFilePath);
+ Assert.That(content, Does.Contain("Test message"));
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs b/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs
new file mode 100644
index 00000000..9c026a4a
--- /dev/null
+++ b/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs
@@ -0,0 +1,187 @@
+using System.Text.Json;
+using GFramework.Core.Abstractions.logging;
+using GFramework.Core.logging.formatters;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 JsonLogFormatter 的功能和行为
+///
+[TestFixture]
+public class JsonLogFormatterTests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ _formatter = new JsonLogFormatter();
+ }
+
+ private JsonLogFormatter _formatter = null!;
+
+ [Test]
+ public void Format_WithBasicEntry_ShouldProduceValidJson()
+ {
+ var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123, DateTimeKind.Utc);
+ var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test message", null, null);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(() => JsonDocument.Parse(result), Throws.Nothing);
+
+ var doc = JsonDocument.Parse(result);
+ Assert.That(doc.RootElement.GetProperty("level").GetString(), Is.EqualTo("INFO"));
+ Assert.That(doc.RootElement.GetProperty("logger").GetString(), Is.EqualTo("TestLogger"));
+ Assert.That(doc.RootElement.GetProperty("message").GetString(), Is.EqualTo("Test message"));
+ }
+
+ [Test]
+ public void Format_WithException_ShouldIncludeExceptionDetails()
+ {
+ var exception = new InvalidOperationException("Test exception");
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Error, "TestLogger", "Error occurred", exception, null);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ var exceptionObj = doc.RootElement.GetProperty("exception");
+
+ Assert.That(exceptionObj.GetProperty("type").GetString(), Does.Contain("InvalidOperationException"));
+ Assert.That(exceptionObj.GetProperty("message").GetString(), Is.EqualTo("Test exception"));
+ Assert.That(exceptionObj.TryGetProperty("stackTrace", out _), Is.True);
+ }
+
+ [Test]
+ public void Format_WithProperties_ShouldIncludePropertiesObject()
+ {
+ var properties = new Dictionary
+ {
+ ["UserId"] = 12345,
+ ["UserName"] = "TestUser",
+ ["IsActive"] = true
+ };
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "User action", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ if (doc.RootElement.TryGetProperty("properties", out var propsObj))
+ {
+ // 使用 TryGetProperty 来安全访问属性
+ Assert.That(
+ propsObj.TryGetProperty("userId", out var userIdProp) ||
+ propsObj.TryGetProperty("UserId", out userIdProp), Is.True,
+ $"userId/UserId not found. Available properties: {string.Join(", ", propsObj.EnumerateObject().Select(p => p.Name))}");
+ Assert.That(userIdProp.GetInt32(), Is.EqualTo(12345));
+
+ Assert.That(
+ propsObj.TryGetProperty("userName", out var userNameProp) ||
+ propsObj.TryGetProperty("UserName", out userNameProp), Is.True);
+ Assert.That(userNameProp.GetString(), Is.EqualTo("TestUser"));
+
+ Assert.That(
+ propsObj.TryGetProperty("isActive", out var isActiveProp) ||
+ propsObj.TryGetProperty("IsActive", out isActiveProp), Is.True);
+ Assert.That(isActiveProp.GetBoolean(), Is.True);
+ }
+ else
+ {
+ Assert.Fail($"Properties object should be present when properties are provided. JSON: {result}");
+ }
+ }
+
+ [Test]
+ public void Format_WithNullProperty_ShouldHandleNull()
+ {
+ var properties = new Dictionary
+ {
+ ["Key1"] = null,
+ ["Key2"] = "value"
+ };
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test message", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ if (doc.RootElement.TryGetProperty("properties", out var propsObj))
+ {
+ // 使用 TryGetProperty 来安全访问属性
+ Assert.That(
+ propsObj.TryGetProperty("key1", out var key1Prop) || propsObj.TryGetProperty("Key1", out key1Prop),
+ Is.True,
+ $"key1/Key1 not found. Available properties: {string.Join(", ", propsObj.EnumerateObject().Select(p => p.Name))}");
+ Assert.That(key1Prop.ValueKind, Is.EqualTo(JsonValueKind.Null));
+
+ Assert.That(
+ propsObj.TryGetProperty("key2", out var key2Prop) || propsObj.TryGetProperty("Key2", out key2Prop),
+ Is.True);
+ Assert.That(key2Prop.GetString(), Is.EqualTo("value"));
+ }
+ else
+ {
+ Assert.Fail($"Properties object should be present when properties are provided. JSON: {result}");
+ }
+ }
+
+ [Test]
+ public void Format_WithAllLogLevels_ShouldFormatCorrectly()
+ {
+ var levels = new[]
+ { LogLevel.Trace, LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error, LogLevel.Fatal };
+ var expectedStrings = new[] { "TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "FATAL" };
+
+ for (int i = 0; i < levels.Length; i++)
+ {
+ var entry = new LogEntry(DateTime.UtcNow, levels[i], "TestLogger", "Test", null, null);
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ Assert.That(doc.RootElement.GetProperty("level").GetString(), Is.EqualTo(expectedStrings[i]));
+ }
+ }
+
+ [Test]
+ public void Format_WithSpecialCharacters_ShouldEscapeCorrectly()
+ {
+ var message = "Test \"quoted\" and \n newline";
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", message, null, null);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ Assert.That(doc.RootElement.GetProperty("message").GetString(), Is.EqualTo(message));
+ }
+
+ [Test]
+ public void Format_ShouldUseIso8601Timestamp()
+ {
+ var timestamp = new DateTime(2026, 2, 26, 10, 30, 45, 123, DateTimeKind.Utc);
+ var entry = new LogEntry(timestamp, LogLevel.Info, "TestLogger", "Test", null, null);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ var timestampStr = doc.RootElement.GetProperty("timestamp").GetString();
+
+ Assert.That(timestampStr, Does.Contain("2026-02-26"));
+ Assert.That(timestampStr, Does.Contain("T"));
+ }
+
+ [Test]
+ public void Format_WithComplexProperties_ShouldSerializeCorrectly()
+ {
+ var properties = new Dictionary
+ {
+ ["Number"] = 123,
+ ["String"] = "test",
+ ["Boolean"] = true,
+ ["Null"] = null,
+ ["Array"] = new[] { 1, 2, 3 }
+ };
+ var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", "Test", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ Assert.That(() => JsonDocument.Parse(result), Throws.Nothing);
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Tests/logging/LogContextTests.cs b/GFramework.Core.Tests/logging/LogContextTests.cs
new file mode 100644
index 00000000..27402dce
--- /dev/null
+++ b/GFramework.Core.Tests/logging/LogContextTests.cs
@@ -0,0 +1,204 @@
+using GFramework.Core.Abstractions.logging;
+using NUnit.Framework;
+
+namespace GFramework.Core.Tests.logging;
+
+///
+/// 测试 LogContext 的功能和行为
+///
+[TestFixture]
+public class LogContextTests
+{
+ [SetUp]
+ public void SetUp()
+ {
+ LogContext.Clear();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ LogContext.Clear();
+ }
+
+ [Test]
+ public void Current_WhenEmpty_ShouldReturnEmptyDictionary()
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current, Is.Not.Null);
+ Assert.That(current.Count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Push_ShouldAddPropertyToContext()
+ {
+ using (LogContext.Push("Key1", "Value1"))
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current.Count, Is.EqualTo(1));
+ Assert.That(current["Key1"], Is.EqualTo("Value1"));
+ }
+ }
+
+ [Test]
+ public void Push_WithMultipleProperties_ShouldAddAllProperties()
+ {
+ using (LogContext.PushProperties(("Key1", "Value1"), ("Key2", 123)))
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current.Count, Is.EqualTo(2));
+ Assert.That(current["Key1"], Is.EqualTo("Value1"));
+ Assert.That(current["Key2"], Is.EqualTo(123));
+ }
+ }
+
+ [Test]
+ public void Push_WithNestedContext_ShouldMergeProperties()
+ {
+ using (LogContext.Push("Key1", "Value1"))
+ {
+ using (LogContext.Push("Key2", "Value2"))
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current.Count, Is.EqualTo(2));
+ Assert.That(current["Key1"], Is.EqualTo("Value1"));
+ Assert.That(current["Key2"], Is.EqualTo("Value2"));
+ }
+ }
+ }
+
+ [Test]
+ public void Push_WithSameKey_ShouldOverrideValue()
+ {
+ using (LogContext.Push("Key1", "Value1"))
+ {
+ using (LogContext.Push("Key1", "Value2"))
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current.Count, Is.EqualTo(1));
+ Assert.That(current["Key1"], Is.EqualTo("Value2"));
+ }
+
+ // 释放后应该恢复原值
+ var restored = LogContext.Current;
+ Assert.That(restored["Key1"], Is.EqualTo("Value1"));
+ }
+ }
+
+ [Test]
+ public void Dispose_ShouldRestorePreviousValue()
+ {
+ using (LogContext.Push("Key1", "Value1"))
+ {
+ Assert.That(LogContext.Current["Key1"], Is.EqualTo("Value1"));
+ }
+
+ // 释放后应该清空
+ Assert.That(LogContext.Current.Count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Clear_ShouldRemoveAllProperties()
+ {
+ using (LogContext.Push("Key1", "Value1"))
+ {
+ using (LogContext.Push("Key2", "Value2"))
+ {
+ LogContext.Clear();
+
+ Assert.That(LogContext.Current.Count, Is.EqualTo(0));
+ }
+ }
+ }
+
+ [Test]
+ public void Push_WithNullKey_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => LogContext.Push(null!, "Value"));
+ }
+
+ [Test]
+ public void Push_WithEmptyKey_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => LogContext.Push("", "Value"));
+ }
+
+ [Test]
+ public void Push_WithWhitespaceKey_ShouldThrowArgumentException()
+ {
+ Assert.Throws(() => LogContext.Push(" ", "Value"));
+ }
+
+ [Test]
+ public void Push_WithNullValue_ShouldWork()
+ {
+ using (LogContext.Push("Key1", null))
+ {
+ var current = LogContext.Current;
+
+ Assert.That(current.Count, Is.EqualTo(1));
+ Assert.That(current["Key1"], Is.Null);
+ }
+ }
+
+ [Test]
+ public async Task Push_InAsyncContext_ShouldIsolateAcrossThreads()
+ {
+ var task1Values = new List