diff --git a/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs b/GFramework.Core.Tests/logging/AsyncLogAppenderTests.cs
new file mode 100644
index 0000000..dab71c7
--- /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.Now;
+ for (int i = 0; i < 10; i++)
+ {
+ var entry = new LogEntry(DateTime.Now, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
+ asyncAppender.Append(entry);
+ }
+
+ var elapsed = (DateTime.Now - 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.Now, 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.Now, 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.Now, 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.Now, 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.Now, 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.Now, 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.Now, 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 0000000..18a6bac
--- /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 0000000..a14462f
--- /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_ShouldThrowArgumentNullException()
+ {
+ 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.Now, 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.Now, 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.Now, 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.Now, LogLevel.Info, "GFramework.Core", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry1), Is.True);
+
+ // 级别不满足
+ var entry2 = new LogEntry(DateTime.Now, LogLevel.Debug, "GFramework.Core", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry2), Is.False);
+
+ // 命名空间不满足
+ var entry3 = new LogEntry(DateTime.Now, LogLevel.Info, "OtherNamespace", "Test", null, null);
+ Assert.That(compositeFilter.ShouldLog(entry3), Is.False);
+
+ // 都不满足
+ var entry4 = new LogEntry(DateTime.Now, 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.Now, LogLevel.Warning, "GFramework.Core", "Test", null, null);
+ Assert.That(outerComposite.ShouldLog(entry1), Is.True);
+
+ var entry2 = new LogEntry(DateTime.Now, 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 0000000..cbf355c
--- /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 0000000..9375562
--- /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.Now, 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.Now, LogLevel.Info, "TestLogger", "Info message", null, null);
+ var warningEntry = new LogEntry(DateTime.Now, 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.Now, 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.Now, 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.Now, 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 0000000..f997c1c
--- /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.Now, 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.Now, 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.Now, 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.Now, 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.Now, 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.Now, 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 0000000..9c2cd24
--- /dev/null
+++ b/GFramework.Core.Tests/logging/FileAppenderTests.cs
@@ -0,0 +1,172 @@
+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_ShouldCreateDirectoryIfNotExists()
+ {
+ var dirPath = Path.Combine(Path.GetTempPath(), $"test_dir_{Guid.NewGuid()}");
+ var filePath = Path.Combine(dirPath, "test.log");
+
+ try
+ {
+ using var appender = 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.Now, 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.Now, 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.Now, 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.Now, 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.Now, LogLevel.Info, "TestLogger", "First message", null, null);
+ appender1.Append(entry);
+ appender1.Flush();
+ }
+
+ // 第二次写入
+ using (var appender2 = new FileAppender(_testFilePath))
+ {
+ var entry = new LogEntry(DateTime.Now, 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.Now, 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 0000000..2cf8ffb
--- /dev/null
+++ b/GFramework.Core.Tests/logging/JsonLogFormatterTests.cs
@@ -0,0 +1,153 @@
+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.Now, 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.Now, LogLevel.Info, "TestLogger", "User action", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ var propsObj = doc.RootElement.GetProperty("properties");
+
+ Assert.That(propsObj.GetProperty("userId").GetInt32(), Is.EqualTo(12345));
+ Assert.That(propsObj.GetProperty("userName").GetString(), Is.EqualTo("TestUser"));
+ Assert.That(propsObj.GetProperty("isActive").GetBoolean(), Is.True);
+ }
+
+ [Test]
+ public void Format_WithNullProperty_ShouldHandleNull()
+ {
+ var properties = new Dictionary
+ {
+ ["Key1"] = null
+ };
+ var entry = new LogEntry(DateTime.Now, LogLevel.Info, "TestLogger", "Test message", null, properties);
+
+ var result = _formatter.Format(entry);
+
+ var doc = JsonDocument.Parse(result);
+ var propsObj = doc.RootElement.GetProperty("properties");
+
+ Assert.That(propsObj.GetProperty("key1").ValueKind, Is.EqualTo(JsonValueKind.Null));
+ }
+
+ [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.Now, 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.Now, 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.Now, 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 0000000..27402dc
--- /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