diff --git a/.gitignore b/.gitignore index cff1d66..a6b3851 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ riderModule.iml /_ReSharper.Caches/ GFramework.sln.DotSettings.user .idea/ -.opencode.json +opencode.json .claude/settings.local.json \ No newline at end of file diff --git a/GFramework.Core.Tests/extensions/AsyncExtensionsTests.cs b/GFramework.Core.Tests/extensions/AsyncExtensionsTests.cs new file mode 100644 index 0000000..c23843a --- /dev/null +++ b/GFramework.Core.Tests/extensions/AsyncExtensionsTests.cs @@ -0,0 +1,258 @@ +using System.Diagnostics; +using GFramework.Core.extensions; +using NUnit.Framework; + +namespace GFramework.Core.Tests.extensions; + +/// +/// 测试 AsyncExtensions 扩展方法的功能 +/// +[TestFixture] +public class AsyncExtensionsTests +{ + [Test] + public async Task WithTimeout_Should_Return_Result_When_Task_Completes_Before_Timeout() + { + // Arrange + var task = Task.FromResult(42); + + // Act + var result = await task.WithTimeout(TimeSpan.FromSeconds(1)); + + // Assert + Assert.That(result, Is.EqualTo(42)); + } + + [Test] + public void WithTimeout_Should_Throw_TimeoutException_When_Task_Exceeds_Timeout() + { + // Arrange + var task = Task.Delay(TimeSpan.FromSeconds(2)).ContinueWith(_ => 42); + + // Act & Assert + Assert.ThrowsAsync(async () => + await task.WithTimeout(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public void WithTimeout_Should_Throw_OperationCanceledException_When_Cancellation_Requested() + { + // Arrange + using var cts = new CancellationTokenSource(); + var task = Task.Delay(TimeSpan.FromSeconds(2)).ContinueWith(_ => 42); + cts.Cancel(); + + // Act & Assert + Assert.ThrowsAsync(async () => + await task.WithTimeout(TimeSpan.FromSeconds(1), cts.Token)); + } + + [Test] + public async Task WithTimeout_NoResult_Should_Complete_When_Task_Completes_Before_Timeout() + { + // Arrange + var task = Task.CompletedTask; + var stopwatch = Stopwatch.StartNew(); + + // Act + await task.WithTimeout(TimeSpan.FromSeconds(1)); + stopwatch.Stop(); + + // Assert + Assert.That(stopwatch.ElapsedMilliseconds, Is.LessThan(1000), "Task should complete before timeout"); + Assert.Pass("Task completed successfully within timeout period"); + } + + [Test] + public void WithTimeout_NoResult_Should_Throw_TimeoutException_When_Task_Exceeds_Timeout() + { + // Arrange + var task = Task.Delay(TimeSpan.FromSeconds(2)); + + // Act & Assert + Assert.ThrowsAsync(async () => + await task.WithTimeout(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public async Task WithRetry_Should_Return_Result_When_Task_Succeeds() + { + // Arrange + var attemptCount = 0; + Func> taskFactory = () => + { + attemptCount++; + return Task.FromResult(42); + }; + + // Act + var result = await taskFactory.WithRetry(3, TimeSpan.FromMilliseconds(10)); + + // Assert + Assert.That(result, Is.EqualTo(42)); + Assert.That(attemptCount, Is.EqualTo(1)); + } + + [Test] + public async Task WithRetry_Should_Retry_On_Failure() + { + // Arrange + var attemptCount = 0; + Func> taskFactory = () => + { + attemptCount++; + if (attemptCount < 3) + throw new InvalidOperationException("Temporary failure"); + return Task.FromResult(42); + }; + + // Act + var result = await taskFactory.WithRetry(3, TimeSpan.FromMilliseconds(10)); + + // Assert + Assert.That(result, Is.EqualTo(42)); + Assert.That(attemptCount, Is.EqualTo(3)); + } + + [Test] + public void WithRetry_Should_Throw_AggregateException_When_All_Retries_Fail() + { + // Arrange + var attemptCount = 0; + Func> taskFactory = () => + { + attemptCount++; + throw new InvalidOperationException("Permanent failure"); + }; + + // Act & Assert + Assert.ThrowsAsync(async () => + await taskFactory.WithRetry(2, TimeSpan.FromMilliseconds(10))); + } + + [Test] + public async Task WithRetry_Should_Respect_ShouldRetry_Predicate() + { + // Arrange + var attemptCount = 0; + Func> taskFactory = () => + { + attemptCount++; + throw new ArgumentException("Should not retry"); + }; + + // Act & Assert + Assert.ThrowsAsync(async () => + await taskFactory.WithRetry(3, TimeSpan.FromMilliseconds(10), + ex => ex is not ArgumentException)); + + await Task.Delay(50); // 等待任务完成 + Assert.That(attemptCount, Is.EqualTo(1)); // 不应该重试 + } + + [Test] + public async Task TryAsync_Should_Return_Success_When_Task_Succeeds() + { + // Arrange + Func> func = () => Task.FromResult(42); + + // Act + var result = await func.TryAsync(); + + // Assert + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.IfFail(0), Is.EqualTo(42)); + } + + [Test] + public async Task TryAsync_Should_Return_Failure_When_Task_Throws() + { + // Arrange + Func> func = () => throw new InvalidOperationException("Test error"); + + // Act + var result = await func.TryAsync(); + + // Assert + Assert.That(result.IsFaulted, Is.True); + } + + [Test] + public async Task WhenAll_Should_Wait_For_All_Tasks() + { + // Arrange + var task1 = Task.Delay(10); + var task2 = Task.Delay(20); + var task3 = Task.Delay(30); + var tasks = new[] { task1, task2, task3 }; + + // Act + await tasks.WhenAll(); + + // Assert + Assert.That(task1.IsCompleted, Is.True); + Assert.That(task2.IsCompleted, Is.True); + Assert.That(task3.IsCompleted, Is.True); + } + + [Test] + public async Task WhenAll_WithResults_Should_Return_All_Results() + { + // Arrange + var task1 = Task.FromResult(1); + var task2 = Task.FromResult(2); + var task3 = Task.FromResult(3); + var tasks = new[] { task1, task2, task3 }; + + // Act + var results = await tasks.WhenAll(); + + // Assert + Assert.That(results, Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public async Task WithFallback_Should_Return_Result_When_Task_Succeeds() + { + // Arrange + var task = Task.FromResult(42); + + // Act + var result = await task.WithFallback(_ => -1); + + // Assert + Assert.That(result, Is.EqualTo(42)); + } + + [Test] + public async Task WithFallback_Should_Return_Fallback_Value_When_Task_Fails() + { + // Arrange + var task = Task.FromException(new InvalidOperationException("Test error")); + + // Act + var result = await task.WithFallback(ex => -1); + + // Assert + Assert.That(result, Is.EqualTo(-1)); + } + + [Test] + public async Task WithFallback_Should_Pass_Exception_To_Fallback() + { + // Arrange + var expectedException = new InvalidOperationException("Test error"); + var task = Task.FromException(expectedException); + Exception? capturedEx = null; + + // Act + await task.WithFallback(ex => + { + capturedEx = ex; + return -1; + }); + + // Assert + Assert.That(capturedEx, Is.SameAs(expectedException)); + } +} diff --git a/GFramework.Core.Tests/extensions/CollectionExtensionsTests.cs b/GFramework.Core.Tests/extensions/CollectionExtensionsTests.cs new file mode 100644 index 0000000..683b88b --- /dev/null +++ b/GFramework.Core.Tests/extensions/CollectionExtensionsTests.cs @@ -0,0 +1,185 @@ +using GFramework.Core.extensions; +using NUnit.Framework; + +namespace GFramework.Core.Tests.extensions; + +/// +/// 测试 CollectionExtensions 扩展方法的功能 +/// +[TestFixture] +public class CollectionExtensionsTests +{ + [Test] + public void ForEach_Should_Execute_Action_For_Each_Element() + { + // Arrange + var numbers = new[] { 1, 2, 3, 4, 5 }; + var sum = 0; + + // Act + numbers.ForEach(n => sum += n); + + // Assert + Assert.That(sum, Is.EqualTo(15)); + } + + [Test] + public void ForEach_Should_Throw_ArgumentNullException_When_Source_Is_Null() + { + // Arrange + IEnumerable? numbers = null; + + // Act & Assert + Assert.Throws(() => numbers!.ForEach(n => { })); + } + + [Test] + public void ForEach_Should_Throw_ArgumentNullException_When_Action_Is_Null() + { + // Arrange + var numbers = new[] { 1, 2, 3 }; + + // Act & Assert + Assert.Throws(() => numbers.ForEach(null!)); + } + + [Test] + public void IsNullOrEmpty_Should_Return_True_When_Source_Is_Null() + { + // Arrange + IEnumerable? numbers = null; + + // Act + var result = numbers.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrEmpty_Should_Return_True_When_Source_Is_Empty() + { + // Arrange + var numbers = Array.Empty(); + + // Act + var result = numbers.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrEmpty_Should_Return_False_When_Source_Has_Elements() + { + // Arrange + var numbers = new[] { 1, 2, 3 }; + + // Act + var result = numbers.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void WhereNotNull_Should_Filter_Out_Null_Elements() + { + // Arrange + var items = new string?[] { "a", null, "b", null, "c" }; + + // Act + var result = items.WhereNotNull().ToArray(); + + // Assert + Assert.That(result.Length, Is.EqualTo(3)); + Assert.That(result, Is.EqualTo(new[] { "a", "b", "c" })); + } + + [Test] + public void WhereNotNull_Should_Return_Empty_Collection_When_All_Elements_Are_Null() + { + // Arrange + var items = new string?[] { null, null, null }; + + // Act + var result = items.WhereNotNull().ToArray(); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void WhereNotNull_Should_Throw_ArgumentNullException_When_Source_Is_Null() + { + // Arrange + IEnumerable? items = null; + + // Act & Assert + Assert.Throws(() => items!.WhereNotNull().ToArray()); + } + + [Test] + public void ToDictionarySafe_Should_Create_Dictionary() + { + // Arrange + var items = new[] { ("a", 1), ("b", 2), ("c", 3) }; + + // Act + var result = items.ToDictionarySafe(x => x.Item1, x => x.Item2); + + // Assert + Assert.That(result.Count, Is.EqualTo(3)); + Assert.That(result["a"], Is.EqualTo(1)); + Assert.That(result["b"], Is.EqualTo(2)); + Assert.That(result["c"], Is.EqualTo(3)); + } + + [Test] + public void ToDictionarySafe_Should_Overwrite_Duplicate_Keys() + { + // Arrange + var items = new[] { ("a", 1), ("b", 2), ("a", 3) }; + + // Act + var result = items.ToDictionarySafe(x => x.Item1, x => x.Item2); + + // Assert + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result["a"], Is.EqualTo(3)); // 最后一个值 + Assert.That(result["b"], Is.EqualTo(2)); + } + + [Test] + public void ToDictionarySafe_Should_Throw_ArgumentNullException_When_Source_Is_Null() + { + // Arrange + IEnumerable<(string, int)>? items = null; + + // Act & Assert + Assert.Throws(() => + items!.ToDictionarySafe(x => x.Item1, x => x.Item2)); + } + + [Test] + public void ToDictionarySafe_Should_Throw_ArgumentNullException_When_KeySelector_Is_Null() + { + // Arrange + var items = new[] { ("a", 1), ("b", 2) }; + + // Act & Assert + Assert.Throws(() => + items.ToDictionarySafe<(string, int), string, int>(null!, x => x.Item2)); + } + + [Test] + public void ToDictionarySafe_Should_Throw_ArgumentNullException_When_ValueSelector_Is_Null() + { + // Arrange + var items = new[] { ("a", 1), ("b", 2) }; + + // Act & Assert + Assert.Throws(() => + items.ToDictionarySafe<(string, int), string, int>(x => x.Item1, null!)); + } +} diff --git a/GFramework.Core.Tests/extensions/GuardExtensionsTests.cs b/GFramework.Core.Tests/extensions/GuardExtensionsTests.cs new file mode 100644 index 0000000..30aa325 --- /dev/null +++ b/GFramework.Core.Tests/extensions/GuardExtensionsTests.cs @@ -0,0 +1,133 @@ +using GFramework.Core.extensions; +using NUnit.Framework; + +namespace GFramework.Core.Tests.extensions; + +/// +/// 测试 GuardExtensions 扩展方法的功能 +/// +[TestFixture] +public class GuardExtensionsTests +{ + [Test] + public void ThrowIfNull_Should_Return_Value_When_Value_Is_Not_Null() + { + // Arrange + var value = "test"; + + // Act + var result = value.ThrowIfNull(); + + // Assert + Assert.That(result, Is.EqualTo("test")); + } + + [Test] + public void ThrowIfNull_Should_Throw_ArgumentNullException_When_Value_Is_Null() + { + // Arrange + string? value = null; + + // Act & Assert + Assert.Throws(() => value.ThrowIfNull()); + } + + [Test] + public void ThrowIfNull_Should_Include_ParamName_In_Exception() + { + // Arrange + string? value = null; + + // Act & Assert + var ex = Assert.Throws(() => value.ThrowIfNull("testParam")); + Assert.That(ex.ParamName, Is.EqualTo("testParam")); + } + + [Test] + public void ThrowIfNullOrEmpty_Should_Return_Value_When_Value_Is_Not_Empty() + { + // Arrange + var value = "test"; + + // Act + var result = value.ThrowIfNullOrEmpty(); + + // Assert + Assert.That(result, Is.EqualTo("test")); + } + + [Test] + public void ThrowIfNullOrEmpty_Should_Throw_ArgumentNullException_When_Value_Is_Null() + { + // Arrange + string? value = null; + + // Act & Assert + Assert.Throws(() => value.ThrowIfNullOrEmpty()); + } + + [Test] + public void ThrowIfNullOrEmpty_Should_Throw_ArgumentException_When_Value_Is_Empty() + { + // Arrange + var value = string.Empty; + + // Act & Assert + Assert.Throws(() => value.ThrowIfNullOrEmpty()); + } + + [Test] + public void ThrowIfNullOrEmpty_Should_Include_ParamName_In_Exception() + { + // Arrange + var value = string.Empty; + + // Act & Assert + var ex = Assert.Throws(() => value.ThrowIfNullOrEmpty("testParam")); + Assert.That(ex.ParamName, Is.EqualTo("testParam")); + } + + [Test] + public void ThrowIfEmpty_Should_Return_Collection_When_Collection_Has_Elements() + { + // Arrange + var collection = new[] { 1, 2, 3 }; + + // Act + var result = collection.ThrowIfEmpty(); + + // Assert + Assert.That(result, Is.EqualTo(collection)); + } + + [Test] + public void ThrowIfEmpty_Should_Throw_ArgumentNullException_When_Collection_Is_Null() + { + // Arrange + IEnumerable? collection = null; + + // Act & Assert + Assert.Throws(() => collection.ThrowIfEmpty()); + } + + [Test] + public void ThrowIfEmpty_Should_Throw_ArgumentException_When_Collection_Is_Empty() + { + // Arrange + var collection = Array.Empty(); + + // Act & Assert + Assert.Throws(() => collection.ThrowIfEmpty()); + } + + [Test] + public void ThrowIfEmpty_Should_Include_ParamName_In_Exception() + { + // Arrange + var collection = Array.Empty(); + + // Act & Assert + var ex = Assert.Throws(() => collection.ThrowIfEmpty("testParam")); + Assert.That(ex.ParamName, Is.EqualTo("testParam")); + } +} diff --git a/GFramework.Core.Tests/extensions/NumericExtensionsTests.cs b/GFramework.Core.Tests/extensions/NumericExtensionsTests.cs new file mode 100644 index 0000000..c85bf5c --- /dev/null +++ b/GFramework.Core.Tests/extensions/NumericExtensionsTests.cs @@ -0,0 +1,215 @@ +using GFramework.Core.extensions; +using NUnit.Framework; + +namespace GFramework.Core.Tests.extensions; + +/// +/// 测试 NumericExtensions 扩展方法的功能 +/// +[TestFixture] +public class NumericExtensionsTests +{ + [Test] + public void Clamp_Should_Return_Min_When_Value_Is_Less_Than_Min() + { + // Arrange + var value = -10; + + // Act + var result = value.Clamp(0, 100); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void Clamp_Should_Return_Max_When_Value_Is_Greater_Than_Max() + { + // Arrange + var value = 150; + + // Act + var result = value.Clamp(0, 100); + + // Assert + Assert.That(result, Is.EqualTo(100)); + } + + [Test] + public void Clamp_Should_Return_Value_When_Value_Is_Within_Range() + { + // Arrange + var value = 50; + + // Act + var result = value.Clamp(0, 100); + + // Assert + Assert.That(result, Is.EqualTo(50)); + } + + [Test] + public void Clamp_Should_Throw_ArgumentException_When_Min_Is_Greater_Than_Max() + { + // Arrange + var value = 50; + + // Act & Assert + Assert.Throws(() => value.Clamp(100, 0)); + } + + [Test] + public void Between_Should_Return_True_When_Value_Is_Within_Range() + { + // Arrange + var value = 50; + + // Act + var result = value.Between(0, 100); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Between_Should_Return_True_When_Value_Equals_Min() + { + // Arrange + var value = 0; + + // Act + var result = value.Between(0, 100); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Between_Should_Return_True_When_Value_Equals_Max() + { + // Arrange + var value = 100; + + // Act + var result = value.Between(0, 100); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Between_Should_Return_False_When_Value_Is_Less_Than_Min() + { + // Arrange + var value = -10; + + // Act + var result = value.Between(0, 100); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void Between_Should_Return_False_When_Value_Is_Greater_Than_Max() + { + // Arrange + var value = 150; + + // Act + var result = value.Between(0, 100); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void Between_Should_Return_False_When_Value_Equals_Boundary_And_Not_Inclusive() + { + // Arrange + var value = 0; + + // Act + var result = value.Between(0, 100, inclusive: false); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void Between_Should_Throw_ArgumentException_When_Min_Is_Greater_Than_Max() + { + // Arrange + var value = 50; + + // Act & Assert + Assert.Throws(() => value.Between(100, 0)); + } + + [Test] + public void Lerp_Should_Return_From_When_T_Is_Zero() + { + // Arrange & Act + var result = 0f.Lerp(100f, 0f); + + // Assert + Assert.That(result, Is.EqualTo(0f)); + } + + [Test] + public void Lerp_Should_Return_To_When_T_Is_One() + { + // Arrange & Act + var result = 0f.Lerp(100f, 1f); + + // Assert + Assert.That(result, Is.EqualTo(100f)); + } + + [Test] + public void Lerp_Should_Return_Midpoint_When_T_Is_Half() + { + // Arrange & Act + var result = 0f.Lerp(100f, 0.5f); + + // Assert + Assert.That(result, Is.EqualTo(50f)); + } + + [Test] + public void InverseLerp_Should_Return_Zero_When_Value_Equals_From() + { + // Arrange & Act + var result = 0f.InverseLerp(0f, 100f); + + // Assert + Assert.That(result, Is.EqualTo(0f)); + } + + [Test] + public void InverseLerp_Should_Return_One_When_Value_Equals_To() + { + // Arrange & Act + var result = 100f.InverseLerp(0f, 100f); + + // Assert + Assert.That(result, Is.EqualTo(1f)); + } + + [Test] + public void InverseLerp_Should_Return_Half_When_Value_Is_Midpoint() + { + // Arrange & Act + var result = 50f.InverseLerp(0f, 100f); + + // Assert + Assert.That(result, Is.EqualTo(0.5f)); + } + + [Test] + public void InverseLerp_Should_Throw_DivideByZeroException_When_From_Equals_To() + { + // Arrange & Act & Assert + Assert.Throws(() => 50f.InverseLerp(100f, 100f)); + } +} diff --git a/GFramework.Core.Tests/extensions/StringExtensionsTests.cs b/GFramework.Core.Tests/extensions/StringExtensionsTests.cs new file mode 100644 index 0000000..c650014 --- /dev/null +++ b/GFramework.Core.Tests/extensions/StringExtensionsTests.cs @@ -0,0 +1,233 @@ +using GFramework.Core.extensions; +using NUnit.Framework; + +namespace GFramework.Core.Tests.extensions; + +/// +/// 测试 StringExtensions 扩展方法的功能 +/// +[TestFixture] +public class StringExtensionsTests +{ + [Test] + public void IsNullOrEmpty_Should_Return_True_When_String_Is_Null() + { + // Arrange + string? text = null; + + // Act + var result = text.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrEmpty_Should_Return_True_When_String_Is_Empty() + { + // Arrange + var text = string.Empty; + + // Act + var result = text.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrEmpty_Should_Return_False_When_String_Has_Content() + { + // Arrange + var text = "Hello"; + + // Act + var result = text.IsNullOrEmpty(); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void IsNullOrWhiteSpace_Should_Return_True_When_String_Is_Null() + { + // Arrange + string? text = null; + + // Act + var result = text.IsNullOrWhiteSpace(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrWhiteSpace_Should_Return_True_When_String_Is_WhiteSpace() + { + // Arrange + var text = " "; + + // Act + var result = text.IsNullOrWhiteSpace(); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsNullOrWhiteSpace_Should_Return_False_When_String_Has_Content() + { + // Arrange + var text = "Hello"; + + // Act + var result = text.IsNullOrWhiteSpace(); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void NullIfEmpty_Should_Return_Null_When_String_Is_Empty() + { + // Arrange + var text = string.Empty; + + // Act + var result = text.NullIfEmpty(); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void NullIfEmpty_Should_Return_Null_When_String_Is_Null() + { + // Arrange + string? text = null; + + // Act + var result = text.NullIfEmpty(); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void NullIfEmpty_Should_Return_String_When_String_Has_Content() + { + // Arrange + var text = "Hello"; + + // Act + var result = text.NullIfEmpty(); + + // Assert + Assert.That(result, Is.EqualTo("Hello")); + } + + [Test] + public void Truncate_Should_Return_Original_String_When_Length_Is_Less_Than_MaxLength() + { + // Arrange + var text = "Hello"; + + // Act + var result = text.Truncate(10); + + // Assert + Assert.That(result, Is.EqualTo("Hello")); + } + + [Test] + public void Truncate_Should_Truncate_String_And_Add_Suffix() + { + // Arrange + var text = "Hello World"; + + // Act + var result = text.Truncate(8); + + // Assert + Assert.That(result, Is.EqualTo("Hello...")); + } + + [Test] + public void Truncate_Should_Use_Custom_Suffix() + { + // Arrange + var text = "Hello World"; + + // Act + var result = text.Truncate(8, "~"); + + // Assert + Assert.That(result, Is.EqualTo("Hello W~")); + } + + [Test] + public void Truncate_Should_Throw_ArgumentNullException_When_String_Is_Null() + { + // Arrange + string? text = null; + + // Act & Assert + Assert.Throws(() => text!.Truncate(10)); + } + + [Test] + public void Truncate_Should_Throw_ArgumentOutOfRangeException_When_MaxLength_Is_Less_Than_Suffix_Length() + { + // Arrange + var text = "Hello"; + + // Act & Assert + Assert.Throws(() => text.Truncate(2, "...")); + } + + [Test] + public void Join_Should_Join_Strings_With_Separator() + { + // Arrange + var words = new[] { "Hello", "World" }; + + // Act + var result = words.Join(", "); + + // Assert + Assert.That(result, Is.EqualTo("Hello, World")); + } + + [Test] + public void Join_Should_Return_Empty_String_When_Collection_Is_Empty() + { + // Arrange + var words = Array.Empty(); + + // Act + var result = words.Join(", "); + + // Assert + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test] + public void Join_Should_Throw_ArgumentNullException_When_Collection_Is_Null() + { + // Arrange + IEnumerable? words = null; + + // Act & Assert + Assert.Throws(() => words!.Join(", ")); + } + + [Test] + public void Join_Should_Throw_ArgumentNullException_When_Separator_Is_Null() + { + // Arrange + var words = new[] { "Hello", "World" }; + + // Act & Assert + Assert.Throws(() => words.Join(null!)); + } +} diff --git a/GFramework.Core/extensions/AsyncExtensions.cs b/GFramework.Core/extensions/AsyncExtensions.cs new file mode 100644 index 0000000..437752c --- /dev/null +++ b/GFramework.Core/extensions/AsyncExtensions.cs @@ -0,0 +1,230 @@ +namespace GFramework.Core.extensions; + +/// +/// 异步扩展方法 +/// +public static class AsyncExtensions +{ + /// + /// 为任务添加超时限制 + /// + /// 任务结果类型 + /// 要执行的任务 + /// 超时时间 + /// 取消令牌 + /// 任务结果 + /// 当 task 为 null 时抛出 + /// 当任务超时时抛出 + /// 当操作被取消时抛出 + /// + /// + /// var result = await SomeAsyncOperation().WithTimeout(TimeSpan.FromSeconds(5)); + /// + /// + public static async Task WithTimeout( + this Task task, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(task); + + using var timeoutCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + var delayTask = Task.Delay(timeout, linkedCts.Token); + var completedTask = await Task.WhenAny(task, delayTask); + + if (completedTask == delayTask) + { + // 优先检查外部取消令牌,若已取消则抛出 OperationCanceledException + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException($"操作在 {timeout.TotalSeconds} 秒后超时"); + } + + await timeoutCts.CancelAsync(); + return await task; + } + + /// + /// 为任务添加超时限制(无返回值版本) + /// + /// 要执行的任务 + /// 超时时间 + /// 取消令牌 + /// 当 task 为 null 时抛出 + /// 当任务超时时抛出 + /// 当操作被取消时抛出 + public static async Task WithTimeout( + this Task task, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(task); + + using var timeoutCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + var delayTask = Task.Delay(timeout, linkedCts.Token); + var completedTask = await Task.WhenAny(task, delayTask); + + if (completedTask == delayTask) + { + // 优先检查外部取消令牌,若已取消则抛出 OperationCanceledException + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException($"操作在 {timeout.TotalSeconds} 秒后超时"); + } + + await timeoutCts.CancelAsync(); + await task; + } + + /// + /// 为任务工厂添加重试机制 + /// + /// 任务结果类型 + /// 任务工厂函数 + /// 最大重试次数 + /// 重试间隔 + /// 判断是否应该重试的函数,默认对所有异常重试 + /// 任务结果 + /// 当 taskFactory 为 null 时抛出 + /// 当 maxRetries 小于 0 时抛出 + /// + /// + /// var result = await (() => UnreliableOperation()) + /// .WithRetry(maxRetries: 3, delay: TimeSpan.FromSeconds(1)); + /// + /// + public static async Task WithRetry( + this Func> taskFactory, + int maxRetries, + TimeSpan delay, + Func? shouldRetry = null) + { + ArgumentNullException.ThrowIfNull(taskFactory); + + if (maxRetries < 0) + throw new ArgumentOutOfRangeException(nameof(maxRetries), "最大重试次数不能为负数"); + + shouldRetry ??= _ => true; + + for (var attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + return await taskFactory(); + } + catch (Exception ex) + { + // 若还有重试机会且允许重试,则等待后继续;否则统一包装为 AggregateException 抛出 + if (attempt < maxRetries && shouldRetry(ex)) + { + await Task.Delay(delay); + } + else + { + throw new AggregateException($"操作在 {attempt} 次重试后仍然失败", ex); + } + } + } + + // 理论上不可达,仅满足编译器要求 + throw new AggregateException($"操作在 {maxRetries} 次重试后仍然失败"); + } + + /// + /// 安全执行异步操作,将异常包装为 Result 类型 + /// + /// 任务结果类型 + /// 要执行的异步函数 + /// 包含结果或异常的 Result 对象 + /// 当 func 为 null 时抛出 + /// + /// + /// var result = await (() => RiskyOperation()).TryAsync(); + /// result.Match( + /// value => Console.WriteLine($"成功: {value}"), + /// error => Console.WriteLine($"失败: {error.Message}") + /// ); + /// + /// + public static async Task> TryAsync(this Func> func) + { + ArgumentNullException.ThrowIfNull(func); + + try + { + var result = await func(); + return new Result(result); + } + catch (Exception ex) + { + return new Result(ex); + } + } + + /// + /// 等待所有任务完成 + /// + /// 任务集合 + /// 当 tasks 为 null 时抛出 + /// + /// + /// var tasks = new[] { Task1(), Task2(), Task3() }; + /// await tasks.WhenAll(); + /// + /// + public static Task WhenAll(this IEnumerable tasks) + { + ArgumentNullException.ThrowIfNull(tasks); + return Task.WhenAll(tasks); + } + + /// + /// 等待所有任务完成并返回结果数组 + /// + /// 任务结果类型 + /// 任务集合 + /// 所有任务的结果数组 + /// 当 tasks 为 null 时抛出 + /// + /// + /// var tasks = new[] { GetValue1(), GetValue2(), GetValue3() }; + /// var results = await tasks.WhenAll(); + /// + /// + public static Task WhenAll(this IEnumerable> tasks) + { + ArgumentNullException.ThrowIfNull(tasks); + return Task.WhenAll(tasks); + } + + /// + /// 为任务添加失败回退机制 + /// + /// 任务结果类型 + /// 要执行的任务 + /// 失败时的回退函数 + /// 任务结果或回退值 + /// 当 task 或 fallback 为 null 时抛出 + /// + /// + /// var result = await RiskyOperation() + /// .WithFallback(ex => DefaultValue); + /// + /// + public static async Task WithFallback(this Task task, Func fallback) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(fallback); + + try + { + return await task; + } + catch (Exception ex) + { + return fallback(ex); + } + } +} \ No newline at end of file diff --git a/GFramework.Core/extensions/CollectionExtensions.cs b/GFramework.Core/extensions/CollectionExtensions.cs new file mode 100644 index 0000000..74bec8c --- /dev/null +++ b/GFramework.Core/extensions/CollectionExtensions.cs @@ -0,0 +1,104 @@ +namespace GFramework.Core.extensions; + +/// +/// 集合扩展方法 +/// +public static class CollectionExtensions +{ + /// + /// 对集合中的每个元素执行指定操作 + /// + /// 集合元素类型 + /// 源集合 + /// 要对每个元素执行的操作 + /// 当 source 或 action 为 null 时抛出 + /// + /// + /// var numbers = new[] { 1, 2, 3 }; + /// numbers.ForEach(n => Console.WriteLine(n)); + /// + /// + public static void ForEach(this IEnumerable source, Action action) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(action); + + foreach (var item in source) action(item); + } + + /// + /// 检查集合是否为 null 或空 + /// + /// 集合元素类型 + /// 要检查的集合 + /// 如果集合为 null 或不包含任何元素,则返回 true;否则返回 false + /// + /// + /// List<int>? numbers = null; + /// if (numbers.IsNullOrEmpty()) { /* ... */ } + /// + /// + public static bool IsNullOrEmpty(this IEnumerable? source) + { + return source is null || !source.Any(); + } + + /// + /// 过滤掉集合中的 null 元素 + /// + /// 集合元素类型(引用类型) + /// 源集合 + /// 不包含 null 元素的集合 + /// 当 source 为 null 时抛出 + /// + /// + /// var items = new string?[] { "a", null, "b", null, "c" }; + /// var nonNull = items.WhereNotNull(); // ["a", "b", "c"] + /// + /// + public static IEnumerable WhereNotNull(this IEnumerable source) where T : class + { + ArgumentNullException.ThrowIfNull(source); + + return source.Where(item => item is not null)!; + } + + /// + /// 将集合转换为字典,如果存在重复键则使用最后一个值 + /// + /// 源集合元素类型 + /// 字典键类型 + /// 字典值类型 + /// 源集合 + /// 键选择器函数 + /// 值选择器函数 + /// 转换后的字典 + /// 当 source、keySelector 或 valueSelector 为 null 时抛出 + /// + /// + /// var items = new[] { ("a", 1), ("b", 2), ("a", 3) }; + /// var dict = items.ToDictionarySafe(x => x.Item1, x => x.Item2); + /// // dict["a"] == 3 (最后一个值) + /// + /// + public static Dictionary ToDictionarySafe( + this IEnumerable source, + Func keySelector, + Func valueSelector) where TKey : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(keySelector); + ArgumentNullException.ThrowIfNull(valueSelector); + + var dictionary = new Dictionary(); + + foreach (var item in source) + { + var key = keySelector(item); + var value = valueSelector(item); + dictionary[key] = value; // 覆盖重复键 + } + + return dictionary; + } +} diff --git a/GFramework.Core/extensions/GuardExtensions.cs b/GFramework.Core/extensions/GuardExtensions.cs new file mode 100644 index 0000000..35c20d8 --- /dev/null +++ b/GFramework.Core/extensions/GuardExtensions.cs @@ -0,0 +1,118 @@ +using System.Runtime.CompilerServices; + +namespace GFramework.Core.extensions; + +/// +/// 参数验证扩展方法(Guard 模式) +/// +public static class GuardExtensions +{ + /// + /// 如果值为 null 则抛出 ArgumentNullException + /// + /// 引用类型 + /// 要检查的值 + /// 参数名称(自动捕获) + /// 非 null 的值 + /// 当 value 为 null 时抛出 + /// + /// + /// public void Process(string? input) + /// { + /// var safeInput = input.ThrowIfNull(); // 自动使用 "input" 作为参数名 + /// } + /// + /// + public static T ThrowIfNull( + this T? value, + [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : class + { + ArgumentNullException.ThrowIfNull(value, paramName); + return value; + } + + /// + /// 如果字符串为 null 或空则抛出 ArgumentException + /// + /// 要检查的字符串 + /// 参数名称(自动捕获) + /// 非空字符串 + /// 当 value 为 null 时抛出 + /// 当 value 为空字符串时抛出 + /// + /// + /// public void SetName(string? name) + /// { + /// var safeName = name.ThrowIfNullOrEmpty(); + /// } + /// + /// + public static string ThrowIfNullOrEmpty( + this string? value, + [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentNullException.ThrowIfNull(value, paramName); + + if (value.Length == 0) + throw new ArgumentException("字符串不能为空", paramName); + + return value; + } + + /// + /// 如果字符串为 null、空或仅包含空白字符则抛出 ArgumentException + /// + /// 要检查的字符串 + /// 参数名称(自动捕获) + /// 非空白字符串 + /// 当 value 为 null 时抛出 + /// 当 value 为空或仅包含空白字符时抛出 + /// + /// + /// public void SetDescription(string? description) + /// { + /// var safeDescription = description.ThrowIfNullOrWhiteSpace(); + /// } + /// + /// + public static string ThrowIfNullOrWhiteSpace( + this string? value, + [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + ArgumentNullException.ThrowIfNull(value, paramName); + + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("字符串不能为空或仅包含空白字符", paramName); + + return value; + } + + /// + /// 如果集合为空则抛出 ArgumentException + /// + /// 集合元素类型 + /// 要检查的集合 + /// 参数名称(自动捕获) + /// 非空集合 + /// 当 source 为 null 时抛出 + /// 当 source 为空集合时抛出 + /// + /// + /// public void ProcessItems(IEnumerable<int>? items) + /// { + /// var safeItems = items.ThrowIfEmpty(); + /// } + /// + /// + public static IEnumerable ThrowIfEmpty( + this IEnumerable? source, + [CallerArgumentExpression(nameof(source))] string? paramName = null) + { + ArgumentNullException.ThrowIfNull(source, paramName); + + if (!source.Any()) + throw new ArgumentException("集合不能为空", paramName); + + return source; + } +} diff --git a/GFramework.Core/extensions/NumericExtensions.cs b/GFramework.Core/extensions/NumericExtensions.cs new file mode 100644 index 0000000..544d6fe --- /dev/null +++ b/GFramework.Core/extensions/NumericExtensions.cs @@ -0,0 +1,112 @@ +namespace GFramework.Core.extensions; + +/// +/// 数值扩展方法 +/// +public static class NumericExtensions +{ + /// + /// 将值限制在指定的范围内 + /// + /// 实现 IComparable 的类型 + /// 要限制的值 + /// 最小值 + /// 最大值 + /// 限制后的值 + /// 当 value、min 或 max 为 null 时抛出 + /// 当 min 大于 max 时抛出 + /// + /// + /// var value = 150; + /// var clamped = value.Clamp(0, 100); // 返回 100 + /// + /// + public static T Clamp(this T value, T min, T max) where T : IComparable + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(min); + ArgumentNullException.ThrowIfNull(max); + + if (min.CompareTo(max) > 0) + throw new ArgumentException($"最小值 ({min}) 不能大于最大值 ({max})"); + + if (value.CompareTo(min) < 0) + return min; + + if (value.CompareTo(max) > 0) + return max; + + return value; + } + + /// + /// 检查值是否在指定范围内 + /// + /// 实现 IComparable 的类型 + /// 要检查的值 + /// 最小值 + /// 最大值 + /// 是否包含边界值,默认为 true + /// 如果值在范围内则返回 true,否则返回 false + /// 当 value、min 或 max 为 null 时抛出 + /// 当 min 大于 max 时抛出 + /// + /// + /// var value = 50; + /// var inRange = value.Between(0, 100); // 返回 true + /// var inRangeExclusive = value.Between(50, 100, inclusive: false); // 返回 false + /// + /// + public static bool Between(this T value, T min, T max, bool inclusive = true) where T : IComparable + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(min); + ArgumentNullException.ThrowIfNull(max); + + if (min.CompareTo(max) > 0) + throw new ArgumentException($"最小值 ({min}) 不能大于最大值 ({max})"); + + if (inclusive) + return value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0; + + return value.CompareTo(min) > 0 && value.CompareTo(max) < 0; + } + + /// + /// 在两个值之间进行线性插值 + /// + /// 起始值 + /// 目标值 + /// 插值参数(0 到 1 之间) + /// 插值结果 + /// + /// + /// var result = 0f.Lerp(100f, 0.5f); // 返回 50 + /// + /// + public static float Lerp(this float from, float to, float t) + { + return from + (to - from) * t; + } + + /// + /// 计算值在两个值之间的插值参数 + /// + /// 当前值 + /// 起始值 + /// 目标值 + /// 插值参数(通常在 0 到 1 之间) + /// 当 from 等于 to 时抛出 + /// + /// + /// var t = 50f.InverseLerp(0f, 100f); // 返回 0.5 + /// + /// + public static float InverseLerp(this float value, float from, float to) + { + if (Math.Abs(to - from) < float.Epsilon) + throw new DivideByZeroException("起始值和目标值不能相等"); + + return (value - from) / (to - from); + } +} diff --git a/GFramework.Core/extensions/StringExtensions.cs b/GFramework.Core/extensions/StringExtensions.cs new file mode 100644 index 0000000..8d88428 --- /dev/null +++ b/GFramework.Core/extensions/StringExtensions.cs @@ -0,0 +1,106 @@ +namespace GFramework.Core.extensions; + +/// +/// 字符串扩展方法 +/// +public static class StringExtensions +{ + /// + /// 指示指定的字符串是 null 还是空字符串 + /// + /// 要测试的字符串 + /// 如果 str 参数为 null 或空字符串 (""),则为 true;否则为 false + /// + /// + /// string? text = null; + /// if (text.IsNullOrEmpty()) { /* ... */ } + /// + /// + public static bool IsNullOrEmpty(this string? str) + { + return string.IsNullOrEmpty(str); + } + + /// + /// 指示指定的字符串是 null、空还是仅由空白字符组成 + /// + /// 要测试的字符串 + /// 如果 str 参数为 null、空字符串或仅包含空白字符,则为 true;否则为 false + /// + /// + /// string? text = " "; + /// if (text.IsNullOrWhiteSpace()) { /* ... */ } + /// + /// + public static bool IsNullOrWhiteSpace(this string? str) + { + return string.IsNullOrWhiteSpace(str); + } + + /// + /// 如果字符串为空,则返回 null;否则返回原字符串 + /// + /// 要检查的字符串 + /// 如果字符串为空则返回 null,否则返回原字符串 + /// + /// + /// string text = ""; + /// var result = text.NullIfEmpty(); // 返回 null + /// + /// + public static string? NullIfEmpty(this string? str) + { + return string.IsNullOrEmpty(str) ? null : str; + } + + /// + /// 截断字符串到指定的最大长度,并可选地添加后缀 + /// + /// 要截断的字符串 + /// 最大长度(包括后缀) + /// 截断时添加的后缀,默认为 "..." + /// 截断后的字符串 + /// 当 str 为 null 时抛出 + /// 当 maxLength 小于后缀长度时抛出 + /// + /// + /// var text = "Hello World"; + /// var truncated = text.Truncate(8); // "Hello..." + /// + /// + public static string Truncate(this string str, int maxLength, string suffix = "...") + { + ArgumentNullException.ThrowIfNull(str); + ArgumentNullException.ThrowIfNull(suffix); + + if (maxLength < suffix.Length) + throw new ArgumentOutOfRangeException(nameof(maxLength), + $"最大长度必须至少为后缀长度 ({suffix.Length})"); + + if (str.Length <= maxLength) + return str; + + return string.Concat(str.AsSpan(0, maxLength - suffix.Length), suffix); + } + + /// + /// 使用指定的分隔符连接字符串集合 + /// + /// 要连接的字符串集合 + /// 分隔符 + /// 连接后的字符串 + /// 当 values 或 separator 为 null 时抛出 + /// + /// + /// var words = new[] { "Hello", "World" }; + /// var result = words.Join(", "); // "Hello, World" + /// + /// + public static string Join(this IEnumerable values, string separator) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(separator); + + return string.Join(separator, values); + } +}