From 8ead2da385dd09d4e1e1d39d29cf9387a97d80ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:01:48 +0000 Subject: [PATCH 01/43] Initial plan From be7137b9d058e195371c3d8f64cd7e0a053fb3cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:18:36 +0000 Subject: [PATCH 02/43] feat: create PowerKit source-only NuGet package with shared BCL-only utility extensions Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/43c1b0cc-139f-46f8-9717-63e43e341fc1 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .gitignore | 12 ++++ Directory.Build.props | 20 ++++++ License.txt | 21 ++++++ NuGet.config | 10 +++ PowerKit.slnx | 3 + PowerKit/PowerKit.csproj | 34 +++++++++ PowerKit/PowerKit.props | 5 ++ PowerKit/Utils/Disposable.cs | 23 ++++++ .../Extensions/AsyncCollectionExtensions.cs | 53 ++++++++++++++ .../Utils/Extensions/CollectionExtensions.cs | 71 +++++++++++++++++++ .../Utils/Extensions/ExceptionExtensions.cs | 44 ++++++++++++ .../Utils/Extensions/GenericExtensions.cs | 21 ++++++ PowerKit/Utils/Extensions/PathExtensions.cs | 47 ++++++++++++ PowerKit/Utils/Extensions/StreamExtensions.cs | 67 +++++++++++++++++ PowerKit/Utils/Extensions/StringExtensions.cs | 54 ++++++++++++++ .../Utils/Extensions/TextReaderExtensions.cs | 24 +++++++ .../Utils/Extensions/TimeSpanExtensions.cs | 20 ++++++ global.json | 6 ++ 18 files changed, 535 insertions(+) create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 License.txt create mode 100644 NuGet.config create mode 100644 PowerKit.slnx create mode 100644 PowerKit/PowerKit.csproj create mode 100644 PowerKit/PowerKit.props create mode 100644 PowerKit/Utils/Disposable.cs create mode 100644 PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs create mode 100644 PowerKit/Utils/Extensions/CollectionExtensions.cs create mode 100644 PowerKit/Utils/Extensions/ExceptionExtensions.cs create mode 100644 PowerKit/Utils/Extensions/GenericExtensions.cs create mode 100644 PowerKit/Utils/Extensions/PathExtensions.cs create mode 100644 PowerKit/Utils/Extensions/StreamExtensions.cs create mode 100644 PowerKit/Utils/Extensions/StringExtensions.cs create mode 100644 PowerKit/Utils/Extensions/TextReaderExtensions.cs create mode 100644 PowerKit/Utils/Extensions/TimeSpanExtensions.cs create mode 100644 global.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d4f31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# User-specific files +.vs/ +.idea/ +*.suo +*.user + +# Build results +bin/ +obj/ + +# Test results +TestResults/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..50a8b42 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + 0.0.0-dev + Tyrrrz + Copyright (C) Oleksii Holub + preview + enable + true + false + + + + $(Company) + Collection of shared utility extensions for Tyrrrz projects. + utils extensions utilities source + https://github.com/Tyrrrz/PowerKit + https://github.com/Tyrrrz/PowerKit/releases + MIT + + diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..321d06a --- /dev/null +++ b/License.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2026 Oleksii Holub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..5a40e48 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/PowerKit.slnx b/PowerKit.slnx new file mode 100644 index 0000000..59e4068 --- /dev/null +++ b/PowerKit.slnx @@ -0,0 +1,3 @@ + + + diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj new file mode 100644 index 0000000..7a27e2e --- /dev/null +++ b/PowerKit/PowerKit.csproj @@ -0,0 +1,34 @@ + + + + + net9.0 + true + + + + true + false + true + + + + + + + + diff --git a/PowerKit/PowerKit.props b/PowerKit/PowerKit.props new file mode 100644 index 0000000..f553c39 --- /dev/null +++ b/PowerKit/PowerKit.props @@ -0,0 +1,5 @@ + + + preview + + diff --git a/PowerKit/Utils/Disposable.cs b/PowerKit/Utils/Disposable.cs new file mode 100644 index 0000000..c1aa324 --- /dev/null +++ b/PowerKit/Utils/Disposable.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace PowerKit.Utils; + +internal partial class Disposable(Action dispose) : IDisposable +{ + public void Dispose() => dispose(); +} + +internal partial class Disposable +{ + public static IDisposable Null { get; } = Create(() => { }); + + public static IDisposable Create(Action dispose) => new Disposable(dispose); + + public static IDisposable Merge(params IEnumerable disposables) => + Create(() => + { + foreach (var disposable in disposables) + disposable.Dispose(); + }); +} diff --git a/PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs b/PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs new file mode 100644 index 0000000..3806161 --- /dev/null +++ b/PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerKit.Utils.Extensions; + +internal static class AsyncCollectionExtensions +{ + extension(IAsyncEnumerable source) + { + public async IAsyncEnumerable TakeAsync( + int count, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var currentCount = 0; + + await foreach (var item in source.WithCancellation(cancellationToken)) + { + if (currentCount >= count) + yield break; + + yield return item; + currentCount++; + } + } + + public async IAsyncEnumerable SelectManyAsync( + System.Func> transform, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + await foreach (var item in source.WithCancellation(cancellationToken)) + foreach (var result in transform(item)) + yield return result; + } + + public async ValueTask> ToListAsync( + CancellationToken cancellationToken = default + ) + { + var list = new List(); + + await foreach (var item in source.WithCancellation(cancellationToken)) + list.Add(item); + + return list; + } + + public ValueTaskAwaiter> GetAwaiter() => source.ToListAsync().GetAwaiter(); + } +} diff --git a/PowerKit/Utils/Extensions/CollectionExtensions.cs b/PowerKit/Utils/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..99914ea --- /dev/null +++ b/PowerKit/Utils/Extensions/CollectionExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; + +namespace PowerKit.Utils.Extensions; + +internal static class CollectionExtensions +{ + extension(T obj) + { + public IEnumerable ToSingletonEnumerable() + { + yield return obj; + } + } + + extension(IEnumerable source) + where T : class + { + public IEnumerable WhereNotNull() + { + foreach (var item in source) + { + if (item is not null) + yield return item; + } + } + } + + extension(IEnumerable source) + where T : struct + { + public IEnumerable WhereNotNull() + { + foreach (var item in source) + { + if (item is not null) + yield return item.Value; + } + } + } + + extension(IEnumerable source) + { + public IEnumerable WhereNotNullOrWhiteSpace() + { + foreach (var item in source) + { + if (!string.IsNullOrWhiteSpace(item)) + yield return item; + } + } + } + + extension(IEnumerable source) + where T : struct + { + public T? FirstOrNull() + { + foreach (var item in source) + return item; + + return null; + } + + public T? ElementAtOrNull(int index) + { + var list = source as IReadOnlyList ?? source.ToArray(); + return index < list.Count ? list[index] : null; + } + } +} diff --git a/PowerKit/Utils/Extensions/ExceptionExtensions.cs b/PowerKit/Utils/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..5a29cb7 --- /dev/null +++ b/PowerKit/Utils/Extensions/ExceptionExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PowerKit.Utils.Extensions; + +internal static class ExceptionExtensions +{ + extension(AggregateException exception) + { + public Exception? TryGetSingle() + { + var exceptions = exception.Flatten().InnerExceptions; + return exceptions.Count == 1 ? exceptions.Single() : null; + } + } + + extension(Exception exception) + { + private void PopulateDescendants(ICollection result) + { + if (exception is AggregateException aggregateException) + { + foreach (var innerException in aggregateException.InnerExceptions) + { + result.Add(innerException); + innerException.PopulateDescendants(result); + } + } + else if (exception.InnerException is not null) + { + result.Add(exception.InnerException); + exception.InnerException.PopulateDescendants(result); + } + } + + public IReadOnlyList GetSelfAndDescendants() + { + var result = new List { exception }; + exception.PopulateDescendants(result); + return result; + } + } +} diff --git a/PowerKit/Utils/Extensions/GenericExtensions.cs b/PowerKit/Utils/Extensions/GenericExtensions.cs new file mode 100644 index 0000000..73c120d --- /dev/null +++ b/PowerKit/Utils/Extensions/GenericExtensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace PowerKit.Utils.Extensions; + +internal static class GenericExtensions +{ + extension(TIn input) + { + public TOut Pipe(Func transform) => transform(input); + } + + extension(T value) + where T : struct + { + public T? NullIf(Func predicate) => !predicate(value) ? value : null; + + public T? NullIfDefault() => + value.NullIf(v => EqualityComparer.Default.Equals(v, default)); + } +} diff --git a/PowerKit/Utils/Extensions/PathExtensions.cs b/PowerKit/Utils/Extensions/PathExtensions.cs new file mode 100644 index 0000000..d01c27d --- /dev/null +++ b/PowerKit/Utils/Extensions/PathExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace PowerKit.Utils.Extensions; + +internal static class PathExtensions +{ + // This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS). + // We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters, + // not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux, + // which would make some additional characters invalid that are otherwise valid on Linux. + private static readonly HashSet InvalidFileNameChars = + [ + '\0', // Null character - invalid on all filesystems + '/', // Path separator on Unix and Windows + '\\', // Path separator on Windows + ':', // Reserved on Windows (drive letters, NTFS streams) + '*', // Wildcard on Windows + '?', // Wildcard on Windows + '"', // Reserved on Windows + '<', // Redirection on Windows + '>', // Redirection on Windows + '|', // Pipe on Windows + ]; + + extension(Path) + { + public static string EscapeFileName(string path) + { + var buffer = new StringBuilder(path.Length); + + foreach (var c in path) + buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); + + // File names cannot end with a dot on Windows + if (OperatingSystem.IsWindows()) + { + while (buffer.Length > 0 && buffer[^1] == '.') + buffer.Remove(buffer.Length - 1, 1); + } + + return buffer.ToString(); + } + } +} diff --git a/PowerKit/Utils/Extensions/StreamExtensions.cs b/PowerKit/Utils/Extensions/StreamExtensions.cs new file mode 100644 index 0000000..b044769 --- /dev/null +++ b/PowerKit/Utils/Extensions/StreamExtensions.cs @@ -0,0 +1,67 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerKit.Utils.Extensions; + +internal static class StreamExtensions +{ + extension(Stream source) + { + public async Task CopyToAsync( + Stream destination, + bool autoFlush, + CancellationToken cancellationToken = default + ) + { + using var buffer = MemoryPool.Shared.Rent(81920); + + while (true) + { + var bytesRead = await source + .ReadAsync(buffer.Memory, cancellationToken) + .ConfigureAwait(false); + + if (bytesRead <= 0) + break; + + await destination + .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) + .ConfigureAwait(false); + + if (autoFlush) + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask CopyToAsync( + Stream destination, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + using var buffer = MemoryPool.Shared.Rent(81920); + + var totalBytesRead = 0L; + + while (true) + { + var bytesRead = await source + .ReadAsync(buffer.Memory, cancellationToken) + .ConfigureAwait(false); + + if (bytesRead <= 0) + break; + + await destination + .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) + .ConfigureAwait(false); + + totalBytesRead += bytesRead; + progress?.Report(1.0 * totalBytesRead / source.Length); + } + } + } +} diff --git a/PowerKit/Utils/Extensions/StringExtensions.cs b/PowerKit/Utils/Extensions/StringExtensions.cs new file mode 100644 index 0000000..d17e890 --- /dev/null +++ b/PowerKit/Utils/Extensions/StringExtensions.cs @@ -0,0 +1,54 @@ +using System.Text; + +namespace PowerKit.Utils.Extensions; + +internal static class StringExtensions +{ + extension(string str) + { + public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null; + + public string SubstringUntil( + string sub, + System.StringComparison comparison = System.StringComparison.Ordinal + ) => + str.IndexOf(sub, comparison) switch + { + >= 0 and var index => str[..index], + _ => str, + }; + + public string SubstringAfter( + string sub, + System.StringComparison comparison = System.StringComparison.Ordinal + ) => + str.IndexOf(sub, comparison) switch + { + >= 0 and var index => str[(index + sub.Length)..], + _ => "", + }; + + public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str; + + public string ToSpaceSeparatedWords() + { + var builder = new StringBuilder(str.Length * 2); + + foreach (var c in str) + { + if (char.IsUpper(c) && builder.Length > 0) + builder.Append(' '); + + builder.Append(c); + } + + return builder.ToString(); + } + } + + extension(StringBuilder builder) + { + public StringBuilder AppendIfNotEmpty(char value) => + builder.Length > 0 ? builder.Append(value) : builder; + } +} diff --git a/PowerKit/Utils/Extensions/TextReaderExtensions.cs b/PowerKit/Utils/Extensions/TextReaderExtensions.cs new file mode 100644 index 0000000..68363ea --- /dev/null +++ b/PowerKit/Utils/Extensions/TextReaderExtensions.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace PowerKit.Utils.Extensions; + +internal static class TextReaderExtensions +{ + extension(TextReader reader) + { + public async IAsyncEnumerable ReadLinesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + while ( + await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line + ) + { + yield return line; + } + } + } +} diff --git a/PowerKit/Utils/Extensions/TimeSpanExtensions.cs b/PowerKit/Utils/Extensions/TimeSpanExtensions.cs new file mode 100644 index 0000000..ea0a025 --- /dev/null +++ b/PowerKit/Utils/Extensions/TimeSpanExtensions.cs @@ -0,0 +1,20 @@ +using System; + +namespace PowerKit.Utils.Extensions; + +internal static class TimeSpanExtensions +{ + extension(TimeSpan value) + { + public TimeSpan Clamp(TimeSpan min, TimeSpan max) + { + if (value < min) + return min; + + if (value > max) + return max; + + return value; + } + } +} diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} From ad3489c389d5f9f80743a0619d6504c482b1d6e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:25:39 +0000 Subject: [PATCH 03/43] refactor: remove Utils namespace/dir, move files one level up Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/56664dc8-6a19-453c-9c1a-fa9b6aa2d4b8 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/{Utils => }/Disposable.cs | 2 +- PowerKit/{Utils => }/Extensions/AsyncCollectionExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/CollectionExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/ExceptionExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/GenericExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/PathExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/StreamExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/StringExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/TextReaderExtensions.cs | 2 +- PowerKit/{Utils => }/Extensions/TimeSpanExtensions.cs | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) rename PowerKit/{Utils => }/Disposable.cs (95%) rename PowerKit/{Utils => }/Extensions/AsyncCollectionExtensions.cs (97%) rename PowerKit/{Utils => }/Extensions/CollectionExtensions.cs (97%) rename PowerKit/{Utils => }/Extensions/ExceptionExtensions.cs (97%) rename PowerKit/{Utils => }/Extensions/GenericExtensions.cs (92%) rename PowerKit/{Utils => }/Extensions/PathExtensions.cs (97%) rename PowerKit/{Utils => }/Extensions/StreamExtensions.cs (98%) rename PowerKit/{Utils => }/Extensions/StringExtensions.cs (97%) rename PowerKit/{Utils => }/Extensions/TextReaderExtensions.cs (93%) rename PowerKit/{Utils => }/Extensions/TimeSpanExtensions.cs (89%) diff --git a/PowerKit/Utils/Disposable.cs b/PowerKit/Disposable.cs similarity index 95% rename from PowerKit/Utils/Disposable.cs rename to PowerKit/Disposable.cs index c1aa324..c79eed0 100644 --- a/PowerKit/Utils/Disposable.cs +++ b/PowerKit/Disposable.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace PowerKit.Utils; +namespace PowerKit; internal partial class Disposable(Action dispose) : IDisposable { diff --git a/PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs b/PowerKit/Extensions/AsyncCollectionExtensions.cs similarity index 97% rename from PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs rename to PowerKit/Extensions/AsyncCollectionExtensions.cs index 3806161..86ed232 100644 --- a/PowerKit/Utils/Extensions/AsyncCollectionExtensions.cs +++ b/PowerKit/Extensions/AsyncCollectionExtensions.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class AsyncCollectionExtensions { diff --git a/PowerKit/Utils/Extensions/CollectionExtensions.cs b/PowerKit/Extensions/CollectionExtensions.cs similarity index 97% rename from PowerKit/Utils/Extensions/CollectionExtensions.cs rename to PowerKit/Extensions/CollectionExtensions.cs index 99914ea..544bd32 100644 --- a/PowerKit/Utils/Extensions/CollectionExtensions.cs +++ b/PowerKit/Extensions/CollectionExtensions.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class CollectionExtensions { diff --git a/PowerKit/Utils/Extensions/ExceptionExtensions.cs b/PowerKit/Extensions/ExceptionExtensions.cs similarity index 97% rename from PowerKit/Utils/Extensions/ExceptionExtensions.cs rename to PowerKit/Extensions/ExceptionExtensions.cs index 5a29cb7..0b13f52 100644 --- a/PowerKit/Utils/Extensions/ExceptionExtensions.cs +++ b/PowerKit/Extensions/ExceptionExtensions.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class ExceptionExtensions { diff --git a/PowerKit/Utils/Extensions/GenericExtensions.cs b/PowerKit/Extensions/GenericExtensions.cs similarity index 92% rename from PowerKit/Utils/Extensions/GenericExtensions.cs rename to PowerKit/Extensions/GenericExtensions.cs index 73c120d..e69e82c 100644 --- a/PowerKit/Utils/Extensions/GenericExtensions.cs +++ b/PowerKit/Extensions/GenericExtensions.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class GenericExtensions { diff --git a/PowerKit/Utils/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs similarity index 97% rename from PowerKit/Utils/Extensions/PathExtensions.cs rename to PowerKit/Extensions/PathExtensions.cs index d01c27d..2a83ee4 100644 --- a/PowerKit/Utils/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -3,7 +3,7 @@ using System.IO; using System.Text; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class PathExtensions { diff --git a/PowerKit/Utils/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs similarity index 98% rename from PowerKit/Utils/Extensions/StreamExtensions.cs rename to PowerKit/Extensions/StreamExtensions.cs index b044769..2e5a37b 100644 --- a/PowerKit/Utils/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class StreamExtensions { diff --git a/PowerKit/Utils/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs similarity index 97% rename from PowerKit/Utils/Extensions/StringExtensions.cs rename to PowerKit/Extensions/StringExtensions.cs index d17e890..9e7790b 100644 --- a/PowerKit/Utils/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -1,6 +1,6 @@ using System.Text; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class StringExtensions { diff --git a/PowerKit/Utils/Extensions/TextReaderExtensions.cs b/PowerKit/Extensions/TextReaderExtensions.cs similarity index 93% rename from PowerKit/Utils/Extensions/TextReaderExtensions.cs rename to PowerKit/Extensions/TextReaderExtensions.cs index 68363ea..08e1323 100644 --- a/PowerKit/Utils/Extensions/TextReaderExtensions.cs +++ b/PowerKit/Extensions/TextReaderExtensions.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; using System.Threading; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class TextReaderExtensions { diff --git a/PowerKit/Utils/Extensions/TimeSpanExtensions.cs b/PowerKit/Extensions/TimeSpanExtensions.cs similarity index 89% rename from PowerKit/Utils/Extensions/TimeSpanExtensions.cs rename to PowerKit/Extensions/TimeSpanExtensions.cs index ea0a025..08e5a9d 100644 --- a/PowerKit/Utils/Extensions/TimeSpanExtensions.cs +++ b/PowerKit/Extensions/TimeSpanExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace PowerKit.Utils.Extensions; +namespace PowerKit.Extensions; internal static class TimeSpanExtensions { From e5886168349de7951032cf792ddf2b2b96e51ff7 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:41:43 +0300 Subject: [PATCH 04/43] Change copyright year to 2026 Updated copyright year in the License file. --- License.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/License.txt b/License.txt index 321d06a..68c3d52 100644 --- a/License.txt +++ b/License.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2026 Oleksii Holub +Copyright (c) 2026 Oleksii Holub Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 8c4b9aa99f0ab9f887e60ad1f91cfd6cf9d22e53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:49:09 +0000 Subject: [PATCH 05/43] =?UTF-8?q?refactor:=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20rename=20classes,=20split=20files,=20netstandard?= =?UTF-8?q?2.0,=20remove=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/7af97092-2f8d-463c-8357-7b2ac9afa654 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- Directory.Build.props | 2 +- PowerKit.slnx | 8 ++++ .../AggregateExceptionExtensions.cs | 16 +++++++ ...nsions.cs => AsyncEnumerableExtensions.cs} | 2 +- ...nExtensions.cs => EnumerableExtensions.cs} | 14 ++---- PowerKit/Extensions/ExceptionExtensions.cs | 44 +++++++------------ ...cExtensions.cs => FunctionalExtensions.cs} | 2 +- PowerKit/Extensions/ObjectExtensions.cs | 14 ++++++ PowerKit/Extensions/PathExtensions.cs | 10 ++--- .../Extensions/StringBuilderExtensions.cs | 12 +++++ PowerKit/Extensions/StringExtensions.cs | 6 --- PowerKit/PowerKit.csproj | 10 +++-- PowerKit/PowerKit.props | 5 --- 13 files changed, 82 insertions(+), 63 deletions(-) create mode 100644 PowerKit/Extensions/AggregateExceptionExtensions.cs rename PowerKit/Extensions/{AsyncCollectionExtensions.cs => AsyncEnumerableExtensions.cs} (96%) rename PowerKit/Extensions/{CollectionExtensions.cs => EnumerableExtensions.cs} (82%) rename PowerKit/Extensions/{GenericExtensions.cs => FunctionalExtensions.cs} (91%) create mode 100644 PowerKit/Extensions/ObjectExtensions.cs create mode 100644 PowerKit/Extensions/StringBuilderExtensions.cs delete mode 100644 PowerKit/PowerKit.props diff --git a/Directory.Build.props b/Directory.Build.props index 50a8b42..85c9b94 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ $(Company) - Collection of shared utility extensions for Tyrrrz projects. + Collection of utilities and extensions for rapid .NET development utils extensions utilities source https://github.com/Tyrrrz/PowerKit https://github.com/Tyrrrz/PowerKit/releases diff --git a/PowerKit.slnx b/PowerKit.slnx index 59e4068..fbcbc92 100644 --- a/PowerKit.slnx +++ b/PowerKit.slnx @@ -1,3 +1,11 @@ + + + + + + + + diff --git a/PowerKit/Extensions/AggregateExceptionExtensions.cs b/PowerKit/Extensions/AggregateExceptionExtensions.cs new file mode 100644 index 0000000..b0aaaaf --- /dev/null +++ b/PowerKit/Extensions/AggregateExceptionExtensions.cs @@ -0,0 +1,16 @@ +using System; +using System.Linq; + +namespace PowerKit.Extensions; + +internal static class AggregateExceptionExtensions +{ + extension(AggregateException exception) + { + public Exception? TryGetSingle() + { + var exceptions = exception.Flatten().InnerExceptions; + return exceptions.Count == 1 ? exceptions.Single() : null; + } + } +} diff --git a/PowerKit/Extensions/AsyncCollectionExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs similarity index 96% rename from PowerKit/Extensions/AsyncCollectionExtensions.cs rename to PowerKit/Extensions/AsyncEnumerableExtensions.cs index 86ed232..e5b56d0 100644 --- a/PowerKit/Extensions/AsyncCollectionExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -5,7 +5,7 @@ namespace PowerKit.Extensions; -internal static class AsyncCollectionExtensions +internal static class AsyncEnumerableExtensions { extension(IAsyncEnumerable source) { diff --git a/PowerKit/Extensions/CollectionExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs similarity index 82% rename from PowerKit/Extensions/CollectionExtensions.cs rename to PowerKit/Extensions/EnumerableExtensions.cs index 544bd32..fa147c2 100644 --- a/PowerKit/Extensions/CollectionExtensions.cs +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -3,16 +3,8 @@ namespace PowerKit.Extensions; -internal static class CollectionExtensions +internal static class EnumerableExtensions { - extension(T obj) - { - public IEnumerable ToSingletonEnumerable() - { - yield return obj; - } - } - extension(IEnumerable source) where T : class { @@ -46,7 +38,7 @@ public IEnumerable WhereNotNullOrWhiteSpace() foreach (var item in source) { if (!string.IsNullOrWhiteSpace(item)) - yield return item; + yield return item!; } } } @@ -65,7 +57,7 @@ public IEnumerable WhereNotNullOrWhiteSpace() public T? ElementAtOrNull(int index) { var list = source as IReadOnlyList ?? source.ToArray(); - return index < list.Count ? list[index] : null; + return index >= 0 && index < list.Count ? list[index] : null; } } } diff --git a/PowerKit/Extensions/ExceptionExtensions.cs b/PowerKit/Extensions/ExceptionExtensions.cs index 0b13f52..2c98a95 100644 --- a/PowerKit/Extensions/ExceptionExtensions.cs +++ b/PowerKit/Extensions/ExceptionExtensions.cs @@ -1,44 +1,34 @@ using System; using System.Collections.Generic; -using System.Linq; namespace PowerKit.Extensions; internal static class ExceptionExtensions { - extension(AggregateException exception) - { - public Exception? TryGetSingle() - { - var exceptions = exception.Flatten().InnerExceptions; - return exceptions.Count == 1 ? exceptions.Single() : null; - } - } - extension(Exception exception) { - private void PopulateDescendants(ICollection result) + public IReadOnlyList GetSelfAndDescendants() { - if (exception is AggregateException aggregateException) + var result = new List { exception }; + PopulateDescendants(exception, result); + return result; + + static void PopulateDescendants(Exception ex, ICollection result) { - foreach (var innerException in aggregateException.InnerExceptions) + if (ex is AggregateException aggregateException) { - result.Add(innerException); - innerException.PopulateDescendants(result); + foreach (var innerException in aggregateException.InnerExceptions) + { + result.Add(innerException); + PopulateDescendants(innerException, result); + } + } + else if (ex.InnerException is not null) + { + result.Add(ex.InnerException); + PopulateDescendants(ex.InnerException, result); } } - else if (exception.InnerException is not null) - { - result.Add(exception.InnerException); - exception.InnerException.PopulateDescendants(result); - } - } - - public IReadOnlyList GetSelfAndDescendants() - { - var result = new List { exception }; - exception.PopulateDescendants(result); - return result; } } } diff --git a/PowerKit/Extensions/GenericExtensions.cs b/PowerKit/Extensions/FunctionalExtensions.cs similarity index 91% rename from PowerKit/Extensions/GenericExtensions.cs rename to PowerKit/Extensions/FunctionalExtensions.cs index e69e82c..8b8d49d 100644 --- a/PowerKit/Extensions/GenericExtensions.cs +++ b/PowerKit/Extensions/FunctionalExtensions.cs @@ -3,7 +3,7 @@ namespace PowerKit.Extensions; -internal static class GenericExtensions +internal static class FunctionalExtensions { extension(TIn input) { diff --git a/PowerKit/Extensions/ObjectExtensions.cs b/PowerKit/Extensions/ObjectExtensions.cs new file mode 100644 index 0000000..4225646 --- /dev/null +++ b/PowerKit/Extensions/ObjectExtensions.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace PowerKit.Extensions; + +internal static class ObjectExtensions +{ + extension(T obj) + { + public IEnumerable ToSingletonEnumerable() + { + yield return obj; + } + } +} diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 2a83ee4..b95e3f7 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -34,12 +33,9 @@ public static string EscapeFileName(string path) foreach (var c in path) buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); - // File names cannot end with a dot on Windows - if (OperatingSystem.IsWindows()) - { - while (buffer.Length > 0 && buffer[^1] == '.') - buffer.Remove(buffer.Length - 1, 1); - } + // File names cannot end with a dot (invalid on Windows, ambiguous on other filesystems) + while (buffer.Length > 0 && buffer[buffer.Length - 1] == '.') + buffer.Remove(buffer.Length - 1, 1); return buffer.ToString(); } diff --git a/PowerKit/Extensions/StringBuilderExtensions.cs b/PowerKit/Extensions/StringBuilderExtensions.cs new file mode 100644 index 0000000..15ef10f --- /dev/null +++ b/PowerKit/Extensions/StringBuilderExtensions.cs @@ -0,0 +1,12 @@ +using System.Text; + +namespace PowerKit.Extensions; + +internal static class StringBuilderExtensions +{ + extension(StringBuilder builder) + { + public StringBuilder AppendIfNotEmpty(char value) => + builder.Length > 0 ? builder.Append(value) : builder; + } +} diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index 9e7790b..57ef073 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -45,10 +45,4 @@ public string ToSpaceSeparatedWords() return builder.ToString(); } } - - extension(StringBuilder builder) - { - public StringBuilder AppendIfNotEmpty(char value) => - builder.Length > 0 ? builder.Append(value) : builder; - } } diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj index 7a27e2e..14d8aaf 100644 --- a/PowerKit/PowerKit.csproj +++ b/PowerKit/PowerKit.csproj @@ -1,8 +1,6 @@ - - - net9.0 + netstandard2.0 true @@ -13,7 +11,11 @@ - + + + + + - - preview - - From 1c7387300dee538e0112c4d00613d2d7cdc3d780 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:01:30 +0000 Subject: [PATCH 06/43] =?UTF-8?q?fix:=20address=20automated=20review=20fee?= =?UTF-8?q?dback=20=E2=80=94=20TakeAsync=20guard,=20ConfigureAwait,=20CanS?= =?UTF-8?q?eek,=20fileName=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/f217b614-b393-402a-857b-37113c1e00d1 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../Extensions/AsyncEnumerableExtensions.cs | 21 ++++++++++++++++--- PowerKit/Extensions/PathExtensions.cs | 6 +++--- PowerKit/Extensions/StreamExtensions.cs | 4 +++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs index e5b56d0..a6764bb 100644 --- a/PowerKit/Extensions/AsyncEnumerableExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -14,9 +14,16 @@ public async IAsyncEnumerable TakeAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default ) { + if (count <= 0) + yield break; + var currentCount = 0; - await foreach (var item in source.WithCancellation(cancellationToken)) + await foreach ( + var item in source + .WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) { if (currentCount >= count) yield break; @@ -31,7 +38,11 @@ public async IAsyncEnumerable SelectManyAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default ) { - await foreach (var item in source.WithCancellation(cancellationToken)) + await foreach ( + var item in source + .WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) foreach (var result in transform(item)) yield return result; } @@ -42,7 +53,11 @@ public async ValueTask> ToListAsync( { var list = new List(); - await foreach (var item in source.WithCancellation(cancellationToken)) + await foreach ( + var item in source + .WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) list.Add(item); return list; diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index b95e3f7..c962be3 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -26,11 +26,11 @@ internal static class PathExtensions extension(Path) { - public static string EscapeFileName(string path) + public static string EscapeFileName(string fileName) { - var buffer = new StringBuilder(path.Length); + var buffer = new StringBuilder(fileName.Length); - foreach (var c in path) + foreach (var c in fileName) buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); // File names cannot end with a dot (invalid on Windows, ambiguous on other filesystems) diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index 2e5a37b..6925eed 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -60,7 +60,9 @@ await destination .ConfigureAwait(false); totalBytesRead += bytesRead; - progress?.Report(1.0 * totalBytesRead / source.Length); + + if (progress is not null && source.CanSeek && source.Length > 0) + progress.Report(1.0 * totalBytesRead / source.Length); } } } From 24c1daee1baf37e7aa68d45244edf2c76bbc7f7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:12:32 +0000 Subject: [PATCH 07/43] test: add PowerKit.Tests xunit project with 90 tests covering all utilities Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/353694f3-2e07-4be8-825a-8e291bb23a2b Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../AggregateExceptionExtensionsTests.cs | 42 +++++++ .../AsyncEnumerableExtensionsTests.cs | 99 ++++++++++++++++ PowerKit.Tests/DisposableTests.cs | 53 +++++++++ PowerKit.Tests/EnumerableExtensionsTests.cs | 99 ++++++++++++++++ PowerKit.Tests/ExceptionExtensionsTests.cs | 61 ++++++++++ PowerKit.Tests/FunctionalExtensionsTests.cs | 61 ++++++++++ PowerKit.Tests/ObjectExtensionsTests.cs | 29 +++++ PowerKit.Tests/PathExtensionsTests.cs | 61 ++++++++++ PowerKit.Tests/PowerKit.Tests.csproj | 27 +++++ PowerKit.Tests/StreamExtensionsTests.cs | 75 ++++++++++++ .../StringBuilderExtensionsTests.cs | 31 +++++ PowerKit.Tests/StringExtensionsTests.cs | 108 ++++++++++++++++++ PowerKit.Tests/TextReaderExtensionsTests.cs | 31 +++++ PowerKit.Tests/TimeSpanExtensionsTests.cs | 46 ++++++++ PowerKit.slnx | 1 + 15 files changed, 824 insertions(+) create mode 100644 PowerKit.Tests/AggregateExceptionExtensionsTests.cs create mode 100644 PowerKit.Tests/AsyncEnumerableExtensionsTests.cs create mode 100644 PowerKit.Tests/DisposableTests.cs create mode 100644 PowerKit.Tests/EnumerableExtensionsTests.cs create mode 100644 PowerKit.Tests/ExceptionExtensionsTests.cs create mode 100644 PowerKit.Tests/FunctionalExtensionsTests.cs create mode 100644 PowerKit.Tests/ObjectExtensionsTests.cs create mode 100644 PowerKit.Tests/PathExtensionsTests.cs create mode 100644 PowerKit.Tests/PowerKit.Tests.csproj create mode 100644 PowerKit.Tests/StreamExtensionsTests.cs create mode 100644 PowerKit.Tests/StringBuilderExtensionsTests.cs create mode 100644 PowerKit.Tests/StringExtensionsTests.cs create mode 100644 PowerKit.Tests/TextReaderExtensionsTests.cs create mode 100644 PowerKit.Tests/TimeSpanExtensionsTests.cs diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs new file mode 100644 index 0000000..c63ff8d --- /dev/null +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -0,0 +1,42 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class AggregateExceptionExtensionsTests +{ + [Fact] + public void TryGetSingle_SingleInnerException_ReturnsIt() + { + var inner = new Exception("only"); + var aggregate = new AggregateException(inner); + + Assert.Same(inner, aggregate.TryGetSingle()); + } + + [Fact] + public void TryGetSingle_MultipleInnerExceptions_ReturnsNull() + { + var aggregate = new AggregateException(new Exception("a"), new Exception("b")); + + Assert.Null(aggregate.TryGetSingle()); + } + + [Fact] + public void TryGetSingle_NestedAggregateWithOneLeaf_ReturnsLeaf() + { + var leaf = new Exception("leaf"); + var nested = new AggregateException(leaf); + var outer = new AggregateException(nested); + + Assert.Same(leaf, outer.TryGetSingle()); + } + + [Fact] + public void TryGetSingle_NestedAggregateWithMultipleLeaves_ReturnsNull() + { + var nested = new AggregateException(new Exception("a"), new Exception("b")); + var outer = new AggregateException(nested); + + Assert.Null(outer.TryGetSingle()); + } +} diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs new file mode 100644 index 0000000..f2e8462 --- /dev/null +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -0,0 +1,99 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class AsyncEnumerableExtensionsTests +{ + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + yield return item; + } + + [Fact] + public async Task TakeAsync_ZeroCount_ReturnsEmpty() + { + var source = ToAsyncEnumerable([1, 2, 3]); + var result = await source.TakeAsync(0).ToListAsync(); + Assert.Empty(result); + } + + [Fact] + public async Task TakeAsync_NegativeCount_ReturnsEmpty() + { + var source = ToAsyncEnumerable([1, 2, 3]); + var result = await source.TakeAsync(-1).ToListAsync(); + Assert.Empty(result); + } + + [Fact] + public async Task TakeAsync_CountLessThanSource_ReturnsThatMany() + { + var source = ToAsyncEnumerable([1, 2, 3, 4, 5]); + var result = await source.TakeAsync(3).ToListAsync(); + Assert.Equal([1, 2, 3], result); + } + + [Fact] + public async Task TakeAsync_CountGreaterThanSource_ReturnsAll() + { + var source = ToAsyncEnumerable([1, 2, 3]); + var result = await source.TakeAsync(10).ToListAsync(); + Assert.Equal([1, 2, 3], result); + } + + [Fact] + public async Task TakeAsync_ZeroCount_DoesNotConsumeAnyElements() + { + var consumed = 0; + + async IAsyncEnumerable Tracked() + { + consumed++; + yield return 1; + } + + await Tracked().TakeAsync(0).ToListAsync(); + Assert.Equal(0, consumed); + } + + [Fact] + public async Task SelectManyAsync_FlattensResults() + { + var source = ToAsyncEnumerable(["ab", "cd", "ef"]); + var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); + Assert.Equal(['a', 'b', 'c', 'd', 'e', 'f'], result); + } + + [Fact] + public async Task SelectManyAsync_EmptySource_ReturnsEmpty() + { + var source = ToAsyncEnumerable(Array.Empty()); + var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); + Assert.Empty(result); + } + + [Fact] + public async Task ToListAsync_CollectsAllElements() + { + var source = ToAsyncEnumerable([1, 2, 3]); + var result = await source.ToListAsync(); + Assert.Equal([1, 2, 3], result); + } + + [Fact] + public async Task ToListAsync_EmptySource_ReturnsEmptyList() + { + var source = ToAsyncEnumerable(Array.Empty()); + var result = await source.ToListAsync(); + Assert.Empty(result); + } + + [Fact] + public async Task GetAwaiter_DirectAwait_ReturnsAllElements() + { + var source = ToAsyncEnumerable([10, 20, 30]); + var result = await source; + Assert.Equal([10, 20, 30], result); + } +} diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs new file mode 100644 index 0000000..5643353 --- /dev/null +++ b/PowerKit.Tests/DisposableTests.cs @@ -0,0 +1,53 @@ +using PowerKit; + +namespace PowerKit.Tests; + +public class DisposableTests +{ + [Fact] + public void Null_CanBeDisposedWithoutEffect() + { + // Should not throw + Disposable.Null.Dispose(); + } + + [Fact] + public void Create_InvokesActionOnDispose() + { + var invoked = false; + var disposable = Disposable.Create(() => invoked = true); + + Assert.False(invoked); + disposable.Dispose(); + Assert.True(invoked); + } + + [Fact] + public void Merge_DisposesAllItems() + { + var count = 0; + var disposables = Enumerable + .Range(0, 3) + .Select(_ => Disposable.Create(() => count++)) + .ToArray(); + + var merged = Disposable.Merge(disposables); + merged.Dispose(); + + Assert.Equal(3, count); + } + + [Fact] + public void Merge_DisposesInOrder() + { + var order = new List(); + var disposables = Enumerable + .Range(0, 3) + .Select(i => Disposable.Create(() => order.Add(i))) + .ToArray(); + + Disposable.Merge(disposables).Dispose(); + + Assert.Equal([0, 1, 2], order); + } +} diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs new file mode 100644 index 0000000..b2aa0e3 --- /dev/null +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -0,0 +1,99 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class EnumerableExtensionsTests +{ + [Fact] + public void WhereNotNull_ReferenceType_FiltersNulls() + { + string?[] source = ["a", null, "b", null, "c"]; + Assert.Equal(["a", "b", "c"], source.WhereNotNull()); + } + + [Fact] + public void WhereNotNull_ReferenceType_EmptySource_ReturnsEmpty() + { + Assert.Empty(Array.Empty().WhereNotNull()); + } + + [Fact] + public void WhereNotNull_NullableStruct_FiltersNulls() + { + int?[] source = [1, null, 2, null, 3]; + Assert.Equal([1, 2, 3], source.WhereNotNull()); + } + + [Fact] + public void WhereNotNull_NullableStruct_EmptySource_ReturnsEmpty() + { + Assert.Empty(Array.Empty().WhereNotNull()); + } + + [Fact] + public void WhereNotNullOrWhiteSpace_FiltersNullsAndWhitespace() + { + string?[] source = ["hello", null, " ", "", "world"]; + Assert.Equal(["hello", "world"], source.WhereNotNullOrWhiteSpace()); + } + + [Fact] + public void WhereNotNullOrWhiteSpace_EmptySource_ReturnsEmpty() + { + Assert.Empty(Array.Empty().WhereNotNullOrWhiteSpace()); + } + + [Fact] + public void FirstOrNull_NonEmptySource_ReturnsFirst() + { + int[] source = [5, 10, 15]; + Assert.Equal(5, source.FirstOrNull()); + } + + [Fact] + public void FirstOrNull_EmptySource_ReturnsNull() + { + Assert.Null(Array.Empty().FirstOrNull()); + } + + [Fact] + public void ElementAtOrNull_ValidIndex_ReturnsElement() + { + int[] source = [10, 20, 30]; + Assert.Equal(20, source.ElementAtOrNull(1)); + } + + [Fact] + public void ElementAtOrNull_IndexZero_ReturnsFirst() + { + int[] source = [42, 99]; + Assert.Equal(42, source.ElementAtOrNull(0)); + } + + [Fact] + public void ElementAtOrNull_IndexAtLastElement_ReturnsLast() + { + int[] source = [1, 2, 3]; + Assert.Equal(3, source.ElementAtOrNull(2)); + } + + [Fact] + public void ElementAtOrNull_IndexOutOfRange_ReturnsNull() + { + int[] source = [1, 2, 3]; + Assert.Null(source.ElementAtOrNull(10)); + } + + [Fact] + public void ElementAtOrNull_NegativeIndex_ReturnsNull() + { + int[] source = [1, 2, 3]; + Assert.Null(source.ElementAtOrNull(-1)); + } + + [Fact] + public void ElementAtOrNull_EmptySource_ReturnsNull() + { + Assert.Null(Array.Empty().ElementAtOrNull(0)); + } +} diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs new file mode 100644 index 0000000..b3be599 --- /dev/null +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -0,0 +1,61 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class ExceptionExtensionsTests +{ + [Fact] + public void GetSelfAndDescendants_NoInner_ReturnsSelf() + { + var ex = new Exception("root"); + var result = ex.GetSelfAndDescendants(); + Assert.Single(result, ex); + } + + [Fact] + public void GetSelfAndDescendants_WithInnerException_ReturnsSelfAndInner() + { + var inner = new Exception("inner"); + var outer = new Exception("outer", inner); + + var result = outer.GetSelfAndDescendants(); + + Assert.Equal([outer, inner], result); + } + + [Fact] + public void GetSelfAndDescendants_WithChainedInnerExceptions_ReturnsAll() + { + var leaf = new Exception("leaf"); + var middle = new Exception("middle", leaf); + var root = new Exception("root", middle); + + var result = root.GetSelfAndDescendants(); + + Assert.Equal([root, middle, leaf], result); + } + + [Fact] + public void GetSelfAndDescendants_WithAggregateException_FlattensInnerExceptions() + { + var inner1 = new Exception("inner1"); + var inner2 = new Exception("inner2"); + var aggregate = new AggregateException("aggregate", inner1, inner2); + + var result = aggregate.GetSelfAndDescendants(); + + Assert.Equal([aggregate, inner1, inner2], result); + } + + [Fact] + public void GetSelfAndDescendants_NestedAggregateException_ReturnsAllDescendants() + { + var leaf = new Exception("leaf"); + var inner = new AggregateException("inner", leaf); + var root = new AggregateException("root", inner); + + var result = root.GetSelfAndDescendants(); + + Assert.Equal([root, inner, leaf], result); + } +} diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs new file mode 100644 index 0000000..527c995 --- /dev/null +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -0,0 +1,61 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class FunctionalExtensionsTests +{ + [Fact] + public void Pipe_TransformsValue() + { + var result = 5.Pipe(x => x * 2); + Assert.Equal(10, result); + } + + [Fact] + public void Pipe_ChainedCalls_AppliesInOrder() + { + var result = "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!"); + Assert.Equal("HELLO!", result); + } + + [Fact] + public void NullIf_PredicateMatches_ReturnsNull() + { + int value = 0; + Assert.Null(value.NullIf(v => v == 0)); + } + + [Fact] + public void NullIf_PredicateDoesNotMatch_ReturnsValue() + { + int value = 5; + Assert.Equal(5, value.NullIf(v => v == 0)); + } + + [Fact] + public void NullIfDefault_DefaultValue_ReturnsNull() + { + int value = 0; + Assert.Null(value.NullIfDefault()); + } + + [Fact] + public void NullIfDefault_NonDefaultValue_ReturnsValue() + { + int value = 42; + Assert.Equal(42, value.NullIfDefault()); + } + + [Fact] + public void NullIfDefault_DefaultGuid_ReturnsNull() + { + Assert.Null(Guid.Empty.NullIfDefault()); + } + + [Fact] + public void NullIfDefault_NonDefaultGuid_ReturnsValue() + { + var id = Guid.NewGuid(); + Assert.Equal(id, id.NullIfDefault()); + } +} diff --git a/PowerKit.Tests/ObjectExtensionsTests.cs b/PowerKit.Tests/ObjectExtensionsTests.cs new file mode 100644 index 0000000..7ac4128 --- /dev/null +++ b/PowerKit.Tests/ObjectExtensionsTests.cs @@ -0,0 +1,29 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class ObjectExtensionsTests +{ + [Fact] + public void ToSingletonEnumerable_ReturnsEnumerableWithSingleElement() + { + var result = 42.ToSingletonEnumerable().ToList(); + Assert.Single(result, 42); + } + + [Fact] + public void ToSingletonEnumerable_WorksWithReferenceType() + { + var obj = "hello"; + var result = obj.ToSingletonEnumerable().ToList(); + Assert.Single(result, "hello"); + } + + [Fact] + public void ToSingletonEnumerable_WorksWithNull() + { + string? obj = null; + var result = obj.ToSingletonEnumerable().ToList(); + Assert.Single(result, (string?)null); + } +} diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs new file mode 100644 index 0000000..7295c54 --- /dev/null +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -0,0 +1,61 @@ +using System.IO; +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class PathExtensionsTests +{ + [Fact] + public void EscapeFileName_ValidName_ReturnsUnchanged() + { + Assert.Equal("hello world.txt", Path.EscapeFileName("hello world.txt")); + } + + [Fact] + public void EscapeFileName_ReplacesForwardSlash() + { + Assert.Equal("a_b", Path.EscapeFileName("a/b")); + } + + [Fact] + public void EscapeFileName_ReplacesBackslash() + { + Assert.Equal("a_b", Path.EscapeFileName("a\\b")); + } + + [Fact] + public void EscapeFileName_ReplacesColon() + { + Assert.Equal("C_drive", Path.EscapeFileName("C:drive")); + } + + [Fact] + public void EscapeFileName_ReplacesAllInvalidChars() + { + Assert.Equal("a_b_c_d_e_f_g_h_i", Path.EscapeFileName("a\0b/c\\d:e*f?g\"h + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs new file mode 100644 index 0000000..d93a95f --- /dev/null +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -0,0 +1,75 @@ +using System.IO; +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class StreamExtensionsTests +{ + [Fact] + public async Task CopyToAsync_WithAutoFlush_CopiesAllBytes() + { + var data = new byte[] { 1, 2, 3, 4, 5 }; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + await source.CopyToAsync(destination, autoFlush: true); + + Assert.Equal(data, destination.ToArray()); + } + + [Fact] + public async Task CopyToAsync_WithoutAutoFlush_CopiesAllBytes() + { + var data = new byte[] { 10, 20, 30 }; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + await source.CopyToAsync(destination, autoFlush: false); + + Assert.Equal(data, destination.ToArray()); + } + + [Fact] + public async Task CopyToAsync_EmptySource_ProducesEmptyDestination() + { + using var source = new MemoryStream(); + using var destination = new MemoryStream(); + + await source.CopyToAsync(destination, autoFlush: false); + + Assert.Empty(destination.ToArray()); + } + + [Fact] + public async Task CopyToAsync_WithProgress_CopiesAllBytes() + { + var data = new byte[1024]; + new Random(0).NextBytes(data); + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + await source.CopyToAsync(destination, progress: null); + + Assert.Equal(data, destination.ToArray()); + } + + [Fact] + public async Task CopyToAsync_WithProgress_ReportsProgress() + { + var data = new byte[1024]; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + var reports = new List(); + var progress = new Progress(v => reports.Add(v)); + + await source.CopyToAsync(destination, progress: progress); + + // Allow Progress callbacks to fire on the thread pool + await Task.Delay(50); + + Assert.NotEmpty(reports); + Assert.All(reports, v => Assert.InRange(v, 0.0, 1.0)); + Assert.Equal(1.0, reports[^1], precision: 5); + } +} diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs new file mode 100644 index 0000000..abf3295 --- /dev/null +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -0,0 +1,31 @@ +using System.Text; +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class StringBuilderExtensionsTests +{ + [Fact] + public void AppendIfNotEmpty_EmptyBuilder_DoesNotAppend() + { + var builder = new StringBuilder(); + builder.AppendIfNotEmpty(','); + Assert.Equal("", builder.ToString()); + } + + [Fact] + public void AppendIfNotEmpty_NonEmptyBuilder_Appends() + { + var builder = new StringBuilder("hello"); + builder.AppendIfNotEmpty(','); + Assert.Equal("hello,", builder.ToString()); + } + + [Fact] + public void AppendIfNotEmpty_ReturnsBuilder_AllowsChaining() + { + var builder = new StringBuilder("a"); + builder.AppendIfNotEmpty(',').Append("b"); + Assert.Equal("a,b", builder.ToString()); + } +} diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000..d3d4080 --- /dev/null +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -0,0 +1,108 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class StringExtensionsTests +{ + [Fact] + public void NullIfWhiteSpace_NonWhitespaceString_ReturnsSame() + { + Assert.Equal("hello", "hello".NullIfWhiteSpace()); + } + + [Fact] + public void NullIfWhiteSpace_WhitespaceOnly_ReturnsNull() + { + Assert.Null(" ".NullIfWhiteSpace()); + } + + [Fact] + public void NullIfWhiteSpace_EmptyString_ReturnsNull() + { + Assert.Null("".NullIfWhiteSpace()); + } + + [Fact] + public void SubstringUntil_SubstringFound_ReturnsBeforeIt() + { + Assert.Equal("hello", "hello world".SubstringUntil(" ")); + } + + [Fact] + public void SubstringUntil_SubstringNotFound_ReturnsFullString() + { + Assert.Equal("hello", "hello".SubstringUntil("x")); + } + + [Fact] + public void SubstringUntil_SubstringAtStart_ReturnsEmpty() + { + Assert.Equal("", "xhello".SubstringUntil("x")); + } + + [Fact] + public void SubstringAfter_SubstringFound_ReturnsAfterIt() + { + Assert.Equal("world", "hello world".SubstringAfter(" ")); + } + + [Fact] + public void SubstringAfter_SubstringNotFound_ReturnsEmpty() + { + Assert.Equal("", "hello".SubstringAfter("x")); + } + + [Fact] + public void SubstringAfter_SubstringAtEnd_ReturnsEmpty() + { + Assert.Equal("", "hellox".SubstringAfter("x")); + } + + [Fact] + public void Truncate_StringShorterThanLimit_ReturnsFull() + { + Assert.Equal("hi", "hi".Truncate(10)); + } + + [Fact] + public void Truncate_StringExactlyAtLimit_ReturnsFull() + { + Assert.Equal("hello", "hello".Truncate(5)); + } + + [Fact] + public void Truncate_StringLongerThanLimit_ReturnsTruncated() + { + Assert.Equal("hel", "hello".Truncate(3)); + } + + [Fact] + public void ToSpaceSeparatedWords_PascalCase_InsertsSpacesBeforeUppercase() + { + Assert.Equal("Hello World", "HelloWorld".ToSpaceSeparatedWords()); + } + + [Fact] + public void ToSpaceSeparatedWords_SingleWord_ReturnsUnchanged() + { + Assert.Equal("Hello", "Hello".ToSpaceSeparatedWords()); + } + + [Fact] + public void ToSpaceSeparatedWords_AllLowercase_ReturnsUnchanged() + { + Assert.Equal("hello", "hello".ToSpaceSeparatedWords()); + } + + [Fact] + public void ToSpaceSeparatedWords_EmptyString_ReturnsEmpty() + { + Assert.Equal("", "".ToSpaceSeparatedWords()); + } + + [Fact] + public void ToSpaceSeparatedWords_MultipleWords_SplitsCorrectly() + { + Assert.Equal("Foo Bar Baz", "FooBarBaz".ToSpaceSeparatedWords()); + } +} diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs new file mode 100644 index 0000000..61d7e6b --- /dev/null +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -0,0 +1,31 @@ +using System.IO; +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class TextReaderExtensionsTests +{ + [Fact] + public async Task ReadLinesAsync_ReadsAllLines() + { + using var reader = new StringReader("line1\nline2\nline3"); + var lines = await reader.ReadLinesAsync().ToListAsync(); + Assert.Equal(["line1", "line2", "line3"], lines); + } + + [Fact] + public async Task ReadLinesAsync_EmptyReader_ReturnsEmpty() + { + using var reader = new StringReader(""); + var lines = await reader.ReadLinesAsync().ToListAsync(); + Assert.Empty(lines); + } + + [Fact] + public async Task ReadLinesAsync_SingleLine_ReturnsSingleLine() + { + using var reader = new StringReader("hello"); + var lines = await reader.ReadLinesAsync().ToListAsync(); + Assert.Single(lines, "hello"); + } +} diff --git a/PowerKit.Tests/TimeSpanExtensionsTests.cs b/PowerKit.Tests/TimeSpanExtensionsTests.cs new file mode 100644 index 0000000..5e282dd --- /dev/null +++ b/PowerKit.Tests/TimeSpanExtensionsTests.cs @@ -0,0 +1,46 @@ +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class TimeSpanExtensionsTests +{ + [Fact] + public void Clamp_ValueWithinRange_ReturnsValue() + { + var value = TimeSpan.FromSeconds(5); + var result = value.Clamp(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); + Assert.Equal(value, result); + } + + [Fact] + public void Clamp_ValueBelowMin_ReturnsMin() + { + var min = TimeSpan.FromSeconds(3); + var result = TimeSpan.FromSeconds(1).Clamp(min, TimeSpan.FromSeconds(10)); + Assert.Equal(min, result); + } + + [Fact] + public void Clamp_ValueAboveMax_ReturnsMax() + { + var max = TimeSpan.FromSeconds(7); + var result = TimeSpan.FromSeconds(20).Clamp(TimeSpan.FromSeconds(1), max); + Assert.Equal(max, result); + } + + [Fact] + public void Clamp_ValueEqualToMin_ReturnsMin() + { + var min = TimeSpan.FromSeconds(3); + var result = min.Clamp(min, TimeSpan.FromSeconds(10)); + Assert.Equal(min, result); + } + + [Fact] + public void Clamp_ValueEqualToMax_ReturnsMax() + { + var max = TimeSpan.FromSeconds(10); + var result = max.Clamp(TimeSpan.FromSeconds(1), max); + Assert.Equal(max, result); + } +} diff --git a/PowerKit.slnx b/PowerKit.slnx index fbcbc92..e5c1412 100644 --- a/PowerKit.slnx +++ b/PowerKit.slnx @@ -8,4 +8,5 @@ + From 6d9a8f0b6690ab3c6ef7bd47af3cd59f5ac597f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:29:45 +0000 Subject: [PATCH 08/43] test: adopt PolyShim naming and assertion conventions in test files Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/a47ab08b-63ac-4925-a52c-3bc1d197288a Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../AggregateExceptionExtensionsTests.cs | 37 +++++-- .../AsyncEnumerableExtensionsTests.cs | 90 ++++++++++++---- PowerKit.Tests/DisposableTests.cs | 46 +++++--- PowerKit.Tests/EnumerableExtensionsTests.cs | 100 ++++++++++++------ PowerKit.Tests/ExceptionExtensionsTests.cs | 38 +++++-- PowerKit.Tests/FunctionalExtensionsTests.cs | 63 +++++++---- PowerKit.Tests/ObjectExtensionsTests.cs | 27 +++-- PowerKit.Tests/PathExtensionsTests.cs | 46 ++++---- PowerKit.Tests/PowerKit.Tests.csproj | 1 + PowerKit.Tests/StreamExtensionsTests.cs | 41 ++++--- .../StringBuilderExtensionsTests.cs | 28 +++-- PowerKit.Tests/StringExtensionsTests.cs | 86 +++++++++------ PowerKit.Tests/TextReaderExtensionsTests.cs | 28 +++-- PowerKit.Tests/TimeSpanExtensionsTests.cs | 44 +++++--- 14 files changed, 476 insertions(+), 199 deletions(-) diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs index c63ff8d..dbad779 100644 --- a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,38 +6,58 @@ namespace PowerKit.Tests; public class AggregateExceptionExtensionsTests { [Fact] - public void TryGetSingle_SingleInnerException_ReturnsIt() + public void TryGetSingle_Test() { + // Arrange var inner = new Exception("only"); var aggregate = new AggregateException(inner); - Assert.Same(inner, aggregate.TryGetSingle()); + // Act + var result = aggregate.TryGetSingle(); + + // Assert + result.Should().BeSameAs(inner); } [Fact] - public void TryGetSingle_MultipleInnerExceptions_ReturnsNull() + public void TryGetSingle_Multiple_Test() { + // Arrange var aggregate = new AggregateException(new Exception("a"), new Exception("b")); - Assert.Null(aggregate.TryGetSingle()); + // Act + var result = aggregate.TryGetSingle(); + + // Assert + result.Should().BeNull(); } [Fact] - public void TryGetSingle_NestedAggregateWithOneLeaf_ReturnsLeaf() + public void TryGetSingle_NestedSingleLeaf_Test() { + // Arrange var leaf = new Exception("leaf"); var nested = new AggregateException(leaf); var outer = new AggregateException(nested); - Assert.Same(leaf, outer.TryGetSingle()); + // Act + var result = outer.TryGetSingle(); + + // Assert + result.Should().BeSameAs(leaf); } [Fact] - public void TryGetSingle_NestedAggregateWithMultipleLeaves_ReturnsNull() + public void TryGetSingle_NestedMultipleLeaves_Test() { + // Arrange var nested = new AggregateException(new Exception("a"), new Exception("b")); var outer = new AggregateException(nested); - Assert.Null(outer.TryGetSingle()); + // Act + var result = outer.TryGetSingle(); + + // Assert + result.Should().BeNull(); } } diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs index f2e8462..bfe24ae 100644 --- a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -11,40 +12,61 @@ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable sou } [Fact] - public async Task TakeAsync_ZeroCount_ReturnsEmpty() + public async Task TakeAsync_Zero_Test() { + // Arrange var source = ToAsyncEnumerable([1, 2, 3]); + + // Act var result = await source.TakeAsync(0).ToListAsync(); - Assert.Empty(result); + + // Assert + result.Should().BeEmpty(); } [Fact] - public async Task TakeAsync_NegativeCount_ReturnsEmpty() + public async Task TakeAsync_Negative_Test() { + // Arrange var source = ToAsyncEnumerable([1, 2, 3]); + + // Act var result = await source.TakeAsync(-1).ToListAsync(); - Assert.Empty(result); + + // Assert + result.Should().BeEmpty(); } [Fact] - public async Task TakeAsync_CountLessThanSource_ReturnsThatMany() + public async Task TakeAsync_Test() { + // Arrange var source = ToAsyncEnumerable([1, 2, 3, 4, 5]); + + // Act var result = await source.TakeAsync(3).ToListAsync(); - Assert.Equal([1, 2, 3], result); + + // Assert + result.Should().Equal(1, 2, 3); } [Fact] - public async Task TakeAsync_CountGreaterThanSource_ReturnsAll() + public async Task TakeAsync_CountExceedsSource_Test() { + // Arrange var source = ToAsyncEnumerable([1, 2, 3]); + + // Act var result = await source.TakeAsync(10).ToListAsync(); - Assert.Equal([1, 2, 3], result); + + // Assert + result.Should().Equal(1, 2, 3); } [Fact] - public async Task TakeAsync_ZeroCount_DoesNotConsumeAnyElements() + public async Task TakeAsync_Zero_DoesNotConsumeElements_Test() { + // Arrange var consumed = 0; async IAsyncEnumerable Tracked() @@ -53,47 +75,75 @@ async IAsyncEnumerable Tracked() yield return 1; } + // Act await Tracked().TakeAsync(0).ToListAsync(); - Assert.Equal(0, consumed); + + // Assert + consumed.Should().Be(0); } [Fact] - public async Task SelectManyAsync_FlattensResults() + public async Task SelectManyAsync_Test() { + // Arrange var source = ToAsyncEnumerable(["ab", "cd", "ef"]); + + // Act var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); - Assert.Equal(['a', 'b', 'c', 'd', 'e', 'f'], result); + + // Assert + result.Should().Equal('a', 'b', 'c', 'd', 'e', 'f'); } [Fact] - public async Task SelectManyAsync_EmptySource_ReturnsEmpty() + public async Task SelectManyAsync_Empty_Test() { + // Arrange var source = ToAsyncEnumerable(Array.Empty()); + + // Act var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); - Assert.Empty(result); + + // Assert + result.Should().BeEmpty(); } [Fact] - public async Task ToListAsync_CollectsAllElements() + public async Task ToListAsync_Test() { + // Arrange var source = ToAsyncEnumerable([1, 2, 3]); + + // Act var result = await source.ToListAsync(); - Assert.Equal([1, 2, 3], result); + + // Assert + result.Should().Equal(1, 2, 3); } [Fact] - public async Task ToListAsync_EmptySource_ReturnsEmptyList() + public async Task ToListAsync_Empty_Test() { + // Arrange var source = ToAsyncEnumerable(Array.Empty()); + + // Act var result = await source.ToListAsync(); - Assert.Empty(result); + + // Assert + result.Should().BeEmpty(); } [Fact] - public async Task GetAwaiter_DirectAwait_ReturnsAllElements() + public async Task GetAwaiter_Test() { + // Arrange var source = ToAsyncEnumerable([10, 20, 30]); + + // Act var result = await source; - Assert.Equal([10, 20, 30], result); + + // Assert + result.Should().Equal(10, 20, 30); } } diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs index 5643353..9665e8a 100644 --- a/PowerKit.Tests/DisposableTests.cs +++ b/PowerKit.Tests/DisposableTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit; namespace PowerKit.Tests; @@ -5,49 +6,70 @@ namespace PowerKit.Tests; public class DisposableTests { [Fact] - public void Null_CanBeDisposedWithoutEffect() + public void Null_Test() { - // Should not throw + // Act & assert Disposable.Null.Dispose(); } [Fact] - public void Create_InvokesActionOnDispose() + public void Create_Test() { + // Arrange var invoked = false; - var disposable = Disposable.Create(() => invoked = true); - Assert.False(invoked); + // Act + var disposable = Disposable.Create(() => invoked = true); disposable.Dispose(); - Assert.True(invoked); + + // Assert + invoked.Should().BeTrue(); } [Fact] - public void Merge_DisposesAllItems() + public void Create_NotDisposed_Test() { + // Arrange + var invoked = false; + + // Act + Disposable.Create(() => invoked = true); + + // Assert + invoked.Should().BeFalse(); + } + + [Fact] + public void Merge_Test() + { + // Arrange var count = 0; var disposables = Enumerable .Range(0, 3) .Select(_ => Disposable.Create(() => count++)) .ToArray(); - var merged = Disposable.Merge(disposables); - merged.Dispose(); + // Act + Disposable.Merge(disposables).Dispose(); - Assert.Equal(3, count); + // Assert + count.Should().Be(3); } [Fact] - public void Merge_DisposesInOrder() + public void Merge_Order_Test() { + // Arrange var order = new List(); var disposables = Enumerable .Range(0, 3) .Select(i => Disposable.Create(() => order.Add(i))) .ToArray(); + // Act Disposable.Merge(disposables).Dispose(); - Assert.Equal([0, 1, 2], order); + // Assert + order.Should().Equal(0, 1, 2); } } diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index b2aa0e3..9427a5d 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,95 +6,130 @@ namespace PowerKit.Tests; public class EnumerableExtensionsTests { [Fact] - public void WhereNotNull_ReferenceType_FiltersNulls() + public void WhereNotNull_ReferenceType_Test() { + // Arrange string?[] source = ["a", null, "b", null, "c"]; - Assert.Equal(["a", "b", "c"], source.WhereNotNull()); + + // Act + var result = source.WhereNotNull(); + + // Assert + result.Should().Equal("a", "b", "c"); } [Fact] - public void WhereNotNull_ReferenceType_EmptySource_ReturnsEmpty() + public void WhereNotNull_ReferenceType_Empty_Test() { - Assert.Empty(Array.Empty().WhereNotNull()); + // Act & assert + Array.Empty().WhereNotNull().Should().BeEmpty(); } [Fact] - public void WhereNotNull_NullableStruct_FiltersNulls() + public void WhereNotNull_NullableStruct_Test() { + // Arrange int?[] source = [1, null, 2, null, 3]; - Assert.Equal([1, 2, 3], source.WhereNotNull()); + + // Act + var result = source.WhereNotNull(); + + // Assert + result.Should().Equal(1, 2, 3); } [Fact] - public void WhereNotNull_NullableStruct_EmptySource_ReturnsEmpty() + public void WhereNotNull_NullableStruct_Empty_Test() { - Assert.Empty(Array.Empty().WhereNotNull()); + // Act & assert + Array.Empty().WhereNotNull().Should().BeEmpty(); } [Fact] - public void WhereNotNullOrWhiteSpace_FiltersNullsAndWhitespace() + public void WhereNotNullOrWhiteSpace_Test() { + // Arrange string?[] source = ["hello", null, " ", "", "world"]; - Assert.Equal(["hello", "world"], source.WhereNotNullOrWhiteSpace()); + + // Act + var result = source.WhereNotNullOrWhiteSpace(); + + // Assert + result.Should().Equal("hello", "world"); } [Fact] - public void WhereNotNullOrWhiteSpace_EmptySource_ReturnsEmpty() + public void WhereNotNullOrWhiteSpace_Empty_Test() { - Assert.Empty(Array.Empty().WhereNotNullOrWhiteSpace()); + // Act & assert + Array.Empty().WhereNotNullOrWhiteSpace().Should().BeEmpty(); } [Fact] - public void FirstOrNull_NonEmptySource_ReturnsFirst() + public void FirstOrNull_Test() { + // Arrange int[] source = [5, 10, 15]; - Assert.Equal(5, source.FirstOrNull()); + + // Act + var result = source.FirstOrNull(); + + // Assert + result.Should().Be(5); } [Fact] - public void FirstOrNull_EmptySource_ReturnsNull() + public void FirstOrNull_Empty_Test() { - Assert.Null(Array.Empty().FirstOrNull()); + // Act & assert + Array.Empty().FirstOrNull().Should().BeNull(); } [Fact] - public void ElementAtOrNull_ValidIndex_ReturnsElement() + public void ElementAtOrNull_Test() { + // Arrange int[] source = [10, 20, 30]; - Assert.Equal(20, source.ElementAtOrNull(1)); + + // Act + var result = source.ElementAtOrNull(1); + + // Assert + result.Should().Be(20); } [Fact] - public void ElementAtOrNull_IndexZero_ReturnsFirst() + public void ElementAtOrNull_First_Test() { - int[] source = [42, 99]; - Assert.Equal(42, source.ElementAtOrNull(0)); + // Act & assert + new[] { 42, 99 }.ElementAtOrNull(0).Should().Be(42); } [Fact] - public void ElementAtOrNull_IndexAtLastElement_ReturnsLast() + public void ElementAtOrNull_Last_Test() { - int[] source = [1, 2, 3]; - Assert.Equal(3, source.ElementAtOrNull(2)); + // Act & assert + new[] { 1, 2, 3 }.ElementAtOrNull(2).Should().Be(3); } [Fact] - public void ElementAtOrNull_IndexOutOfRange_ReturnsNull() + public void ElementAtOrNull_OutOfRange_Test() { - int[] source = [1, 2, 3]; - Assert.Null(source.ElementAtOrNull(10)); + // Act & assert + new[] { 1, 2, 3 }.ElementAtOrNull(10).Should().BeNull(); } [Fact] - public void ElementAtOrNull_NegativeIndex_ReturnsNull() + public void ElementAtOrNull_NegativeIndex_Test() { - int[] source = [1, 2, 3]; - Assert.Null(source.ElementAtOrNull(-1)); + // Act & assert + new[] { 1, 2, 3 }.ElementAtOrNull(-1).Should().BeNull(); } [Fact] - public void ElementAtOrNull_EmptySource_ReturnsNull() + public void ElementAtOrNull_Empty_Test() { - Assert.Null(Array.Empty().ElementAtOrNull(0)); + // Act & assert + Array.Empty().ElementAtOrNull(0).Should().BeNull(); } } diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs index b3be599..46888fe 100644 --- a/PowerKit.Tests/ExceptionExtensionsTests.cs +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,57 +6,74 @@ namespace PowerKit.Tests; public class ExceptionExtensionsTests { [Fact] - public void GetSelfAndDescendants_NoInner_ReturnsSelf() + public void GetSelfAndDescendants_NoInner_Test() { + // Arrange var ex = new Exception("root"); + + // Act var result = ex.GetSelfAndDescendants(); - Assert.Single(result, ex); + + // Assert + result.Should().Equal(ex); } [Fact] - public void GetSelfAndDescendants_WithInnerException_ReturnsSelfAndInner() + public void GetSelfAndDescendants_WithInner_Test() { + // Arrange var inner = new Exception("inner"); var outer = new Exception("outer", inner); + // Act var result = outer.GetSelfAndDescendants(); - Assert.Equal([outer, inner], result); + // Assert + result.Should().Equal(outer, inner); } [Fact] - public void GetSelfAndDescendants_WithChainedInnerExceptions_ReturnsAll() + public void GetSelfAndDescendants_Chained_Test() { + // Arrange var leaf = new Exception("leaf"); var middle = new Exception("middle", leaf); var root = new Exception("root", middle); + // Act var result = root.GetSelfAndDescendants(); - Assert.Equal([root, middle, leaf], result); + // Assert + result.Should().Equal(root, middle, leaf); } [Fact] - public void GetSelfAndDescendants_WithAggregateException_FlattensInnerExceptions() + public void GetSelfAndDescendants_Aggregate_Test() { + // Arrange var inner1 = new Exception("inner1"); var inner2 = new Exception("inner2"); var aggregate = new AggregateException("aggregate", inner1, inner2); + // Act var result = aggregate.GetSelfAndDescendants(); - Assert.Equal([aggregate, inner1, inner2], result); + // Assert + result.Should().Equal(aggregate, inner1, inner2); } [Fact] - public void GetSelfAndDescendants_NestedAggregateException_ReturnsAllDescendants() + public void GetSelfAndDescendants_NestedAggregate_Test() { + // Arrange var leaf = new Exception("leaf"); var inner = new AggregateException("inner", leaf); var root = new AggregateException("root", inner); + // Act var result = root.GetSelfAndDescendants(); - Assert.Equal([root, inner, leaf], result); + // Assert + result.Should().Equal(root, inner, leaf); } } diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs index 527c995..878d1d7 100644 --- a/PowerKit.Tests/FunctionalExtensionsTests.cs +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,57 +6,79 @@ namespace PowerKit.Tests; public class FunctionalExtensionsTests { [Fact] - public void Pipe_TransformsValue() + public void Pipe_Test() { + // Act var result = 5.Pipe(x => x * 2); - Assert.Equal(10, result); + + // Assert + result.Should().Be(10); } [Fact] - public void Pipe_ChainedCalls_AppliesInOrder() + public void Pipe_Chained_Test() { + // Act var result = "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!"); - Assert.Equal("HELLO!", result); + + // Assert + result.Should().Be("HELLO!"); } [Fact] - public void NullIf_PredicateMatches_ReturnsNull() + public void NullIf_PredicateMatches_Test() { - int value = 0; - Assert.Null(value.NullIf(v => v == 0)); + // Act + var result = 0.NullIf(v => v == 0); + + // Assert + result.Should().BeNull(); } [Fact] - public void NullIf_PredicateDoesNotMatch_ReturnsValue() + public void NullIf_PredicateDoesNotMatch_Test() { - int value = 5; - Assert.Equal(5, value.NullIf(v => v == 0)); + // Act + var result = 5.NullIf(v => v == 0); + + // Assert + result.Should().Be(5); } [Fact] - public void NullIfDefault_DefaultValue_ReturnsNull() + public void NullIfDefault_Default_Test() { - int value = 0; - Assert.Null(value.NullIfDefault()); + // Act + var result = 0.NullIfDefault(); + + // Assert + result.Should().BeNull(); } [Fact] - public void NullIfDefault_NonDefaultValue_ReturnsValue() + public void NullIfDefault_NonDefault_Test() { - int value = 42; - Assert.Equal(42, value.NullIfDefault()); + // Act + var result = 42.NullIfDefault(); + + // Assert + result.Should().Be(42); } [Fact] - public void NullIfDefault_DefaultGuid_ReturnsNull() + public void NullIfDefault_DefaultGuid_Test() { - Assert.Null(Guid.Empty.NullIfDefault()); + // Act & assert + Guid.Empty.NullIfDefault().Should().BeNull(); } [Fact] - public void NullIfDefault_NonDefaultGuid_ReturnsValue() + public void NullIfDefault_NonDefaultGuid_Test() { + // Arrange var id = Guid.NewGuid(); - Assert.Equal(id, id.NullIfDefault()); + + // Act & assert + id.NullIfDefault().Should().Be(id); } } diff --git a/PowerKit.Tests/ObjectExtensionsTests.cs b/PowerKit.Tests/ObjectExtensionsTests.cs index 7ac4128..c1958b7 100644 --- a/PowerKit.Tests/ObjectExtensionsTests.cs +++ b/PowerKit.Tests/ObjectExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,25 +6,35 @@ namespace PowerKit.Tests; public class ObjectExtensionsTests { [Fact] - public void ToSingletonEnumerable_ReturnsEnumerableWithSingleElement() + public void ToSingletonEnumerable_Test() { + // Act var result = 42.ToSingletonEnumerable().ToList(); - Assert.Single(result, 42); + + // Assert + result.Should().Equal(42); } [Fact] - public void ToSingletonEnumerable_WorksWithReferenceType() + public void ToSingletonEnumerable_ReferenceType_Test() { - var obj = "hello"; - var result = obj.ToSingletonEnumerable().ToList(); - Assert.Single(result, "hello"); + // Act + var result = "hello".ToSingletonEnumerable().ToList(); + + // Assert + result.Should().Equal("hello"); } [Fact] - public void ToSingletonEnumerable_WorksWithNull() + public void ToSingletonEnumerable_Null_Test() { + // Arrange string? obj = null; + + // Act var result = obj.ToSingletonEnumerable().ToList(); - Assert.Single(result, (string?)null); + + // Assert + result.Should().Equal((string?)null); } } diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index 7295c54..bcb307e 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -6,56 +7,65 @@ namespace PowerKit.Tests; public class PathExtensionsTests { [Fact] - public void EscapeFileName_ValidName_ReturnsUnchanged() + public void EscapeFileName_Test() { - Assert.Equal("hello world.txt", Path.EscapeFileName("hello world.txt")); + // Act & assert + Path.EscapeFileName("hello world.txt").Should().Be("hello world.txt"); } [Fact] - public void EscapeFileName_ReplacesForwardSlash() + public void EscapeFileName_ForwardSlash_Test() { - Assert.Equal("a_b", Path.EscapeFileName("a/b")); + // Act & assert + Path.EscapeFileName("a/b").Should().Be("a_b"); } [Fact] - public void EscapeFileName_ReplacesBackslash() + public void EscapeFileName_Backslash_Test() { - Assert.Equal("a_b", Path.EscapeFileName("a\\b")); + // Act & assert + Path.EscapeFileName("a\\b").Should().Be("a_b"); } [Fact] - public void EscapeFileName_ReplacesColon() + public void EscapeFileName_Colon_Test() { - Assert.Equal("C_drive", Path.EscapeFileName("C:drive")); + // Act & assert + Path.EscapeFileName("C:drive").Should().Be("C_drive"); } [Fact] - public void EscapeFileName_ReplacesAllInvalidChars() + public void EscapeFileName_AllInvalidChars_Test() { - Assert.Equal("a_b_c_d_e_f_g_h_i", Path.EscapeFileName("a\0b/c\\d:e*f?g\"h + diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index d93a95f..aa938f4 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -6,56 +7,68 @@ namespace PowerKit.Tests; public class StreamExtensionsTests { [Fact] - public async Task CopyToAsync_WithAutoFlush_CopiesAllBytes() + public async Task CopyToAsync_AutoFlush_Test() { + // Arrange var data = new byte[] { 1, 2, 3, 4, 5 }; using var source = new MemoryStream(data); using var destination = new MemoryStream(); + // Act await source.CopyToAsync(destination, autoFlush: true); - Assert.Equal(data, destination.ToArray()); + // Assert + destination.ToArray().Should().Equal(data); } [Fact] - public async Task CopyToAsync_WithoutAutoFlush_CopiesAllBytes() + public async Task CopyToAsync_NoAutoFlush_Test() { + // Arrange var data = new byte[] { 10, 20, 30 }; using var source = new MemoryStream(data); using var destination = new MemoryStream(); + // Act await source.CopyToAsync(destination, autoFlush: false); - Assert.Equal(data, destination.ToArray()); + // Assert + destination.ToArray().Should().Equal(data); } [Fact] - public async Task CopyToAsync_EmptySource_ProducesEmptyDestination() + public async Task CopyToAsync_Empty_Test() { + // Arrange using var source = new MemoryStream(); using var destination = new MemoryStream(); + // Act await source.CopyToAsync(destination, autoFlush: false); - Assert.Empty(destination.ToArray()); + // Assert + destination.ToArray().Should().BeEmpty(); } [Fact] - public async Task CopyToAsync_WithProgress_CopiesAllBytes() + public async Task CopyToAsync_Progress_Test() { + // Arrange var data = new byte[1024]; - new Random(0).NextBytes(data); using var source = new MemoryStream(data); using var destination = new MemoryStream(); + // Act await source.CopyToAsync(destination, progress: null); - Assert.Equal(data, destination.ToArray()); + // Assert + destination.ToArray().Should().Equal(data); } [Fact] - public async Task CopyToAsync_WithProgress_ReportsProgress() + public async Task CopyToAsync_Progress_Reports_Test() { + // Arrange var data = new byte[1024]; using var source = new MemoryStream(data); using var destination = new MemoryStream(); @@ -63,13 +76,15 @@ public async Task CopyToAsync_WithProgress_ReportsProgress() var reports = new List(); var progress = new Progress(v => reports.Add(v)); + // Act await source.CopyToAsync(destination, progress: progress); // Allow Progress callbacks to fire on the thread pool await Task.Delay(50); - Assert.NotEmpty(reports); - Assert.All(reports, v => Assert.InRange(v, 0.0, 1.0)); - Assert.Equal(1.0, reports[^1], precision: 5); + // Assert + reports.Should().NotBeEmpty(); + reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); + reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } } diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs index abf3295..90a7b82 100644 --- a/PowerKit.Tests/StringBuilderExtensionsTests.cs +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -1,4 +1,5 @@ using System.Text; +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -6,26 +7,41 @@ namespace PowerKit.Tests; public class StringBuilderExtensionsTests { [Fact] - public void AppendIfNotEmpty_EmptyBuilder_DoesNotAppend() + public void AppendIfNotEmpty_Empty_Test() { + // Arrange var builder = new StringBuilder(); + + // Act builder.AppendIfNotEmpty(','); - Assert.Equal("", builder.ToString()); + + // Assert + builder.ToString().Should().Be(""); } [Fact] - public void AppendIfNotEmpty_NonEmptyBuilder_Appends() + public void AppendIfNotEmpty_Test() { + // Arrange var builder = new StringBuilder("hello"); + + // Act builder.AppendIfNotEmpty(','); - Assert.Equal("hello,", builder.ToString()); + + // Assert + builder.ToString().Should().Be("hello,"); } [Fact] - public void AppendIfNotEmpty_ReturnsBuilder_AllowsChaining() + public void AppendIfNotEmpty_Chaining_Test() { + // Arrange var builder = new StringBuilder("a"); + + // Act builder.AppendIfNotEmpty(',').Append("b"); - Assert.Equal("a,b", builder.ToString()); + + // Assert + builder.ToString().Should().Be("a,b"); } } diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index d3d4080..1047c6d 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,104 +6,121 @@ namespace PowerKit.Tests; public class StringExtensionsTests { [Fact] - public void NullIfWhiteSpace_NonWhitespaceString_ReturnsSame() + public void NullIfWhiteSpace_Test() { - Assert.Equal("hello", "hello".NullIfWhiteSpace()); + // Act & assert + "hello".NullIfWhiteSpace().Should().Be("hello"); } [Fact] - public void NullIfWhiteSpace_WhitespaceOnly_ReturnsNull() + public void NullIfWhiteSpace_Whitespace_Test() { - Assert.Null(" ".NullIfWhiteSpace()); + // Act & assert + " ".NullIfWhiteSpace().Should().BeNull(); } [Fact] - public void NullIfWhiteSpace_EmptyString_ReturnsNull() + public void NullIfWhiteSpace_Empty_Test() { - Assert.Null("".NullIfWhiteSpace()); + // Act & assert + "".NullIfWhiteSpace().Should().BeNull(); } [Fact] - public void SubstringUntil_SubstringFound_ReturnsBeforeIt() + public void SubstringUntil_Test() { - Assert.Equal("hello", "hello world".SubstringUntil(" ")); + // Act & assert + "hello world".SubstringUntil(" ").Should().Be("hello"); } [Fact] - public void SubstringUntil_SubstringNotFound_ReturnsFullString() + public void SubstringUntil_NotFound_Test() { - Assert.Equal("hello", "hello".SubstringUntil("x")); + // Act & assert + "hello".SubstringUntil("x").Should().Be("hello"); } [Fact] - public void SubstringUntil_SubstringAtStart_ReturnsEmpty() + public void SubstringUntil_AtStart_Test() { - Assert.Equal("", "xhello".SubstringUntil("x")); + // Act & assert + "xhello".SubstringUntil("x").Should().Be(""); } [Fact] - public void SubstringAfter_SubstringFound_ReturnsAfterIt() + public void SubstringAfter_Test() { - Assert.Equal("world", "hello world".SubstringAfter(" ")); + // Act & assert + "hello world".SubstringAfter(" ").Should().Be("world"); } [Fact] - public void SubstringAfter_SubstringNotFound_ReturnsEmpty() + public void SubstringAfter_NotFound_Test() { - Assert.Equal("", "hello".SubstringAfter("x")); + // Act & assert + "hello".SubstringAfter("x").Should().Be(""); } [Fact] - public void SubstringAfter_SubstringAtEnd_ReturnsEmpty() + public void SubstringAfter_AtEnd_Test() { - Assert.Equal("", "hellox".SubstringAfter("x")); + // Act & assert + "hellox".SubstringAfter("x").Should().Be(""); } [Fact] - public void Truncate_StringShorterThanLimit_ReturnsFull() + public void Truncate_ShorterThanLimit_Test() { - Assert.Equal("hi", "hi".Truncate(10)); + // Act & assert + "hi".Truncate(10).Should().Be("hi"); } [Fact] - public void Truncate_StringExactlyAtLimit_ReturnsFull() + public void Truncate_ExactlyAtLimit_Test() { - Assert.Equal("hello", "hello".Truncate(5)); + // Act & assert + "hello".Truncate(5).Should().Be("hello"); } [Fact] - public void Truncate_StringLongerThanLimit_ReturnsTruncated() + public void Truncate_LongerThanLimit_Test() { - Assert.Equal("hel", "hello".Truncate(3)); + // Act & assert + "hello".Truncate(3).Should().Be("hel"); } [Fact] - public void ToSpaceSeparatedWords_PascalCase_InsertsSpacesBeforeUppercase() + public void ToSpaceSeparatedWords_Test() { - Assert.Equal("Hello World", "HelloWorld".ToSpaceSeparatedWords()); + // Act & assert + "HelloWorld".ToSpaceSeparatedWords().Should().Be("Hello World"); } [Fact] - public void ToSpaceSeparatedWords_SingleWord_ReturnsUnchanged() + public void ToSpaceSeparatedWords_SingleWord_Test() { - Assert.Equal("Hello", "Hello".ToSpaceSeparatedWords()); + // Act & assert + "Hello".ToSpaceSeparatedWords().Should().Be("Hello"); } [Fact] - public void ToSpaceSeparatedWords_AllLowercase_ReturnsUnchanged() + public void ToSpaceSeparatedWords_AllLowercase_Test() { - Assert.Equal("hello", "hello".ToSpaceSeparatedWords()); + // Act & assert + "hello".ToSpaceSeparatedWords().Should().Be("hello"); } [Fact] - public void ToSpaceSeparatedWords_EmptyString_ReturnsEmpty() + public void ToSpaceSeparatedWords_Empty_Test() { - Assert.Equal("", "".ToSpaceSeparatedWords()); + // Act & assert + "".ToSpaceSeparatedWords().Should().Be(""); } [Fact] - public void ToSpaceSeparatedWords_MultipleWords_SplitsCorrectly() + public void ToSpaceSeparatedWords_Multiple_Test() { - Assert.Equal("Foo Bar Baz", "FooBarBaz".ToSpaceSeparatedWords()); + // Act & assert + "FooBarBaz".ToSpaceSeparatedWords().Should().Be("Foo Bar Baz"); } } diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs index 61d7e6b..4798e4d 100644 --- a/PowerKit.Tests/TextReaderExtensionsTests.cs +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -6,26 +7,41 @@ namespace PowerKit.Tests; public class TextReaderExtensionsTests { [Fact] - public async Task ReadLinesAsync_ReadsAllLines() + public async Task ReadLinesAsync_Test() { + // Arrange using var reader = new StringReader("line1\nline2\nline3"); + + // Act var lines = await reader.ReadLinesAsync().ToListAsync(); - Assert.Equal(["line1", "line2", "line3"], lines); + + // Assert + lines.Should().Equal("line1", "line2", "line3"); } [Fact] - public async Task ReadLinesAsync_EmptyReader_ReturnsEmpty() + public async Task ReadLinesAsync_Empty_Test() { + // Arrange using var reader = new StringReader(""); + + // Act var lines = await reader.ReadLinesAsync().ToListAsync(); - Assert.Empty(lines); + + // Assert + lines.Should().BeEmpty(); } [Fact] - public async Task ReadLinesAsync_SingleLine_ReturnsSingleLine() + public async Task ReadLinesAsync_SingleLine_Test() { + // Arrange using var reader = new StringReader("hello"); + + // Act var lines = await reader.ReadLinesAsync().ToListAsync(); - Assert.Single(lines, "hello"); + + // Assert + lines.Should().Equal("hello"); } } diff --git a/PowerKit.Tests/TimeSpanExtensionsTests.cs b/PowerKit.Tests/TimeSpanExtensionsTests.cs index 5e282dd..2e2be49 100644 --- a/PowerKit.Tests/TimeSpanExtensionsTests.cs +++ b/PowerKit.Tests/TimeSpanExtensionsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using PowerKit.Extensions; namespace PowerKit.Tests; @@ -5,42 +6,61 @@ namespace PowerKit.Tests; public class TimeSpanExtensionsTests { [Fact] - public void Clamp_ValueWithinRange_ReturnsValue() + public void Clamp_Test() { + // Arrange var value = TimeSpan.FromSeconds(5); + + // Act var result = value.Clamp(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); - Assert.Equal(value, result); + + // Assert + result.Should().Be(value); } [Fact] - public void Clamp_ValueBelowMin_ReturnsMin() + public void Clamp_BelowMin_Test() { + // Arrange var min = TimeSpan.FromSeconds(3); + + // Act var result = TimeSpan.FromSeconds(1).Clamp(min, TimeSpan.FromSeconds(10)); - Assert.Equal(min, result); + + // Assert + result.Should().Be(min); } [Fact] - public void Clamp_ValueAboveMax_ReturnsMax() + public void Clamp_AboveMax_Test() { + // Arrange var max = TimeSpan.FromSeconds(7); + + // Act var result = TimeSpan.FromSeconds(20).Clamp(TimeSpan.FromSeconds(1), max); - Assert.Equal(max, result); + + // Assert + result.Should().Be(max); } [Fact] - public void Clamp_ValueEqualToMin_ReturnsMin() + public void Clamp_EqualToMin_Test() { + // Arrange var min = TimeSpan.FromSeconds(3); - var result = min.Clamp(min, TimeSpan.FromSeconds(10)); - Assert.Equal(min, result); + + // Act & assert + min.Clamp(min, TimeSpan.FromSeconds(10)).Should().Be(min); } [Fact] - public void Clamp_ValueEqualToMax_ReturnsMax() + public void Clamp_EqualToMax_Test() { + // Arrange var max = TimeSpan.FromSeconds(10); - var result = max.Clamp(TimeSpan.FromSeconds(1), max); - Assert.Equal(max, result); + + // Act & assert + max.Clamp(TimeSpan.FromSeconds(1), max).Should().Be(max); } } From ddd9770f395e23ca6e0b043a63f376b74dd6e36c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:55:29 +0000 Subject: [PATCH 09/43] Replace TimeSpanExtensions.Clamp with generic ComparableExtensions.Clamp> Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/0fc311fa-8cb8-41a1-8ec3-9017eb211263 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/ComparableExtensionsTests.cs | 85 +++++++++++++++++++++ PowerKit.Tests/TimeSpanExtensionsTests.cs | 66 ---------------- PowerKit/Extensions/ComparableExtensions.cs | 20 +++++ PowerKit/Extensions/TimeSpanExtensions.cs | 20 ----- 4 files changed, 105 insertions(+), 86 deletions(-) create mode 100644 PowerKit.Tests/ComparableExtensionsTests.cs delete mode 100644 PowerKit.Tests/TimeSpanExtensionsTests.cs create mode 100644 PowerKit/Extensions/ComparableExtensions.cs delete mode 100644 PowerKit/Extensions/TimeSpanExtensions.cs diff --git a/PowerKit.Tests/ComparableExtensionsTests.cs b/PowerKit.Tests/ComparableExtensionsTests.cs new file mode 100644 index 0000000..9bf166c --- /dev/null +++ b/PowerKit.Tests/ComparableExtensionsTests.cs @@ -0,0 +1,85 @@ +using System; +using FluentAssertions; +using PowerKit.Extensions; + +namespace PowerKit.Tests; + +public class ComparableExtensionsTests +{ + [Fact] + public void Clamp_Test() + { + // Arrange & act + var result = 5.Clamp(1, 10); + + // Assert + result.Should().Be(5); + } + + [Fact] + public void Clamp_BelowMin_Test() + { + // Act + var result = 1.Clamp(3, 10); + + // Assert + result.Should().Be(3); + } + + [Fact] + public void Clamp_AboveMax_Test() + { + // Act + var result = 20.Clamp(1, 7); + + // Assert + result.Should().Be(7); + } + + [Fact] + public void Clamp_EqualToMin_Test() + { + // Act & assert + 3.Clamp(3, 10).Should().Be(3); + } + + [Fact] + public void Clamp_EqualToMax_Test() + { + // Act & assert + 10.Clamp(1, 10).Should().Be(10); + } + + [Fact] + public void Clamp_WithDouble_Test() + { + // Act + var result = 3.14.Clamp(0.0, 3.0); + + // Assert + result.Should().Be(3.0); + } + + [Fact] + public void Clamp_WithString_Test() + { + // Act + var result = "banana".Clamp("apple", "cherry"); + + // Assert + result.Should().Be("banana"); + } + + [Fact] + public void Clamp_WithTimeSpan_Test() + { + // Arrange + var value = TimeSpan.FromSeconds(5); + + // Act + var result = value.Clamp(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); + + // Assert + result.Should().Be(value); + } +} diff --git a/PowerKit.Tests/TimeSpanExtensionsTests.cs b/PowerKit.Tests/TimeSpanExtensionsTests.cs deleted file mode 100644 index 2e2be49..0000000 --- a/PowerKit.Tests/TimeSpanExtensionsTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using FluentAssertions; -using PowerKit.Extensions; - -namespace PowerKit.Tests; - -public class TimeSpanExtensionsTests -{ - [Fact] - public void Clamp_Test() - { - // Arrange - var value = TimeSpan.FromSeconds(5); - - // Act - var result = value.Clamp(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); - - // Assert - result.Should().Be(value); - } - - [Fact] - public void Clamp_BelowMin_Test() - { - // Arrange - var min = TimeSpan.FromSeconds(3); - - // Act - var result = TimeSpan.FromSeconds(1).Clamp(min, TimeSpan.FromSeconds(10)); - - // Assert - result.Should().Be(min); - } - - [Fact] - public void Clamp_AboveMax_Test() - { - // Arrange - var max = TimeSpan.FromSeconds(7); - - // Act - var result = TimeSpan.FromSeconds(20).Clamp(TimeSpan.FromSeconds(1), max); - - // Assert - result.Should().Be(max); - } - - [Fact] - public void Clamp_EqualToMin_Test() - { - // Arrange - var min = TimeSpan.FromSeconds(3); - - // Act & assert - min.Clamp(min, TimeSpan.FromSeconds(10)).Should().Be(min); - } - - [Fact] - public void Clamp_EqualToMax_Test() - { - // Arrange - var max = TimeSpan.FromSeconds(10); - - // Act & assert - max.Clamp(TimeSpan.FromSeconds(1), max).Should().Be(max); - } -} diff --git a/PowerKit/Extensions/ComparableExtensions.cs b/PowerKit/Extensions/ComparableExtensions.cs new file mode 100644 index 0000000..15bca7d --- /dev/null +++ b/PowerKit/Extensions/ComparableExtensions.cs @@ -0,0 +1,20 @@ +using System; + +namespace PowerKit.Extensions; + +internal static class ComparableExtensions +{ + extension(T value) where T : IComparable + { + public T Clamp(T min, T max) + { + if (value.CompareTo(min) < 0) + return min; + + if (value.CompareTo(max) > 0) + return max; + + return value; + } + } +} diff --git a/PowerKit/Extensions/TimeSpanExtensions.cs b/PowerKit/Extensions/TimeSpanExtensions.cs deleted file mode 100644 index 08e5a9d..0000000 --- a/PowerKit/Extensions/TimeSpanExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace PowerKit.Extensions; - -internal static class TimeSpanExtensions -{ - extension(TimeSpan value) - { - public TimeSpan Clamp(TimeSpan min, TimeSpan max) - { - if (value < min) - return min; - - if (value > max) - return max; - - return value; - } - } -} From edfb1bdd067a132102f489cd9f674c7e5a3d0cd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:58:30 +0000 Subject: [PATCH 10/43] Add GitHub Actions workflow (mirrors CliWrap main.yml) Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/8a790f45-7bdb-49da-9a53-dd78d357ea9d Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .github/workflows/main.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ea94d51 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,33 @@ +name: main + +on: + workflow_dispatch: + inputs: + package-version: + type: string + description: Package version + required: false + deploy: + type: boolean + description: Deploy package + required: false + default: false + push: + branches: + - prime + tags: + - "*" + pull_request: + branches: + - prime + +jobs: + main: + uses: Tyrrrz/.github/.github/workflows/nuget.yml@prime + with: + deploy: ${{ inputs.deploy || github.ref_type == 'tag' }} + package-version: ${{ inputs.package-version || (github.ref_type == 'tag' && github.ref_name) || format('0.0.0-ci-{0}', github.sha) }} + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} From 6f753480f0abeb88eb3fc1a13a7eaff0ea2944fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:00:55 +0000 Subject: [PATCH 11/43] Update PolyShim to 2.8.0; mark Microsoft.Bcl.AsyncInterfaces as PrivateAssets Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/a2f2d9ad-1a27-451f-b462-4d97ed31bd1e Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/PowerKit.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj index 14d8aaf..5cb4b38 100644 --- a/PowerKit/PowerKit.csproj +++ b/PowerKit/PowerKit.csproj @@ -11,8 +11,8 @@ - - + + From 4fc36168991dc96bac6780cac49c693c5d800d0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:54:55 +0000 Subject: [PATCH 12/43] Update PolyShim to 2.8.1, keep Microsoft.Bcl.AsyncInterfaces workaround Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/0ecc3861-8296-4ffe-a67c-c976391d2542 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/PowerKit.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj index 5cb4b38..29f3087 100644 --- a/PowerKit/PowerKit.csproj +++ b/PowerKit/PowerKit.csproj @@ -11,7 +11,7 @@ - + From 476b3ca17a2f227796956599cedf9daf78928e00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:56:22 +0000 Subject: [PATCH 13/43] Update PolyShim to 2.8.2, remove Microsoft.Bcl.AsyncInterfaces workaround Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/73f0b6ce-379f-44d9-b6ad-59549bf78ef3 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/PowerKit.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj index 29f3087..f51c117 100644 --- a/PowerKit/PowerKit.csproj +++ b/PowerKit/PowerKit.csproj @@ -11,8 +11,7 @@ - - + From efa44ba76c921f2eeea55706a3f09a6ae2eb654b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:31:43 +0000 Subject: [PATCH 14/43] Replace FQNs with using statements in AsyncEnumerableExtensions and StringExtensions Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/567df8bb-d8b0-4e0a-b838-db698a6e0427 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/Extensions/AsyncEnumerableExtensions.cs | 3 ++- PowerKit/Extensions/StringExtensions.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs index a6764bb..c4e78db 100644 --- a/PowerKit/Extensions/AsyncEnumerableExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; @@ -34,7 +35,7 @@ var item in source } public async IAsyncEnumerable SelectManyAsync( - System.Func> transform, + Func> transform, [EnumeratorCancellation] CancellationToken cancellationToken = default ) { diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index 57ef073..50f0828 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Text; namespace PowerKit.Extensions; @@ -10,7 +11,7 @@ internal static class StringExtensions public string SubstringUntil( string sub, - System.StringComparison comparison = System.StringComparison.Ordinal + StringComparison comparison = StringComparison.Ordinal ) => str.IndexOf(sub, comparison) switch { @@ -20,7 +21,7 @@ public string SubstringUntil( public string SubstringAfter( string sub, - System.StringComparison comparison = System.StringComparison.Ordinal + StringComparison comparison = StringComparison.Ordinal ) => str.IndexOf(sub, comparison) switch { From a18e3877dc1d135ba87fa72b4414e3f7c8ddb889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:34:22 +0000 Subject: [PATCH 15/43] Add braces in AsyncEnumerableExtensions; add Min/Max to ComparableExtensions with tests Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/970aa053-5479-4e79-a2e9-0a8055b320f6 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/ComparableExtensionsTests.cs | 42 +++++++++++++++++++ .../Extensions/AsyncEnumerableExtensions.cs | 4 ++ PowerKit/Extensions/ComparableExtensions.cs | 4 ++ 3 files changed, 50 insertions(+) diff --git a/PowerKit.Tests/ComparableExtensionsTests.cs b/PowerKit.Tests/ComparableExtensionsTests.cs index 9bf166c..9cfba61 100644 --- a/PowerKit.Tests/ComparableExtensionsTests.cs +++ b/PowerKit.Tests/ComparableExtensionsTests.cs @@ -82,4 +82,46 @@ public void Clamp_WithTimeSpan_Test() // Assert result.Should().Be(value); } + + [Fact] + public void Min_ReturnsSmaller_Test() + { + // Act & assert + 5.Min(3).Should().Be(3); + } + + [Fact] + public void Min_ReturnsValue_WhenSmaller_Test() + { + // Act & assert + 2.Min(7).Should().Be(2); + } + + [Fact] + public void Min_ReturnsEither_WhenEqual_Test() + { + // Act & assert + 4.Min(4).Should().Be(4); + } + + [Fact] + public void Max_ReturnsLarger_Test() + { + // Act & assert + 5.Max(3).Should().Be(5); + } + + [Fact] + public void Max_ReturnsOther_WhenLarger_Test() + { + // Act & assert + 2.Max(7).Should().Be(7); + } + + [Fact] + public void Max_ReturnsEither_WhenEqual_Test() + { + // Act & assert + 4.Max(4).Should().Be(4); + } } diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs index c4e78db..f91ed0e 100644 --- a/PowerKit/Extensions/AsyncEnumerableExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -44,8 +44,10 @@ var item in source .WithCancellation(cancellationToken) .ConfigureAwait(false) ) + { foreach (var result in transform(item)) yield return result; + } } public async ValueTask> ToListAsync( @@ -59,7 +61,9 @@ var item in source .WithCancellation(cancellationToken) .ConfigureAwait(false) ) + { list.Add(item); + } return list; } diff --git a/PowerKit/Extensions/ComparableExtensions.cs b/PowerKit/Extensions/ComparableExtensions.cs index 15bca7d..b8e0639 100644 --- a/PowerKit/Extensions/ComparableExtensions.cs +++ b/PowerKit/Extensions/ComparableExtensions.cs @@ -16,5 +16,9 @@ public T Clamp(T min, T max) return value; } + + public T Min(T other) => value.CompareTo(other) <= 0 ? value : other; + + public T Max(T other) => value.CompareTo(other) >= 0 ? value : other; } } From 5e4248922a93e611ff33a43e2e9e31277f05626c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:40:54 +0000 Subject: [PATCH 16/43] Add LastOrNull, NullIfEmpty/NullIfWhiteSpace for strings, Trim for StringBuilder, CopyToAsync with contentLength, strip trailing whitespace in EscapeFileName, move local fn to top Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/73499376-76d9-4185-8bf8-2691bd607f26 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/EnumerableExtensionsTests.cs | 27 ++++++ PowerKit.Tests/FunctionalExtensionsTests.cs | 42 +++++++++ PowerKit.Tests/PathExtensionsTests.cs | 14 +++ PowerKit.Tests/StreamExtensionsTests.cs | 38 ++++++++ .../StringBuilderExtensionsTests.cs | 91 +++++++++++++++++++ PowerKit.Tests/StringExtensionsTests.cs | 21 ----- PowerKit/Extensions/EnumerableExtensions.cs | 14 +++ PowerKit/Extensions/ExceptionExtensions.cs | 8 +- PowerKit/Extensions/FunctionalExtensions.cs | 7 ++ PowerKit/Extensions/PathExtensions.cs | 6 +- PowerKit/Extensions/StreamExtensions.cs | 39 ++++++++ .../Extensions/StringBuilderExtensions.cs | 15 +++ PowerKit/Extensions/StringExtensions.cs | 2 - 13 files changed, 295 insertions(+), 29 deletions(-) diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index 9427a5d..3952199 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -132,4 +132,31 @@ public void ElementAtOrNull_Empty_Test() // Act & assert Array.Empty().ElementAtOrNull(0).Should().BeNull(); } + + [Fact] + public void LastOrNull_Test() + { + // Arrange + int[] source = [5, 10, 15]; + + // Act + var result = source.LastOrNull(); + + // Assert + result.Should().Be(15); + } + + [Fact] + public void LastOrNull_Single_Test() + { + // Act & assert + new[] { 42 }.LastOrNull().Should().Be(42); + } + + [Fact] + public void LastOrNull_Empty_Test() + { + // Act & assert + Array.Empty().LastOrNull().Should().BeNull(); + } } diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs index 878d1d7..aed4c8d 100644 --- a/PowerKit.Tests/FunctionalExtensionsTests.cs +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -81,4 +81,46 @@ public void NullIfDefault_NonDefaultGuid_Test() // Act & assert id.NullIfDefault().Should().Be(id); } + + [Fact] + public void NullIfEmpty_Test() + { + // Act & assert + "hello".NullIfEmpty().Should().Be("hello"); + } + + [Fact] + public void NullIfEmpty_Empty_Test() + { + // Act & assert + "".NullIfEmpty().Should().BeNull(); + } + + [Fact] + public void NullIfEmpty_Whitespace_Test() + { + // Act & assert + " ".NullIfEmpty().Should().Be(" "); + } + + [Fact] + public void NullIfWhiteSpace_Test() + { + // Act & assert + "hello".NullIfWhiteSpace().Should().Be("hello"); + } + + [Fact] + public void NullIfWhiteSpace_Whitespace_Test() + { + // Act & assert + " ".NullIfWhiteSpace().Should().BeNull(); + } + + [Fact] + public void NullIfWhiteSpace_Empty_Test() + { + // Act & assert + "".NullIfWhiteSpace().Should().BeNull(); + } } diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index bcb307e..c73f125 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -48,6 +48,20 @@ public void EscapeFileName_TrailingDots_Test() Path.EscapeFileName("hello...").Should().Be("hello"); } + [Fact] + public void EscapeFileName_TrailingWhitespace_Test() + { + // Act & assert + Path.EscapeFileName("hello ").Should().Be("hello"); + } + + [Fact] + public void EscapeFileName_TrailingDotsAndWhitespace_Test() + { + // Act & assert + Path.EscapeFileName("hello. . ").Should().Be("hello"); + } + [Fact] public void EscapeFileName_DotsInMiddle_Test() { diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index aa938f4..203a4d4 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -87,4 +87,42 @@ public async Task CopyToAsync_Progress_Reports_Test() reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } + + [Fact] + public async Task CopyToAsync_ContentLength_Test() + { + // Arrange + var data = new byte[1024]; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + // Act + await source.CopyToAsync(destination, contentLength: 1024); + + // Assert + destination.ToArray().Should().Equal(data); + } + + [Fact] + public async Task CopyToAsync_ContentLength_Progress_Test() + { + // Arrange + var data = new byte[1024]; + using var source = new MemoryStream(data); + using var destination = new MemoryStream(); + + var reports = new List(); + var progress = new Progress(v => reports.Add(v)); + + // Act + await source.CopyToAsync(destination, contentLength: 1024, progress: progress); + + // Allow Progress callbacks to fire on the thread pool + await Task.Delay(50); + + // Assert + reports.Should().NotBeEmpty(); + reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); + reports[^1].Should().BeApproximately(1.0, precision: 1e-5); + } } diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs index 90a7b82..e013d78 100644 --- a/PowerKit.Tests/StringBuilderExtensionsTests.cs +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -44,4 +44,95 @@ public void AppendIfNotEmpty_Chaining_Test() // Assert builder.ToString().Should().Be("a,b"); } + + [Fact] + public void Trim_Test() + { + // Arrange + var builder = new StringBuilder(" hello "); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be("hello"); + } + + [Fact] + public void Trim_LeadingOnly_Test() + { + // Arrange + var builder = new StringBuilder(" hello"); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be("hello"); + } + + [Fact] + public void Trim_TrailingOnly_Test() + { + // Arrange + var builder = new StringBuilder("hello "); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be("hello"); + } + + [Fact] + public void Trim_NoWhitespace_Test() + { + // Arrange + var builder = new StringBuilder("hello"); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be("hello"); + } + + [Fact] + public void Trim_Empty_Test() + { + // Arrange + var builder = new StringBuilder(); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be(""); + } + + [Fact] + public void Trim_OnlyWhitespace_Test() + { + // Arrange + var builder = new StringBuilder(" "); + + // Act + builder.Trim(); + + // Assert + builder.ToString().Should().Be(""); + } + + [Fact] + public void Trim_Chaining_Test() + { + // Arrange + var builder = new StringBuilder(" hello "); + + // Act + var result = builder.Trim().Append("!"); + + // Assert + result.ToString().Should().Be("hello!"); + } } diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index 1047c6d..17b5382 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -5,27 +5,6 @@ namespace PowerKit.Tests; public class StringExtensionsTests { - [Fact] - public void NullIfWhiteSpace_Test() - { - // Act & assert - "hello".NullIfWhiteSpace().Should().Be("hello"); - } - - [Fact] - public void NullIfWhiteSpace_Whitespace_Test() - { - // Act & assert - " ".NullIfWhiteSpace().Should().BeNull(); - } - - [Fact] - public void NullIfWhiteSpace_Empty_Test() - { - // Act & assert - "".NullIfWhiteSpace().Should().BeNull(); - } - [Fact] public void SubstringUntil_Test() { diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs index fa147c2..50f4cf9 100644 --- a/PowerKit/Extensions/EnumerableExtensions.cs +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -49,11 +49,25 @@ public IEnumerable WhereNotNullOrWhiteSpace() public T? FirstOrNull() { foreach (var item in source) + { return item; + } return null; } + public T? LastOrNull() + { + var last = default(T?); + + foreach (var item in source) + { + last = item; + } + + return last; + } + public T? ElementAtOrNull(int index) { var list = source as IReadOnlyList ?? source.ToArray(); diff --git a/PowerKit/Extensions/ExceptionExtensions.cs b/PowerKit/Extensions/ExceptionExtensions.cs index 2c98a95..ab721f7 100644 --- a/PowerKit/Extensions/ExceptionExtensions.cs +++ b/PowerKit/Extensions/ExceptionExtensions.cs @@ -9,10 +9,6 @@ internal static class ExceptionExtensions { public IReadOnlyList GetSelfAndDescendants() { - var result = new List { exception }; - PopulateDescendants(exception, result); - return result; - static void PopulateDescendants(Exception ex, ICollection result) { if (ex is AggregateException aggregateException) @@ -29,6 +25,10 @@ static void PopulateDescendants(Exception ex, ICollection result) PopulateDescendants(ex.InnerException, result); } } + + var result = new List { exception }; + PopulateDescendants(exception, result); + return result; } } } diff --git a/PowerKit/Extensions/FunctionalExtensions.cs b/PowerKit/Extensions/FunctionalExtensions.cs index 8b8d49d..5e40566 100644 --- a/PowerKit/Extensions/FunctionalExtensions.cs +++ b/PowerKit/Extensions/FunctionalExtensions.cs @@ -18,4 +18,11 @@ internal static class FunctionalExtensions public T? NullIfDefault() => value.NullIf(v => EqualityComparer.Default.Equals(v, default)); } + + extension(string value) + { + public string? NullIfEmpty() => !string.IsNullOrEmpty(value) ? value : null; + + public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(value) ? value : null; + } } diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index c962be3..cb73306 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -33,9 +33,11 @@ public static string EscapeFileName(string fileName) foreach (var c in fileName) buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); - // File names cannot end with a dot (invalid on Windows, ambiguous on other filesystems) - while (buffer.Length > 0 && buffer[buffer.Length - 1] == '.') + // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems) + while (buffer.Length > 0 && (buffer[buffer.Length - 1] == '.' || char.IsWhiteSpace(buffer[buffer.Length - 1]))) + { buffer.Remove(buffer.Length - 1, 1); + } return buffer.ToString(); } diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index 6925eed..fb4c523 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -53,7 +53,9 @@ public async ValueTask CopyToAsync( .ConfigureAwait(false); if (bytesRead <= 0) + { break; + } await destination .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) @@ -62,7 +64,44 @@ await destination totalBytesRead += bytesRead; if (progress is not null && source.CanSeek && source.Length > 0) + { progress.Report(1.0 * totalBytesRead / source.Length); + } + } + } + + public async ValueTask CopyToAsync( + Stream destination, + long contentLength, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + using var buffer = MemoryPool.Shared.Rent(81920); + + var totalBytesRead = 0L; + + while (true) + { + var bytesRead = await source + .ReadAsync(buffer.Memory, cancellationToken) + .ConfigureAwait(false); + + if (bytesRead <= 0) + { + break; + } + + await destination + .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) + .ConfigureAwait(false); + + totalBytesRead += bytesRead; + + if (progress is not null && contentLength > 0) + { + progress.Report(1.0 * totalBytesRead / contentLength); + } } } } diff --git a/PowerKit/Extensions/StringBuilderExtensions.cs b/PowerKit/Extensions/StringBuilderExtensions.cs index 15ef10f..5abc708 100644 --- a/PowerKit/Extensions/StringBuilderExtensions.cs +++ b/PowerKit/Extensions/StringBuilderExtensions.cs @@ -8,5 +8,20 @@ internal static class StringBuilderExtensions { public StringBuilder AppendIfNotEmpty(char value) => builder.Length > 0 ? builder.Append(value) : builder; + + public StringBuilder Trim() + { + while (builder.Length > 0 && char.IsWhiteSpace(builder[builder.Length - 1])) + { + builder.Remove(builder.Length - 1, 1); + } + + while (builder.Length > 0 && char.IsWhiteSpace(builder[0])) + { + builder.Remove(0, 1); + } + + return builder; + } } } diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index 50f0828..cbee95e 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -7,8 +7,6 @@ internal static class StringExtensions { extension(string str) { - public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null; - public string SubstringUntil( string sub, StringComparison comparison = StringComparison.Ordinal From 754cc9e04c275d84062619d8e09f531c673f714e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:43:26 +0000 Subject: [PATCH 17/43] Optimize LastOrNull with IReadOnlyList fast path and StringBuilder.Trim with batch Remove Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/73499376-76d9-4185-8bf8-2691bd607f26 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/Extensions/EnumerableExtensions.cs | 5 +++++ .../Extensions/StringBuilderExtensions.cs | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs index 50f4cf9..8bdc534 100644 --- a/PowerKit/Extensions/EnumerableExtensions.cs +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -58,6 +58,11 @@ public IEnumerable WhereNotNullOrWhiteSpace() public T? LastOrNull() { + if (source is IReadOnlyList list) + { + return list.Count > 0 ? list[list.Count - 1] : null; + } + var last = default(T?); foreach (var item in source) diff --git a/PowerKit/Extensions/StringBuilderExtensions.cs b/PowerKit/Extensions/StringBuilderExtensions.cs index 5abc708..d8e73df 100644 --- a/PowerKit/Extensions/StringBuilderExtensions.cs +++ b/PowerKit/Extensions/StringBuilderExtensions.cs @@ -11,14 +11,26 @@ public StringBuilder AppendIfNotEmpty(char value) => public StringBuilder Trim() { - while (builder.Length > 0 && char.IsWhiteSpace(builder[builder.Length - 1])) + var start = 0; + while (start < builder.Length && char.IsWhiteSpace(builder[start])) { - builder.Remove(builder.Length - 1, 1); + start++; } - while (builder.Length > 0 && char.IsWhiteSpace(builder[0])) + var end = builder.Length - 1; + while (end >= start && char.IsWhiteSpace(builder[end])) { - builder.Remove(0, 1); + end--; + } + + if (end < builder.Length - 1) + { + builder.Remove(end + 1, builder.Length - end - 1); + } + + if (start > 0) + { + builder.Remove(0, start); } return builder; From 92e15909eedb76149adc05f469215af6efcf2f31 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:46:24 +0300 Subject: [PATCH 18/43] Update Directory.Build.props --- Directory.Build.props | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index 85c9b94..0822769 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,6 +9,17 @@ false + + + annotations + + + + + false + false + + $(Company) Collection of utilities and extensions for rapid .NET development From 8569b057b0e2198ce14730c343c76eddf2c2ae22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:47:00 +0000 Subject: [PATCH 19/43] Rename ToSpaceSeparatedWords to SeparateWords with char separator parameter Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/5ea59e8c-39c4-4615-8297-1fef32a7b0da Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/StringExtensionsTests.cs | 27 ++++++++++++++++--------- PowerKit/Extensions/StringExtensions.cs | 6 ++++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index 17b5382..f09a439 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -69,37 +69,44 @@ public void Truncate_LongerThanLimit_Test() } [Fact] - public void ToSpaceSeparatedWords_Test() + public void SeparateWords_Test() { // Act & assert - "HelloWorld".ToSpaceSeparatedWords().Should().Be("Hello World"); + "HelloWorld".SeparateWords(' ').Should().Be("Hello World"); } [Fact] - public void ToSpaceSeparatedWords_SingleWord_Test() + public void SeparateWords_SingleWord_Test() { // Act & assert - "Hello".ToSpaceSeparatedWords().Should().Be("Hello"); + "Hello".SeparateWords(' ').Should().Be("Hello"); } [Fact] - public void ToSpaceSeparatedWords_AllLowercase_Test() + public void SeparateWords_AllLowercase_Test() { // Act & assert - "hello".ToSpaceSeparatedWords().Should().Be("hello"); + "hello".SeparateWords(' ').Should().Be("hello"); } [Fact] - public void ToSpaceSeparatedWords_Empty_Test() + public void SeparateWords_Empty_Test() { // Act & assert - "".ToSpaceSeparatedWords().Should().Be(""); + "".SeparateWords(' ').Should().Be(""); } [Fact] - public void ToSpaceSeparatedWords_Multiple_Test() + public void SeparateWords_Multiple_Test() { // Act & assert - "FooBarBaz".ToSpaceSeparatedWords().Should().Be("Foo Bar Baz"); + "FooBarBaz".SeparateWords(' ').Should().Be("Foo Bar Baz"); + } + + [Fact] + public void SeparateWords_CustomSeparator_Test() + { + // Act & assert + "FooBarBaz".SeparateWords('-').Should().Be("Foo-Bar-Baz"); } } diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index cbee95e..c002f63 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -29,14 +29,16 @@ public string SubstringAfter( public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str; - public string ToSpaceSeparatedWords() + public string SeparateWords(char separator) { var builder = new StringBuilder(str.Length * 2); foreach (var c in str) { if (char.IsUpper(c) && builder.Length > 0) - builder.Append(' '); + { + builder.Append(separator); + } builder.Append(c); } From b1fd164ce4bfb8484fd67680b7742c364a020f26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:52:09 +0000 Subject: [PATCH 20/43] Use central package management (Directory.Packages.props), move package ItemGroup to bottom Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/567bbb33-4906-4cee-8422-b0f5270b7025 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- Directory.Packages.props | 14 ++++++++++++++ PowerKit.Tests/PowerKit.Tests.csproj | 10 +++++----- PowerKit.slnx | 1 + PowerKit/PowerKit.csproj | 8 ++++---- 4 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..0afbb3b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + true + + + + + + + + + + + diff --git a/PowerKit.Tests/PowerKit.Tests.csproj b/PowerKit.Tests/PowerKit.Tests.csproj index 02a3b9e..ccce6ce 100644 --- a/PowerKit.Tests/PowerKit.Tests.csproj +++ b/PowerKit.Tests/PowerKit.Tests.csproj @@ -13,11 +13,11 @@ - - - - - + + + + + diff --git a/PowerKit.slnx b/PowerKit.slnx index e5c1412..eb54d4e 100644 --- a/PowerKit.slnx +++ b/PowerKit.slnx @@ -1,6 +1,7 @@ + diff --git a/PowerKit/PowerKit.csproj b/PowerKit/PowerKit.csproj index f51c117..eddfde8 100644 --- a/PowerKit/PowerKit.csproj +++ b/PowerKit/PowerKit.csproj @@ -10,10 +10,6 @@ true - - - - + + + + From 06b9baa4fc5f815badbe118dd47cc3cd57711f1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:46:55 +0000 Subject: [PATCH 21/43] Add await Task.Yield() to ToAsyncEnumerable test helper Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/5912ae80-5064-4eb6-b559-a0432c8b031e Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/AsyncEnumerableExtensionsTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs index bfe24ae..a2bd3ec 100644 --- a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -8,7 +8,10 @@ public class AsyncEnumerableExtensionsTests private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) { foreach (var item in source) + { + await Task.Yield(); yield return item; + } } [Fact] From 3e9a7a5e46590e1ed3cb97dd795adb2c7fd9919d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:53:23 +0000 Subject: [PATCH 22/43] Simplify tests: remove global usings, merge AsyncEnumerable/Comparable tests Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/6e5b93bc-cbb8-46fa-8e04-346fa5fa9d37 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../AggregateExceptionExtensionsTests.cs | 1 + .../AsyncEnumerableExtensionsTests.cs | 137 +++--------------- PowerKit.Tests/ComparableExtensionsTests.cs | 106 +------------- PowerKit.Tests/DisposableTests.cs | 1 + PowerKit.Tests/EnumerableExtensionsTests.cs | 1 + PowerKit.Tests/ExceptionExtensionsTests.cs | 1 + PowerKit.Tests/FunctionalExtensionsTests.cs | 1 + PowerKit.Tests/ObjectExtensionsTests.cs | 1 + PowerKit.Tests/PathExtensionsTests.cs | 1 + PowerKit.Tests/PowerKit.Tests.csproj | 4 - PowerKit.Tests/StreamExtensionsTests.cs | 1 + .../StringBuilderExtensionsTests.cs | 1 + PowerKit.Tests/StringExtensionsTests.cs | 1 + PowerKit.Tests/TextReaderExtensionsTests.cs | 1 + 14 files changed, 42 insertions(+), 216 deletions(-) diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs index dbad779..6aa5c3e 100644 --- a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs index a2bd3ec..d5e566e 100644 --- a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; @@ -14,139 +15,47 @@ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable sou } } - [Fact] - public async Task TakeAsync_Zero_Test() - { - // Arrange - var source = ToAsyncEnumerable([1, 2, 3]); - - // Act - var result = await source.TakeAsync(0).ToListAsync(); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public async Task TakeAsync_Negative_Test() - { - // Arrange - var source = ToAsyncEnumerable([1, 2, 3]); - - // Act - var result = await source.TakeAsync(-1).ToListAsync(); - - // Assert - result.Should().BeEmpty(); - } - [Fact] public async Task TakeAsync_Test() { - // Arrange - var source = ToAsyncEnumerable([1, 2, 3, 4, 5]); + // Act & assert + (await ToAsyncEnumerable([1, 2, 3, 4, 5]).TakeAsync(3).ToListAsync()) + .Should() + .Equal(1, 2, 3); - // Act - var result = await source.TakeAsync(3).ToListAsync(); + (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(0).ToListAsync()) + .Should() + .BeEmpty(); - // Assert - result.Should().Equal(1, 2, 3); - } - - [Fact] - public async Task TakeAsync_CountExceedsSource_Test() - { - // Arrange - var source = ToAsyncEnumerable([1, 2, 3]); - - // Act - var result = await source.TakeAsync(10).ToListAsync(); - - // Assert - result.Should().Equal(1, 2, 3); - } - - [Fact] - public async Task TakeAsync_Zero_DoesNotConsumeElements_Test() - { - // Arrange - var consumed = 0; - - async IAsyncEnumerable Tracked() - { - consumed++; - yield return 1; - } - - // Act - await Tracked().TakeAsync(0).ToListAsync(); - - // Assert - consumed.Should().Be(0); + (await ToAsyncEnumerable([1, 2, 3]).TakeAsync(10).ToListAsync()) + .Should() + .Equal(1, 2, 3); } [Fact] public async Task SelectManyAsync_Test() { - // Arrange - var source = ToAsyncEnumerable(["ab", "cd", "ef"]); - - // Act - var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); - - // Assert - result.Should().Equal('a', 'b', 'c', 'd', 'e', 'f'); - } - - [Fact] - public async Task SelectManyAsync_Empty_Test() - { - // Arrange - var source = ToAsyncEnumerable(Array.Empty()); - - // Act - var result = await source.SelectManyAsync(s => s.ToCharArray()).ToListAsync(); - - // Assert - result.Should().BeEmpty(); + // Act & assert + (await ToAsyncEnumerable(["ab", "cd"]).SelectManyAsync(s => s.ToCharArray()).ToListAsync()) + .Should() + .Equal('a', 'b', 'c', 'd'); } [Fact] public async Task ToListAsync_Test() { - // Arrange - var source = ToAsyncEnumerable([1, 2, 3]); - - // Act - var result = await source.ToListAsync(); - - // Assert - result.Should().Equal(1, 2, 3); - } - - [Fact] - public async Task ToListAsync_Empty_Test() - { - // Arrange - var source = ToAsyncEnumerable(Array.Empty()); - - // Act - var result = await source.ToListAsync(); - - // Assert - result.Should().BeEmpty(); + // Act & assert + (await ToAsyncEnumerable([1, 2, 3]).ToListAsync()) + .Should() + .Equal(1, 2, 3); } [Fact] public async Task GetAwaiter_Test() { - // Arrange - var source = ToAsyncEnumerable([10, 20, 30]); - - // Act - var result = await source; - - // Assert - result.Should().Equal(10, 20, 30); + // Act & assert + (await ToAsyncEnumerable([10, 20, 30])) + .Should() + .Equal(10, 20, 30); } } diff --git a/PowerKit.Tests/ComparableExtensionsTests.cs b/PowerKit.Tests/ComparableExtensionsTests.cs index 9cfba61..c66747c 100644 --- a/PowerKit.Tests/ComparableExtensionsTests.cs +++ b/PowerKit.Tests/ComparableExtensionsTests.cs @@ -1,6 +1,6 @@ -using System; using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; @@ -8,120 +8,30 @@ public class ComparableExtensionsTests { [Fact] public void Clamp_Test() - { - // Arrange & act - var result = 5.Clamp(1, 10); - - // Assert - result.Should().Be(5); - } - - [Fact] - public void Clamp_BelowMin_Test() - { - // Act - var result = 1.Clamp(3, 10); - - // Assert - result.Should().Be(3); - } - - [Fact] - public void Clamp_AboveMax_Test() - { - // Act - var result = 20.Clamp(1, 7); - - // Assert - result.Should().Be(7); - } - - [Fact] - public void Clamp_EqualToMin_Test() { // Act & assert - 3.Clamp(3, 10).Should().Be(3); - } - - [Fact] - public void Clamp_EqualToMax_Test() - { - // Act & assert - 10.Clamp(1, 10).Should().Be(10); - } - - [Fact] - public void Clamp_WithDouble_Test() - { - // Act - var result = 3.14.Clamp(0.0, 3.0); - - // Assert - result.Should().Be(3.0); - } - - [Fact] - public void Clamp_WithString_Test() - { - // Act - var result = "banana".Clamp("apple", "cherry"); - - // Assert - result.Should().Be("banana"); - } - - [Fact] - public void Clamp_WithTimeSpan_Test() - { - // Arrange - var value = TimeSpan.FromSeconds(5); - - // Act - var result = value.Clamp(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); - - // Assert - result.Should().Be(value); + 5.Clamp(1, 10).Should().Be(5); + 1.Clamp(3, 10).Should().Be(3); + 20.Clamp(1, 7).Should().Be(7); + 3.14.Clamp(0.0, 3.0).Should().Be(3.0); + "banana".Clamp("apple", "cherry").Should().Be("banana"); } [Fact] - public void Min_ReturnsSmaller_Test() + public void Min_Test() { // Act & assert 5.Min(3).Should().Be(3); - } - - [Fact] - public void Min_ReturnsValue_WhenSmaller_Test() - { - // Act & assert 2.Min(7).Should().Be(2); - } - - [Fact] - public void Min_ReturnsEither_WhenEqual_Test() - { - // Act & assert 4.Min(4).Should().Be(4); } [Fact] - public void Max_ReturnsLarger_Test() + public void Max_Test() { // Act & assert 5.Max(3).Should().Be(5); - } - - [Fact] - public void Max_ReturnsOther_WhenLarger_Test() - { - // Act & assert 2.Max(7).Should().Be(7); - } - - [Fact] - public void Max_ReturnsEither_WhenEqual_Test() - { - // Act & assert 4.Max(4).Should().Be(4); } } diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs index 9665e8a..096fb8d 100644 --- a/PowerKit.Tests/DisposableTests.cs +++ b/PowerKit.Tests/DisposableTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index 3952199..22c5454 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs index 46888fe..e3bf08e 100644 --- a/PowerKit.Tests/ExceptionExtensionsTests.cs +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs index aed4c8d..76f8e34 100644 --- a/PowerKit.Tests/FunctionalExtensionsTests.cs +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/ObjectExtensionsTests.cs b/PowerKit.Tests/ObjectExtensionsTests.cs index c1958b7..68af34d 100644 --- a/PowerKit.Tests/ObjectExtensionsTests.cs +++ b/PowerKit.Tests/ObjectExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index c73f125..b34a8ef 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -1,6 +1,7 @@ using System.IO; using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/PowerKit.Tests.csproj b/PowerKit.Tests/PowerKit.Tests.csproj index ccce6ce..98ac4b7 100644 --- a/PowerKit.Tests/PowerKit.Tests.csproj +++ b/PowerKit.Tests/PowerKit.Tests.csproj @@ -20,9 +20,5 @@ - - - - diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index 203a4d4..a4966cf 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -1,6 +1,7 @@ using System.IO; using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs index e013d78..adc81d8 100644 --- a/PowerKit.Tests/StringBuilderExtensionsTests.cs +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -1,6 +1,7 @@ using System.Text; using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index f09a439..eed8fca 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs index 4798e4d..2a6e65d 100644 --- a/PowerKit.Tests/TextReaderExtensionsTests.cs +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -1,6 +1,7 @@ using System.IO; using FluentAssertions; using PowerKit.Extensions; +using Xunit; namespace PowerKit.Tests; From bd9c9b964aa81ec5eef810ffaa8c3305469e1023 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:04:48 +0000 Subject: [PATCH 23/43] Use .NET 10, remove ImplicitUsings, merge tests (1 per implementation method) Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/9d5de743-75ab-4f65-8a3e-3bbf5b6e0a4a Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../AggregateExceptionExtensionsTests.cs | 77 ++++------ .../AsyncEnumerableExtensionsTests.cs | 2 + PowerKit.Tests/DisposableTests.cs | 39 +---- PowerKit.Tests/EnumerableExtensionsTests.cs | 141 +++--------------- PowerKit.Tests/ExceptionExtensionsTests.cs | 102 +++++-------- PowerKit.Tests/FunctionalExtensionsTests.cs | 97 ++---------- PowerKit.Tests/ObjectExtensionsTests.cs | 33 +--- PowerKit.Tests/PathExtensionsTests.cs | 60 -------- PowerKit.Tests/PowerKit.Tests.csproj | 3 +- PowerKit.Tests/StreamExtensionsTests.cs | 3 + .../StringBuilderExtensionsTests.cs | 132 ++-------------- PowerKit.Tests/StringExtensionsTests.cs | 68 +-------- PowerKit.Tests/TextReaderExtensionsTests.cs | 47 ++---- 13 files changed, 130 insertions(+), 674 deletions(-) diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs index 6aa5c3e..9ad8752 100644 --- a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -9,56 +10,30 @@ public class AggregateExceptionExtensionsTests [Fact] public void TryGetSingle_Test() { - // Arrange - var inner = new Exception("only"); - var aggregate = new AggregateException(inner); - - // Act - var result = aggregate.TryGetSingle(); - - // Assert - result.Should().BeSameAs(inner); - } - - [Fact] - public void TryGetSingle_Multiple_Test() - { - // Arrange - var aggregate = new AggregateException(new Exception("a"), new Exception("b")); - - // Act - var result = aggregate.TryGetSingle(); - - // Assert - result.Should().BeNull(); - } - - [Fact] - public void TryGetSingle_NestedSingleLeaf_Test() - { - // Arrange - var leaf = new Exception("leaf"); - var nested = new AggregateException(leaf); - var outer = new AggregateException(nested); - - // Act - var result = outer.TryGetSingle(); - - // Assert - result.Should().BeSameAs(leaf); - } - - [Fact] - public void TryGetSingle_NestedMultipleLeaves_Test() - { - // Arrange - var nested = new AggregateException(new Exception("a"), new Exception("b")); - var outer = new AggregateException(nested); - - // Act - var result = outer.TryGetSingle(); - - // Assert - result.Should().BeNull(); + { + // Act & assert + var inner = new Exception("only"); + new AggregateException(inner).TryGetSingle().Should().BeSameAs(inner); + } + + new AggregateException(new Exception("a"), new Exception("b")) + .TryGetSingle() + .Should() + .BeNull(); + + { + var leaf = new Exception("leaf"); + new AggregateException(new AggregateException(leaf)) + .TryGetSingle() + .Should() + .BeSameAs(leaf); + } + + new AggregateException( + new AggregateException(new Exception("a"), new Exception("b")) + ) + .TryGetSingle() + .Should() + .BeNull(); } } diff --git a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs index d5e566e..fdba27c 100644 --- a/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs +++ b/PowerKit.Tests/AsyncEnumerableExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using FluentAssertions; using PowerKit.Extensions; using Xunit; diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs index 096fb8d..d76edcd 100644 --- a/PowerKit.Tests/DisposableTests.cs +++ b/PowerKit.Tests/DisposableTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using FluentAssertions; using PowerKit; using Xunit; @@ -18,47 +20,16 @@ public void Create_Test() { // Arrange var invoked = false; - - // Act var disposable = Disposable.Create(() => invoked = true); - disposable.Dispose(); - - // Assert - invoked.Should().BeTrue(); - } - - [Fact] - public void Create_NotDisposed_Test() - { - // Arrange - var invoked = false; - // Act - Disposable.Create(() => invoked = true); - - // Assert + // Act & assert invoked.Should().BeFalse(); + disposable.Dispose(); + invoked.Should().BeTrue(); } [Fact] public void Merge_Test() - { - // Arrange - var count = 0; - var disposables = Enumerable - .Range(0, 3) - .Select(_ => Disposable.Create(() => count++)) - .ToArray(); - - // Act - Disposable.Merge(disposables).Dispose(); - - // Assert - count.Should().Be(3); - } - - [Fact] - public void Merge_Order_Test() { // Arrange var order = new List(); diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index 22c5454..9330576 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -7,157 +8,49 @@ namespace PowerKit.Tests; public class EnumerableExtensionsTests { [Fact] - public void WhereNotNull_ReferenceType_Test() - { - // Arrange - string?[] source = ["a", null, "b", null, "c"]; - - // Act - var result = source.WhereNotNull(); - - // Assert - result.Should().Equal("a", "b", "c"); - } - - [Fact] - public void WhereNotNull_ReferenceType_Empty_Test() + public void WhereNotNull_Test() { // Act & assert + new string?[] { "a", null, "b", null, "c" }.WhereNotNull().Should().Equal("a", "b", "c"); + new int?[] { 1, null, 2, null, 3 }.WhereNotNull().Should().Equal(1, 2, 3); Array.Empty().WhereNotNull().Should().BeEmpty(); } - [Fact] - public void WhereNotNull_NullableStruct_Test() - { - // Arrange - int?[] source = [1, null, 2, null, 3]; - - // Act - var result = source.WhereNotNull(); - - // Assert - result.Should().Equal(1, 2, 3); - } - - [Fact] - public void WhereNotNull_NullableStruct_Empty_Test() - { - // Act & assert - Array.Empty().WhereNotNull().Should().BeEmpty(); - } - [Fact] public void WhereNotNullOrWhiteSpace_Test() - { - // Arrange - string?[] source = ["hello", null, " ", "", "world"]; - - // Act - var result = source.WhereNotNullOrWhiteSpace(); - - // Assert - result.Should().Equal("hello", "world"); - } - - [Fact] - public void WhereNotNullOrWhiteSpace_Empty_Test() { // Act & assert - Array.Empty().WhereNotNullOrWhiteSpace().Should().BeEmpty(); + new string?[] { "hello", null, " ", "", "world" } + .WhereNotNullOrWhiteSpace() + .Should() + .Equal("hello", "world"); } [Fact] public void FirstOrNull_Test() - { - // Arrange - int[] source = [5, 10, 15]; - - // Act - var result = source.FirstOrNull(); - - // Assert - result.Should().Be(5); - } - - [Fact] - public void FirstOrNull_Empty_Test() { // Act & assert + new[] { 5, 10, 15 }.FirstOrNull().Should().Be(5); Array.Empty().FirstOrNull().Should().BeNull(); } - [Fact] - public void ElementAtOrNull_Test() - { - // Arrange - int[] source = [10, 20, 30]; - - // Act - var result = source.ElementAtOrNull(1); - - // Assert - result.Should().Be(20); - } - - [Fact] - public void ElementAtOrNull_First_Test() - { - // Act & assert - new[] { 42, 99 }.ElementAtOrNull(0).Should().Be(42); - } - - [Fact] - public void ElementAtOrNull_Last_Test() - { - // Act & assert - new[] { 1, 2, 3 }.ElementAtOrNull(2).Should().Be(3); - } - - [Fact] - public void ElementAtOrNull_OutOfRange_Test() - { - // Act & assert - new[] { 1, 2, 3 }.ElementAtOrNull(10).Should().BeNull(); - } - - [Fact] - public void ElementAtOrNull_NegativeIndex_Test() - { - // Act & assert - new[] { 1, 2, 3 }.ElementAtOrNull(-1).Should().BeNull(); - } - - [Fact] - public void ElementAtOrNull_Empty_Test() - { - // Act & assert - Array.Empty().ElementAtOrNull(0).Should().BeNull(); - } - [Fact] public void LastOrNull_Test() - { - // Arrange - int[] source = [5, 10, 15]; - - // Act - var result = source.LastOrNull(); - - // Assert - result.Should().Be(15); - } - - [Fact] - public void LastOrNull_Single_Test() { // Act & assert + new[] { 5, 10, 15 }.LastOrNull().Should().Be(15); new[] { 42 }.LastOrNull().Should().Be(42); + Array.Empty().LastOrNull().Should().BeNull(); } [Fact] - public void LastOrNull_Empty_Test() + public void ElementAtOrNull_Test() { // Act & assert - Array.Empty().LastOrNull().Should().BeNull(); + new[] { 10, 20, 30 }.ElementAtOrNull(1).Should().Be(20); + new[] { 10, 20, 30 }.ElementAtOrNull(0).Should().Be(10); + new[] { 10, 20, 30 }.ElementAtOrNull(10).Should().BeNull(); + new[] { 10, 20, 30 }.ElementAtOrNull(-1).Should().BeNull(); + Array.Empty().ElementAtOrNull(0).Should().BeNull(); } } diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs index e3bf08e..01d70a0 100644 --- a/PowerKit.Tests/ExceptionExtensionsTests.cs +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -7,74 +8,39 @@ namespace PowerKit.Tests; public class ExceptionExtensionsTests { [Fact] - public void GetSelfAndDescendants_NoInner_Test() + public void GetSelfAndDescendants_Test() { - // Arrange - var ex = new Exception("root"); - - // Act - var result = ex.GetSelfAndDescendants(); - - // Assert - result.Should().Equal(ex); - } - - [Fact] - public void GetSelfAndDescendants_WithInner_Test() - { - // Arrange - var inner = new Exception("inner"); - var outer = new Exception("outer", inner); - - // Act - var result = outer.GetSelfAndDescendants(); - - // Assert - result.Should().Equal(outer, inner); - } - - [Fact] - public void GetSelfAndDescendants_Chained_Test() - { - // Arrange - var leaf = new Exception("leaf"); - var middle = new Exception("middle", leaf); - var root = new Exception("root", middle); - - // Act - var result = root.GetSelfAndDescendants(); - - // Assert - result.Should().Equal(root, middle, leaf); - } - - [Fact] - public void GetSelfAndDescendants_Aggregate_Test() - { - // Arrange - var inner1 = new Exception("inner1"); - var inner2 = new Exception("inner2"); - var aggregate = new AggregateException("aggregate", inner1, inner2); - - // Act - var result = aggregate.GetSelfAndDescendants(); - - // Assert - result.Should().Equal(aggregate, inner1, inner2); - } - - [Fact] - public void GetSelfAndDescendants_NestedAggregate_Test() - { - // Arrange - var leaf = new Exception("leaf"); - var inner = new AggregateException("inner", leaf); - var root = new AggregateException("root", inner); - - // Act - var result = root.GetSelfAndDescendants(); - - // Assert - result.Should().Equal(root, inner, leaf); + { + // Act & assert + var ex = new Exception("root"); + ex.GetSelfAndDescendants().Should().Equal(ex); + } + + { + var inner = new Exception("inner"); + var outer = new Exception("outer", inner); + outer.GetSelfAndDescendants().Should().Equal(outer, inner); + } + + { + var leaf = new Exception("leaf"); + var middle = new Exception("middle", leaf); + var root = new Exception("root", middle); + root.GetSelfAndDescendants().Should().Equal(root, middle, leaf); + } + + { + var inner1 = new Exception("inner1"); + var inner2 = new Exception("inner2"); + var aggregate = new AggregateException("aggregate", inner1, inner2); + aggregate.GetSelfAndDescendants().Should().Equal(aggregate, inner1, inner2); + } + + { + var leaf = new Exception("leaf"); + var inner = new AggregateException("inner", leaf); + var root = new AggregateException("root", inner); + root.GetSelfAndDescendants().Should().Equal(root, inner, leaf); + } } } diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs index 76f8e34..449b6b5 100644 --- a/PowerKit.Tests/FunctionalExtensionsTests.cs +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -9,78 +10,26 @@ public class FunctionalExtensionsTests [Fact] public void Pipe_Test() { - // Act - var result = 5.Pipe(x => x * 2); - - // Assert - result.Should().Be(10); - } - - [Fact] - public void Pipe_Chained_Test() - { - // Act - var result = "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!"); - - // Assert - result.Should().Be("HELLO!"); - } - - [Fact] - public void NullIf_PredicateMatches_Test() - { - // Act - var result = 0.NullIf(v => v == 0); - - // Assert - result.Should().BeNull(); - } - - [Fact] - public void NullIf_PredicateDoesNotMatch_Test() - { - // Act - var result = 5.NullIf(v => v == 0); - - // Assert - result.Should().Be(5); - } - - [Fact] - public void NullIfDefault_Default_Test() - { - // Act - var result = 0.NullIfDefault(); - - // Assert - result.Should().BeNull(); - } - - [Fact] - public void NullIfDefault_NonDefault_Test() - { - // Act - var result = 42.NullIfDefault(); - - // Assert - result.Should().Be(42); + // Act & assert + 5.Pipe(x => x * 2).Should().Be(10); + "hello".Pipe(s => s.ToUpper()).Pipe(s => s + "!").Should().Be("HELLO!"); } [Fact] - public void NullIfDefault_DefaultGuid_Test() + public void NullIf_Test() { // Act & assert - Guid.Empty.NullIfDefault().Should().BeNull(); + 0.NullIf(v => v == 0).Should().BeNull(); + 5.NullIf(v => v == 0).Should().Be(5); } [Fact] - public void NullIfDefault_NonDefaultGuid_Test() + public void NullIfDefault_Test() { - // Arrange - var id = Guid.NewGuid(); - // Act & assert - id.NullIfDefault().Should().Be(id); + 0.NullIfDefault().Should().BeNull(); + 42.NullIfDefault().Should().Be(42); + Guid.Empty.NullIfDefault().Should().BeNull(); } [Fact] @@ -88,19 +37,7 @@ public void NullIfEmpty_Test() { // Act & assert "hello".NullIfEmpty().Should().Be("hello"); - } - - [Fact] - public void NullIfEmpty_Empty_Test() - { - // Act & assert "".NullIfEmpty().Should().BeNull(); - } - - [Fact] - public void NullIfEmpty_Whitespace_Test() - { - // Act & assert " ".NullIfEmpty().Should().Be(" "); } @@ -109,19 +46,7 @@ public void NullIfWhiteSpace_Test() { // Act & assert "hello".NullIfWhiteSpace().Should().Be("hello"); - } - - [Fact] - public void NullIfWhiteSpace_Whitespace_Test() - { - // Act & assert " ".NullIfWhiteSpace().Should().BeNull(); - } - - [Fact] - public void NullIfWhiteSpace_Empty_Test() - { - // Act & assert "".NullIfWhiteSpace().Should().BeNull(); } } diff --git a/PowerKit.Tests/ObjectExtensionsTests.cs b/PowerKit.Tests/ObjectExtensionsTests.cs index 68af34d..f184a3d 100644 --- a/PowerKit.Tests/ObjectExtensionsTests.cs +++ b/PowerKit.Tests/ObjectExtensionsTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -9,33 +10,9 @@ public class ObjectExtensionsTests [Fact] public void ToSingletonEnumerable_Test() { - // Act - var result = 42.ToSingletonEnumerable().ToList(); - - // Assert - result.Should().Equal(42); - } - - [Fact] - public void ToSingletonEnumerable_ReferenceType_Test() - { - // Act - var result = "hello".ToSingletonEnumerable().ToList(); - - // Assert - result.Should().Equal("hello"); - } - - [Fact] - public void ToSingletonEnumerable_Null_Test() - { - // Arrange - string? obj = null; - - // Act - var result = obj.ToSingletonEnumerable().ToList(); - - // Assert - result.Should().Equal((string?)null); + // Act & assert + 42.ToSingletonEnumerable().ToList().Should().Equal(42); + "hello".ToSingletonEnumerable().ToList().Should().Equal("hello"); + ((string?)null).ToSingletonEnumerable().ToList().Should().Equal((string?)null); } } diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index b34a8ef..04fd106 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -12,75 +12,15 @@ public void EscapeFileName_Test() { // Act & assert Path.EscapeFileName("hello world.txt").Should().Be("hello world.txt"); - } - - [Fact] - public void EscapeFileName_ForwardSlash_Test() - { - // Act & assert Path.EscapeFileName("a/b").Should().Be("a_b"); - } - - [Fact] - public void EscapeFileName_Backslash_Test() - { - // Act & assert Path.EscapeFileName("a\\b").Should().Be("a_b"); - } - - [Fact] - public void EscapeFileName_Colon_Test() - { - // Act & assert Path.EscapeFileName("C:drive").Should().Be("C_drive"); - } - - [Fact] - public void EscapeFileName_AllInvalidChars_Test() - { - // Act & assert Path.EscapeFileName("a\0b/c\\d:e*f?g\"h - net9.0 - enable + net10.0 enable false diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index a4966cf..34f2cb3 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -1,4 +1,7 @@ +using System; +using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using FluentAssertions; using PowerKit.Extensions; using Xunit; diff --git a/PowerKit.Tests/StringBuilderExtensionsTests.cs b/PowerKit.Tests/StringBuilderExtensionsTests.cs index adc81d8..a75cf84 100644 --- a/PowerKit.Tests/StringBuilderExtensionsTests.cs +++ b/PowerKit.Tests/StringBuilderExtensionsTests.cs @@ -7,133 +7,25 @@ namespace PowerKit.Tests; public class StringBuilderExtensionsTests { - [Fact] - public void AppendIfNotEmpty_Empty_Test() - { - // Arrange - var builder = new StringBuilder(); - - // Act - builder.AppendIfNotEmpty(','); - - // Assert - builder.ToString().Should().Be(""); - } - [Fact] public void AppendIfNotEmpty_Test() { - // Arrange - var builder = new StringBuilder("hello"); - - // Act - builder.AppendIfNotEmpty(','); - - // Assert - builder.ToString().Should().Be("hello,"); - } - - [Fact] - public void AppendIfNotEmpty_Chaining_Test() - { - // Arrange - var builder = new StringBuilder("a"); - - // Act - builder.AppendIfNotEmpty(',').Append("b"); - - // Assert - builder.ToString().Should().Be("a,b"); + // Act & assert + new StringBuilder().AppendIfNotEmpty(',').ToString().Should().Be(""); + new StringBuilder("hello").AppendIfNotEmpty(',').ToString().Should().Be("hello,"); + new StringBuilder("a").AppendIfNotEmpty(',').Append("b").ToString().Should().Be("a,b"); } [Fact] public void Trim_Test() { - // Arrange - var builder = new StringBuilder(" hello "); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be("hello"); - } - - [Fact] - public void Trim_LeadingOnly_Test() - { - // Arrange - var builder = new StringBuilder(" hello"); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be("hello"); - } - - [Fact] - public void Trim_TrailingOnly_Test() - { - // Arrange - var builder = new StringBuilder("hello "); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be("hello"); - } - - [Fact] - public void Trim_NoWhitespace_Test() - { - // Arrange - var builder = new StringBuilder("hello"); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be("hello"); - } - - [Fact] - public void Trim_Empty_Test() - { - // Arrange - var builder = new StringBuilder(); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be(""); - } - - [Fact] - public void Trim_OnlyWhitespace_Test() - { - // Arrange - var builder = new StringBuilder(" "); - - // Act - builder.Trim(); - - // Assert - builder.ToString().Should().Be(""); - } - - [Fact] - public void Trim_Chaining_Test() - { - // Arrange - var builder = new StringBuilder(" hello "); - - // Act - var result = builder.Trim().Append("!"); - - // Assert - result.ToString().Should().Be("hello!"); + // Act & assert + new StringBuilder(" hello ").Trim().ToString().Should().Be("hello"); + new StringBuilder(" hello").Trim().ToString().Should().Be("hello"); + new StringBuilder("hello ").Trim().ToString().Should().Be("hello"); + new StringBuilder("hello").Trim().ToString().Should().Be("hello"); + new StringBuilder().Trim().ToString().Should().Be(""); + new StringBuilder(" ").Trim().ToString().Should().Be(""); + new StringBuilder(" hello ").Trim().Append("!").ToString().Should().Be("hello!"); } } diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index eed8fca..3f23070 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -11,19 +11,7 @@ public void SubstringUntil_Test() { // Act & assert "hello world".SubstringUntil(" ").Should().Be("hello"); - } - - [Fact] - public void SubstringUntil_NotFound_Test() - { - // Act & assert "hello".SubstringUntil("x").Should().Be("hello"); - } - - [Fact] - public void SubstringUntil_AtStart_Test() - { - // Act & assert "xhello".SubstringUntil("x").Should().Be(""); } @@ -32,40 +20,16 @@ public void SubstringAfter_Test() { // Act & assert "hello world".SubstringAfter(" ").Should().Be("world"); - } - - [Fact] - public void SubstringAfter_NotFound_Test() - { - // Act & assert "hello".SubstringAfter("x").Should().Be(""); - } - - [Fact] - public void SubstringAfter_AtEnd_Test() - { - // Act & assert "hellox".SubstringAfter("x").Should().Be(""); } [Fact] - public void Truncate_ShorterThanLimit_Test() + public void Truncate_Test() { // Act & assert "hi".Truncate(10).Should().Be("hi"); - } - - [Fact] - public void Truncate_ExactlyAtLimit_Test() - { - // Act & assert "hello".Truncate(5).Should().Be("hello"); - } - - [Fact] - public void Truncate_LongerThanLimit_Test() - { - // Act & assert "hello".Truncate(3).Should().Be("hel"); } @@ -74,40 +38,10 @@ public void SeparateWords_Test() { // Act & assert "HelloWorld".SeparateWords(' ').Should().Be("Hello World"); - } - - [Fact] - public void SeparateWords_SingleWord_Test() - { - // Act & assert "Hello".SeparateWords(' ').Should().Be("Hello"); - } - - [Fact] - public void SeparateWords_AllLowercase_Test() - { - // Act & assert "hello".SeparateWords(' ').Should().Be("hello"); - } - - [Fact] - public void SeparateWords_Empty_Test() - { - // Act & assert "".SeparateWords(' ').Should().Be(""); - } - - [Fact] - public void SeparateWords_Multiple_Test() - { - // Act & assert "FooBarBaz".SeparateWords(' ').Should().Be("Foo Bar Baz"); - } - - [Fact] - public void SeparateWords_CustomSeparator_Test() - { - // Act & assert "FooBarBaz".SeparateWords('-').Should().Be("Foo-Bar-Baz"); } } diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs index 2a6e65d..3043e85 100644 --- a/PowerKit.Tests/TextReaderExtensionsTests.cs +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Threading.Tasks; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -10,39 +11,17 @@ public class TextReaderExtensionsTests [Fact] public async Task ReadLinesAsync_Test() { - // Arrange - using var reader = new StringReader("line1\nline2\nline3"); - - // Act - var lines = await reader.ReadLinesAsync().ToListAsync(); - - // Assert - lines.Should().Equal("line1", "line2", "line3"); - } - - [Fact] - public async Task ReadLinesAsync_Empty_Test() - { - // Arrange - using var reader = new StringReader(""); - - // Act - var lines = await reader.ReadLinesAsync().ToListAsync(); - - // Assert - lines.Should().BeEmpty(); - } - - [Fact] - public async Task ReadLinesAsync_SingleLine_Test() - { - // Arrange - using var reader = new StringReader("hello"); - - // Act - var lines = await reader.ReadLinesAsync().ToListAsync(); - - // Assert - lines.Should().Equal("hello"); + // Act & assert + (await new StringReader("line1\nline2\nline3").ReadLinesAsync().ToListAsync()) + .Should() + .Equal("line1", "line2", "line3"); + + (await new StringReader("").ReadLinesAsync().ToListAsync()) + .Should() + .BeEmpty(); + + (await new StringReader("hello").ReadLinesAsync().ToListAsync()) + .Should() + .Equal("hello"); } } From 65b6ff313f347ff50a785e3e9b00cfad5cb2060c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:07:30 +0000 Subject: [PATCH 24/43] Add non-default Guid test case to NullIfDefault_Test Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/9d5de743-75ab-4f65-8a3e-3bbf5b6e0a4a Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/FunctionalExtensionsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/PowerKit.Tests/FunctionalExtensionsTests.cs b/PowerKit.Tests/FunctionalExtensionsTests.cs index 449b6b5..ee9c76c 100644 --- a/PowerKit.Tests/FunctionalExtensionsTests.cs +++ b/PowerKit.Tests/FunctionalExtensionsTests.cs @@ -30,6 +30,7 @@ public void NullIfDefault_Test() 0.NullIfDefault().Should().BeNull(); 42.NullIfDefault().Should().Be(42); Guid.Empty.NullIfDefault().Should().BeNull(); + Guid.NewGuid().NullIfDefault().Should().NotBeNull(); } [Fact] From 502c93a51a924ab135f5504d4bfff96d53718d64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:11:14 +0000 Subject: [PATCH 25/43] Add ToKebabCase() and ToSnakeCase() string extensions with tests Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/a502dd1b-aac8-4517-b6f4-f930bd9b8752 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/StringExtensionsTests.cs | 22 ++++++++++++++++++++++ PowerKit/Extensions/StringExtensions.cs | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index 3f23070..8b0b4a0 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -44,4 +44,26 @@ public void SeparateWords_Test() "FooBarBaz".SeparateWords(' ').Should().Be("Foo Bar Baz"); "FooBarBaz".SeparateWords('-').Should().Be("Foo-Bar-Baz"); } + + [Fact] + public void ToKebabCase_Test() + { + // Act & assert + "HelloWorld".ToKebabCase().Should().Be("hello-world"); + "FooBarBaz".ToKebabCase().Should().Be("foo-bar-baz"); + "Hello".ToKebabCase().Should().Be("hello"); + "hello".ToKebabCase().Should().Be("hello"); + "".ToKebabCase().Should().Be(""); + } + + [Fact] + public void ToSnakeCase_Test() + { + // Act & assert + "HelloWorld".ToSnakeCase().Should().Be("hello_world"); + "FooBarBaz".ToSnakeCase().Should().Be("foo_bar_baz"); + "Hello".ToSnakeCase().Should().Be("hello"); + "hello".ToSnakeCase().Should().Be("hello"); + "".ToSnakeCase().Should().Be(""); + } } diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index c002f63..c6ad9d8 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -45,5 +45,9 @@ public string SeparateWords(char separator) return builder.ToString(); } + + public string ToKebabCase() => str.SeparateWords('-').ToLowerInvariant(); + + public string ToSnakeCase() => str.SeparateWords('_').ToLowerInvariant(); } } From ce8971607a62cb4f70de2952e1eddc2ff97985d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:20:29 +0000 Subject: [PATCH 26/43] Address review feedback: unmerge tests, fix Disposable.Merge exception handling, move ToSingletonEnumerable, clean up StreamExtensions Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/e596216f-ce6b-4c78-bd26-1e6796eea22d Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../AggregateExceptionExtensionsTests.cs | 39 ++++++--- PowerKit.Tests/DisposableTests.cs | 33 ++++++-- PowerKit.Tests/EnumerableExtensionsTests.cs | 11 +++ PowerKit.Tests/ExceptionExtensionsTests.cs | 84 ++++++++++++------- PowerKit.Tests/ObjectExtensionsTests.cs | 18 ---- PowerKit.Tests/StreamExtensionsTests.cs | 59 ------------- PowerKit.Tests/TextReaderExtensionsTests.cs | 46 +++++++--- PowerKit/Disposable.cs | 18 +++- PowerKit/Extensions/EnumerableExtensions.cs | 8 ++ PowerKit/Extensions/ObjectExtensions.cs | 14 ---- PowerKit/Extensions/StreamExtensions.cs | 4 +- 11 files changed, 177 insertions(+), 157 deletions(-) delete mode 100644 PowerKit.Tests/ObjectExtensionsTests.cs delete mode 100644 PowerKit/Extensions/ObjectExtensions.cs diff --git a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs index 9ad8752..64a317b 100644 --- a/PowerKit.Tests/AggregateExceptionExtensionsTests.cs +++ b/PowerKit.Tests/AggregateExceptionExtensionsTests.cs @@ -10,25 +10,40 @@ public class AggregateExceptionExtensionsTests [Fact] public void TryGetSingle_Test() { - { - // Act & assert - var inner = new Exception("only"); - new AggregateException(inner).TryGetSingle().Should().BeSameAs(inner); - } + // Arrange + var inner = new Exception("only"); + // Act & assert + new AggregateException(inner).TryGetSingle().Should().BeSameAs(inner); + } + + [Fact] + public void TryGetSingle_Multiple_Test() + { + // Act & assert new AggregateException(new Exception("a"), new Exception("b")) .TryGetSingle() .Should() .BeNull(); + } - { - var leaf = new Exception("leaf"); - new AggregateException(new AggregateException(leaf)) - .TryGetSingle() - .Should() - .BeSameAs(leaf); - } + [Fact] + public void TryGetSingle_Nested_Test() + { + // Arrange + var leaf = new Exception("leaf"); + // Act & assert + new AggregateException(new AggregateException(leaf)) + .TryGetSingle() + .Should() + .BeSameAs(leaf); + } + + [Fact] + public void TryGetSingle_NestedMultiple_Test() + { + // Act & assert new AggregateException( new AggregateException(new Exception("a"), new Exception("b")) ) diff --git a/PowerKit.Tests/DisposableTests.cs b/PowerKit.Tests/DisposableTests.cs index d76edcd..3ac1edd 100644 --- a/PowerKit.Tests/DisposableTests.cs +++ b/PowerKit.Tests/DisposableTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -8,13 +9,6 @@ namespace PowerKit.Tests; public class DisposableTests { - [Fact] - public void Null_Test() - { - // Act & assert - Disposable.Null.Dispose(); - } - [Fact] public void Create_Test() { @@ -44,4 +38,29 @@ public void Merge_Test() // Assert order.Should().Equal(0, 1, 2); } + + [Fact] + public void Merge_Exception_Test() + { + // Arrange + var disposed = new List(); + var disposables = new[] + { + Disposable.Create(() => disposed.Add(0)), + Disposable.Create(() => + { + disposed.Add(1); + throw new InvalidOperationException("fail"); + }), + Disposable.Create(() => disposed.Add(2)), + }; + + // Act + var ex = Assert.Throws(() => Disposable.Merge(disposables).Dispose()); + + // Assert + disposed.Should().Equal(0, 1, 2); + ex.InnerExceptions.Should().ContainSingle(); + ex.InnerExceptions[0].Should().BeOfType(); + } } diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index 9330576..d0a601e 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -7,6 +8,15 @@ namespace PowerKit.Tests; public class EnumerableExtensionsTests { + [Fact] + public void ToSingletonEnumerable_Test() + { + // Act & assert + 42.ToSingletonEnumerable().ToList().Should().Equal(42); + "hello".ToSingletonEnumerable().ToList().Should().Equal("hello"); + ((string?)null).ToSingletonEnumerable().ToList().Should().Equal((string?)null); + } + [Fact] public void WhereNotNull_Test() { @@ -31,6 +41,7 @@ public void FirstOrNull_Test() { // Act & assert new[] { 5, 10, 15 }.FirstOrNull().Should().Be(5); + new[] { 42 }.FirstOrNull().Should().Be(42); Array.Empty().FirstOrNull().Should().BeNull(); } diff --git a/PowerKit.Tests/ExceptionExtensionsTests.cs b/PowerKit.Tests/ExceptionExtensionsTests.cs index 01d70a0..c3a46ad 100644 --- a/PowerKit.Tests/ExceptionExtensionsTests.cs +++ b/PowerKit.Tests/ExceptionExtensionsTests.cs @@ -10,37 +10,57 @@ public class ExceptionExtensionsTests [Fact] public void GetSelfAndDescendants_Test() { - { - // Act & assert - var ex = new Exception("root"); - ex.GetSelfAndDescendants().Should().Equal(ex); - } - - { - var inner = new Exception("inner"); - var outer = new Exception("outer", inner); - outer.GetSelfAndDescendants().Should().Equal(outer, inner); - } - - { - var leaf = new Exception("leaf"); - var middle = new Exception("middle", leaf); - var root = new Exception("root", middle); - root.GetSelfAndDescendants().Should().Equal(root, middle, leaf); - } - - { - var inner1 = new Exception("inner1"); - var inner2 = new Exception("inner2"); - var aggregate = new AggregateException("aggregate", inner1, inner2); - aggregate.GetSelfAndDescendants().Should().Equal(aggregate, inner1, inner2); - } - - { - var leaf = new Exception("leaf"); - var inner = new AggregateException("inner", leaf); - var root = new AggregateException("root", inner); - root.GetSelfAndDescendants().Should().Equal(root, inner, leaf); - } + // Arrange + var ex = new Exception("root"); + + // Act & assert + ex.GetSelfAndDescendants().Should().Equal(ex); + } + + [Fact] + public void GetSelfAndDescendants_Chain_Test() + { + // Arrange + var inner = new Exception("inner"); + var outer = new Exception("outer", inner); + + // Act & assert + outer.GetSelfAndDescendants().Should().Equal(outer, inner); + } + + [Fact] + public void GetSelfAndDescendants_DeepChain_Test() + { + // Arrange + var leaf = new Exception("leaf"); + var middle = new Exception("middle", leaf); + var root = new Exception("root", middle); + + // Act & assert + root.GetSelfAndDescendants().Should().Equal(root, middle, leaf); + } + + [Fact] + public void GetSelfAndDescendants_Aggregate_Test() + { + // Arrange + var inner1 = new Exception("inner1"); + var inner2 = new Exception("inner2"); + var aggregate = new AggregateException("aggregate", inner1, inner2); + + // Act & assert + aggregate.GetSelfAndDescendants().Should().Equal(aggregate, inner1, inner2); + } + + [Fact] + public void GetSelfAndDescendants_NestedAggregate_Test() + { + // Arrange + var leaf = new Exception("leaf"); + var inner = new AggregateException("inner", leaf); + var root = new AggregateException("root", inner); + + // Act & assert + root.GetSelfAndDescendants().Should().Equal(root, inner, leaf); } } diff --git a/PowerKit.Tests/ObjectExtensionsTests.cs b/PowerKit.Tests/ObjectExtensionsTests.cs deleted file mode 100644 index f184a3d..0000000 --- a/PowerKit.Tests/ObjectExtensionsTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Linq; -using FluentAssertions; -using PowerKit.Extensions; -using Xunit; - -namespace PowerKit.Tests; - -public class ObjectExtensionsTests -{ - [Fact] - public void ToSingletonEnumerable_Test() - { - // Act & assert - 42.ToSingletonEnumerable().ToList().Should().Equal(42); - "hello".ToSingletonEnumerable().ToList().Should().Equal("hello"); - ((string?)null).ToSingletonEnumerable().ToList().Should().Equal((string?)null); - } -} diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index 34f2cb3..7fa9587 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -25,35 +25,6 @@ public async Task CopyToAsync_AutoFlush_Test() destination.ToArray().Should().Equal(data); } - [Fact] - public async Task CopyToAsync_NoAutoFlush_Test() - { - // Arrange - var data = new byte[] { 10, 20, 30 }; - using var source = new MemoryStream(data); - using var destination = new MemoryStream(); - - // Act - await source.CopyToAsync(destination, autoFlush: false); - - // Assert - destination.ToArray().Should().Equal(data); - } - - [Fact] - public async Task CopyToAsync_Empty_Test() - { - // Arrange - using var source = new MemoryStream(); - using var destination = new MemoryStream(); - - // Act - await source.CopyToAsync(destination, autoFlush: false); - - // Assert - destination.ToArray().Should().BeEmpty(); - } - [Fact] public async Task CopyToAsync_Progress_Test() { @@ -62,21 +33,6 @@ public async Task CopyToAsync_Progress_Test() using var source = new MemoryStream(data); using var destination = new MemoryStream(); - // Act - await source.CopyToAsync(destination, progress: null); - - // Assert - destination.ToArray().Should().Equal(data); - } - - [Fact] - public async Task CopyToAsync_Progress_Reports_Test() - { - // Arrange - var data = new byte[1024]; - using var source = new MemoryStream(data); - using var destination = new MemoryStream(); - var reports = new List(); var progress = new Progress(v => reports.Add(v)); @@ -92,21 +48,6 @@ public async Task CopyToAsync_Progress_Reports_Test() reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } - [Fact] - public async Task CopyToAsync_ContentLength_Test() - { - // Arrange - var data = new byte[1024]; - using var source = new MemoryStream(data); - using var destination = new MemoryStream(); - - // Act - await source.CopyToAsync(destination, contentLength: 1024); - - // Assert - destination.ToArray().Should().Equal(data); - } - [Fact] public async Task CopyToAsync_ContentLength_Progress_Test() { diff --git a/PowerKit.Tests/TextReaderExtensionsTests.cs b/PowerKit.Tests/TextReaderExtensionsTests.cs index 3043e85..2ec67a3 100644 --- a/PowerKit.Tests/TextReaderExtensionsTests.cs +++ b/PowerKit.Tests/TextReaderExtensionsTests.cs @@ -11,17 +11,39 @@ public class TextReaderExtensionsTests [Fact] public async Task ReadLinesAsync_Test() { - // Act & assert - (await new StringReader("line1\nline2\nline3").ReadLinesAsync().ToListAsync()) - .Should() - .Equal("line1", "line2", "line3"); - - (await new StringReader("").ReadLinesAsync().ToListAsync()) - .Should() - .BeEmpty(); - - (await new StringReader("hello").ReadLinesAsync().ToListAsync()) - .Should() - .Equal("hello"); + // Arrange + using var reader = new StringReader("line1\nline2\nline3"); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().Equal("line1", "line2", "line3"); + } + + [Fact] + public async Task ReadLinesAsync_Empty_Test() + { + // Arrange + using var reader = new StringReader(""); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task ReadLinesAsync_SingleLine_Test() + { + // Arrange + using var reader = new StringReader("hello"); + + // Act + var result = await reader.ReadLinesAsync().ToListAsync(); + + // Assert + result.Should().Equal("hello"); } } diff --git a/PowerKit/Disposable.cs b/PowerKit/Disposable.cs index c79eed0..78c6987 100644 --- a/PowerKit/Disposable.cs +++ b/PowerKit/Disposable.cs @@ -17,7 +17,23 @@ internal partial class Disposable public static IDisposable Merge(params IEnumerable disposables) => Create(() => { + var exceptions = new List(); + foreach (var disposable in disposables) - disposable.Dispose(); + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + if (exceptions.Count > 0) + { + throw new AggregateException(exceptions); + } }); } diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs index 8bdc534..6deec43 100644 --- a/PowerKit/Extensions/EnumerableExtensions.cs +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -5,6 +5,14 @@ namespace PowerKit.Extensions; internal static class EnumerableExtensions { + extension(T obj) + { + public IEnumerable ToSingletonEnumerable() + { + yield return obj; + } + } + extension(IEnumerable source) where T : class { diff --git a/PowerKit/Extensions/ObjectExtensions.cs b/PowerKit/Extensions/ObjectExtensions.cs deleted file mode 100644 index 4225646..0000000 --- a/PowerKit/Extensions/ObjectExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace PowerKit.Extensions; - -internal static class ObjectExtensions -{ - extension(T obj) - { - public IEnumerable ToSingletonEnumerable() - { - yield return obj; - } - } -} diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index fb4c523..38ff1cf 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -73,7 +73,7 @@ await destination public async ValueTask CopyToAsync( Stream destination, long contentLength, - IProgress? progress = null, + IProgress progress, CancellationToken cancellationToken = default ) { @@ -98,7 +98,7 @@ await destination totalBytesRead += bytesRead; - if (progress is not null && contentLength > 0) + if (contentLength > 0) { progress.Report(1.0 * totalBytesRead / contentLength); } From f5626864ed256bee00843ea11a39601046485423 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:11:48 +0000 Subject: [PATCH 27/43] Use ToArray() instead of ToList() in ToSingletonEnumerable test Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/ba7fd89b-de46-401f-ae6c-f82a3b566f6c Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/EnumerableExtensionsTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index d0a601e..8f4b326 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -12,9 +12,9 @@ public class EnumerableExtensionsTests public void ToSingletonEnumerable_Test() { // Act & assert - 42.ToSingletonEnumerable().ToList().Should().Equal(42); - "hello".ToSingletonEnumerable().ToList().Should().Equal("hello"); - ((string?)null).ToSingletonEnumerable().ToList().Should().Equal((string?)null); + 42.ToSingletonEnumerable().ToArray().Should().Equal(42); + "hello".ToSingletonEnumerable().ToArray().Should().Equal("hello"); + ((string?)null).ToSingletonEnumerable().ToArray().Should().Equal((string?)null); } [Fact] From 47da80b57e630c98cc111f3745260770bf084e40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:15:19 +0000 Subject: [PATCH 28/43] Replace Progress with synchronous IProgress in stream tests Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/de9060b6-d912-4603-bbcc-379e6fe1753c Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/StreamExtensionsTests.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index 7fa9587..99308de 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -34,14 +34,11 @@ public async Task CopyToAsync_Progress_Test() using var destination = new MemoryStream(); var reports = new List(); - var progress = new Progress(v => reports.Add(v)); + var progress = new SynchronousProgress(v => reports.Add(v)); // Act await source.CopyToAsync(destination, progress: progress); - // Allow Progress callbacks to fire on the thread pool - await Task.Delay(50); - // Assert reports.Should().NotBeEmpty(); reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); @@ -57,17 +54,19 @@ public async Task CopyToAsync_ContentLength_Progress_Test() using var destination = new MemoryStream(); var reports = new List(); - var progress = new Progress(v => reports.Add(v)); + var progress = new SynchronousProgress(v => reports.Add(v)); // Act await source.CopyToAsync(destination, contentLength: 1024, progress: progress); - // Allow Progress callbacks to fire on the thread pool - await Task.Delay(50); - // Assert reports.Should().NotBeEmpty(); reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } } + +file class SynchronousProgress(Action handler) : IProgress +{ + public void Report(T value) => handler(value); +} From ae8a23c6be7a25440e8211a210b6d6f4ad494a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:21:27 +0000 Subject: [PATCH 29/43] Add WhereNotNullOrEmpty, refactor StreamExtensions to share code, lazy-init exceptions in Disposable.Merge Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/7e8c07e4-d0e1-4317-94db-021dd5d0ed06 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/EnumerableExtensionsTests.cs | 14 ++++++++ PowerKit/Disposable.cs | 6 ++-- PowerKit/Extensions/EnumerableExtensions.cs | 17 ++++++++++ PowerKit/Extensions/StreamExtensions.cs | 36 +++++---------------- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index 8f4b326..f0f477b 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -26,6 +26,20 @@ public void WhereNotNull_Test() Array.Empty().WhereNotNull().Should().BeEmpty(); } + [Fact] + public void WhereNotNullOrEmpty_Test() + { + // Act & assert + new string?[] { "hello", null, "", "world" } + .WhereNotNullOrEmpty() + .Should() + .Equal("hello", "world"); + new string?[] { "hello", " ", "", null } + .WhereNotNullOrEmpty() + .Should() + .Equal("hello", " "); + } + [Fact] public void WhereNotNullOrWhiteSpace_Test() { diff --git a/PowerKit/Disposable.cs b/PowerKit/Disposable.cs index 78c6987..eb2179f 100644 --- a/PowerKit/Disposable.cs +++ b/PowerKit/Disposable.cs @@ -17,7 +17,7 @@ internal partial class Disposable public static IDisposable Merge(params IEnumerable disposables) => Create(() => { - var exceptions = new List(); + List? exceptions = null; foreach (var disposable in disposables) { @@ -27,11 +27,11 @@ public static IDisposable Merge(params IEnumerable disposables) => } catch (Exception ex) { - exceptions.Add(ex); + (exceptions ??= []).Add(ex); } } - if (exceptions.Count > 0) + if (exceptions?.Count > 0) { throw new AggregateException(exceptions); } diff --git a/PowerKit/Extensions/EnumerableExtensions.cs b/PowerKit/Extensions/EnumerableExtensions.cs index 6deec43..378c712 100644 --- a/PowerKit/Extensions/EnumerableExtensions.cs +++ b/PowerKit/Extensions/EnumerableExtensions.cs @@ -21,7 +21,9 @@ public IEnumerable WhereNotNull() foreach (var item in source) { if (item is not null) + { yield return item; + } } } } @@ -34,19 +36,34 @@ public IEnumerable WhereNotNull() foreach (var item in source) { if (item is not null) + { yield return item.Value; + } } } } extension(IEnumerable source) { + public IEnumerable WhereNotNullOrEmpty() + { + foreach (var item in source) + { + if (!string.IsNullOrEmpty(item)) + { + yield return item!; + } + } + } + public IEnumerable WhereNotNullOrWhiteSpace() { foreach (var item in source) { if (!string.IsNullOrWhiteSpace(item)) + { yield return item!; + } } } } diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index 38ff1cf..343ff57 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -25,14 +25,18 @@ public async Task CopyToAsync( .ConfigureAwait(false); if (bytesRead <= 0) + { break; + } await destination .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) .ConfigureAwait(false); if (autoFlush) + { await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + } } } @@ -42,38 +46,14 @@ public async ValueTask CopyToAsync( CancellationToken cancellationToken = default ) { - using var buffer = MemoryPool.Shared.Rent(81920); - - var totalBytesRead = 0L; - - while (true) - { - var bytesRead = await source - .ReadAsync(buffer.Memory, cancellationToken) - .ConfigureAwait(false); - - if (bytesRead <= 0) - { - break; - } - - await destination - .WriteAsync(buffer.Memory[..bytesRead], cancellationToken) - .ConfigureAwait(false); - - totalBytesRead += bytesRead; - - if (progress is not null && source.CanSeek && source.Length > 0) - { - progress.Report(1.0 * totalBytesRead / source.Length); - } - } + var contentLength = source.CanSeek ? source.Length : -1; + await source.CopyToAsync(destination, contentLength, progress, cancellationToken); } public async ValueTask CopyToAsync( Stream destination, long contentLength, - IProgress progress, + IProgress? progress, CancellationToken cancellationToken = default ) { @@ -98,7 +78,7 @@ await destination totalBytesRead += bytesRead; - if (contentLength > 0) + if (progress is not null && contentLength > 0) { progress.Report(1.0 * totalBytesRead / contentLength); } From 57870c8bf4f501a92e1808bee1d3fbf827b12b33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:23:20 +0000 Subject: [PATCH 30/43] Add empty-array test case for WhereNotNullOrEmpty Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/7e8c07e4-d0e1-4317-94db-021dd5d0ed06 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/EnumerableExtensionsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/PowerKit.Tests/EnumerableExtensionsTests.cs b/PowerKit.Tests/EnumerableExtensionsTests.cs index f0f477b..c32dc15 100644 --- a/PowerKit.Tests/EnumerableExtensionsTests.cs +++ b/PowerKit.Tests/EnumerableExtensionsTests.cs @@ -38,6 +38,7 @@ public void WhereNotNullOrEmpty_Test() .WhereNotNullOrEmpty() .Should() .Equal("hello", " "); + Array.Empty().WhereNotNullOrEmpty().Should().BeEmpty(); } [Fact] From 231c828936491e45177c779c80d753e3ff473b1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:38:38 +0000 Subject: [PATCH 31/43] Replace custom SynchronousProgress with Gress package's DelegateProgress Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/ecac02d7-08e2-43dc-b7ea-cf8e9ab88ccb Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- Directory.Packages.props | 1 + PowerKit.Tests/PowerKit.Tests.csproj | 1 + PowerKit.Tests/StreamExtensionsTests.cs | 11 +++-------- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0afbb3b..04d12ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/PowerKit.Tests/PowerKit.Tests.csproj b/PowerKit.Tests/PowerKit.Tests.csproj index a3281be..613d7df 100644 --- a/PowerKit.Tests/PowerKit.Tests.csproj +++ b/PowerKit.Tests/PowerKit.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index 99308de..c52b978 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -1,8 +1,8 @@ -using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using FluentAssertions; +using Gress; using PowerKit.Extensions; using Xunit; @@ -34,7 +34,7 @@ public async Task CopyToAsync_Progress_Test() using var destination = new MemoryStream(); var reports = new List(); - var progress = new SynchronousProgress(v => reports.Add(v)); + var progress = new DelegateProgress(v => reports.Add(v)); // Act await source.CopyToAsync(destination, progress: progress); @@ -54,7 +54,7 @@ public async Task CopyToAsync_ContentLength_Progress_Test() using var destination = new MemoryStream(); var reports = new List(); - var progress = new SynchronousProgress(v => reports.Add(v)); + var progress = new DelegateProgress(v => reports.Add(v)); // Act await source.CopyToAsync(destination, contentLength: 1024, progress: progress); @@ -65,8 +65,3 @@ public async Task CopyToAsync_ContentLength_Progress_Test() reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } } - -file class SynchronousProgress(Action handler) : IProgress -{ - public void Report(T value) => handler(value); -} From 1027ff47d155e7177e8809733d42e749e691af8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:45:00 +0000 Subject: [PATCH 32/43] Use Gress ProgressCollector in stream tests; reorder StreamExtensions methods Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/a4aa3bce-c192-4069-aaec-55a6553835d2 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/StreamExtensionsTests.cs | 10 +++++----- PowerKit/Extensions/StreamExtensions.cs | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index c52b978..d5ef160 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Gress; @@ -33,13 +33,13 @@ public async Task CopyToAsync_Progress_Test() using var source = new MemoryStream(data); using var destination = new MemoryStream(); - var reports = new List(); - var progress = new DelegateProgress(v => reports.Add(v)); + var progress = new ProgressCollector(); // Act await source.CopyToAsync(destination, progress: progress); // Assert + var reports = progress.GetValues().ToArray(); reports.Should().NotBeEmpty(); reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); reports[^1].Should().BeApproximately(1.0, precision: 1e-5); @@ -53,13 +53,13 @@ public async Task CopyToAsync_ContentLength_Progress_Test() using var source = new MemoryStream(data); using var destination = new MemoryStream(); - var reports = new List(); - var progress = new DelegateProgress(v => reports.Add(v)); + var progress = new ProgressCollector(); // Act await source.CopyToAsync(destination, contentLength: 1024, progress: progress); // Assert + var reports = progress.GetValues().ToArray(); reports.Should().NotBeEmpty(); reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); reports[^1].Should().BeApproximately(1.0, precision: 1e-5); diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index 343ff57..de41fd5 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -40,16 +40,6 @@ await destination } } - public async ValueTask CopyToAsync( - Stream destination, - IProgress? progress = null, - CancellationToken cancellationToken = default - ) - { - var contentLength = source.CanSeek ? source.Length : -1; - await source.CopyToAsync(destination, contentLength, progress, cancellationToken); - } - public async ValueTask CopyToAsync( Stream destination, long contentLength, @@ -84,5 +74,15 @@ await destination } } } + + public async ValueTask CopyToAsync( + Stream destination, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var contentLength = source.CanSeek ? source.Length : -1; + await source.CopyToAsync(destination, contentLength, progress, cancellationToken); + } } } From fd61e3285d34d260a3bae7dbdb2481f2f1eaf597 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:52:04 +0000 Subject: [PATCH 33/43] Remove duplicate CopyToAsync_ContentLength_Progress_Test (same as CopyToAsync_Progress_Test) Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/f8dd53be-d7fe-4818-a11e-69427b83c252 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/StreamExtensionsTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/PowerKit.Tests/StreamExtensionsTests.cs b/PowerKit.Tests/StreamExtensionsTests.cs index d5ef160..5930c6d 100644 --- a/PowerKit.Tests/StreamExtensionsTests.cs +++ b/PowerKit.Tests/StreamExtensionsTests.cs @@ -45,23 +45,4 @@ public async Task CopyToAsync_Progress_Test() reports[^1].Should().BeApproximately(1.0, precision: 1e-5); } - [Fact] - public async Task CopyToAsync_ContentLength_Progress_Test() - { - // Arrange - var data = new byte[1024]; - using var source = new MemoryStream(data); - using var destination = new MemoryStream(); - - var progress = new ProgressCollector(); - - // Act - await source.CopyToAsync(destination, contentLength: 1024, progress: progress); - - // Assert - var reports = progress.GetValues().ToArray(); - reports.Should().NotBeEmpty(); - reports.Should().AllSatisfy(v => v.Should().BeInRange(0.0, 1.0)); - reports[^1].Should().BeApproximately(1.0, precision: 1e-5); - } } From b095af14fb7c5ad9d7261fe208db59c0e4ccbb8e Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:01:42 +0300 Subject: [PATCH 34/43] Update PowerKit/Extensions/StreamExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PowerKit/Extensions/StreamExtensions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PowerKit/Extensions/StreamExtensions.cs b/PowerKit/Extensions/StreamExtensions.cs index de41fd5..e392e83 100644 --- a/PowerKit/Extensions/StreamExtensions.cs +++ b/PowerKit/Extensions/StreamExtensions.cs @@ -82,7 +82,9 @@ public async ValueTask CopyToAsync( ) { var contentLength = source.CanSeek ? source.Length : -1; - await source.CopyToAsync(destination, contentLength, progress, cancellationToken); + await source + .CopyToAsync(destination, contentLength, progress, cancellationToken) + .ConfigureAwait(false); } } } From b79b77c2b784252ab91ec2306e9357198b031691 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:02:47 +0300 Subject: [PATCH 35/43] Update PowerKit/Extensions/AggregateExceptionExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PowerKit/Extensions/AggregateExceptionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PowerKit/Extensions/AggregateExceptionExtensions.cs b/PowerKit/Extensions/AggregateExceptionExtensions.cs index b0aaaaf..00ca0c9 100644 --- a/PowerKit/Extensions/AggregateExceptionExtensions.cs +++ b/PowerKit/Extensions/AggregateExceptionExtensions.cs @@ -10,7 +10,7 @@ internal static class AggregateExceptionExtensions public Exception? TryGetSingle() { var exceptions = exception.Flatten().InnerExceptions; - return exceptions.Count == 1 ? exceptions.Single() : null; + return exceptions.Count == 1 ? exceptions[0] : null; } } } From caedc2914757b58d7fbd2311895e11ccd6d51873 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:44:25 +0000 Subject: [PATCH 36/43] Expand InvalidFileNameChars to include ASCII control characters 0x01-0x1F and add braces to foreach Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/a2731348-f326-4da6-af04-9d444f6f2efd Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/PathExtensionsTests.cs | 1 + PowerKit/Extensions/PathExtensions.cs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index 04fd106..b812716 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -16,6 +16,7 @@ public void EscapeFileName_Test() Path.EscapeFileName("a\\b").Should().Be("a_b"); Path.EscapeFileName("C:drive").Should().Be("C_drive"); Path.EscapeFileName("a\0b/c\\d:e*f?g\"h InvalidFileNameChars = [ '\0', // Null character - invalid on all filesystems + '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', // invalid on Windows + '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', // (NTFS/FAT32) + '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F', '/', // Path separator on Unix and Windows '\\', // Path separator on Windows ':', // Reserved on Windows (drive letters, NTFS streams) @@ -31,7 +35,9 @@ public static string EscapeFileName(string fileName) var buffer = new StringBuilder(fileName.Length); foreach (var c in fileName) + { buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); + } // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems) while (buffer.Length > 0 && (buffer[buffer.Length - 1] == '.' || char.IsWhiteSpace(buffer[buffer.Length - 1]))) From 0c1376cf97b894180d3027a141a3f67f53979bc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:50:00 +0000 Subject: [PATCH 37/43] Rename char variable 'c' to 'ch' in PathExtensions and StringExtensions Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/bb331d67-d827-44d6-825d-c79bf921f897 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/Extensions/PathExtensions.cs | 4 ++-- PowerKit/Extensions/StringExtensions.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 0f06e3a..5800651 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -34,9 +34,9 @@ public static string EscapeFileName(string fileName) { var buffer = new StringBuilder(fileName.Length); - foreach (var c in fileName) + foreach (var ch in fileName) { - buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); + buffer.Append(!InvalidFileNameChars.Contains(ch) ? ch : '_'); } // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems) diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index c6ad9d8..57f6c66 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -33,14 +33,14 @@ public string SeparateWords(char separator) { var builder = new StringBuilder(str.Length * 2); - foreach (var c in str) + foreach (var ch in str) { - if (char.IsUpper(c) && builder.Length > 0) + if (char.IsUpper(ch) && builder.Length > 0) { builder.Append(separator); } - builder.Append(c); + builder.Append(ch); } return builder.ToString(); From fa5303316753493d9292df853099af5f5ee1af54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:07:00 +0000 Subject: [PATCH 38/43] Address PR review comments: pattern matching in AggregateExceptionExtensions, braces in AsyncEnumerableExtensions, add SubstringUntilLast/SubstringAfterLast, refactor PathExtensions with GetInvalidFileNameChars/GetInvalidPathChars/crossPlatform param Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/f860d5c2-2b94-48ae-a785-334b01ce9d09 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit.Tests/PathExtensionsTests.cs | 27 +++++++++++ PowerKit.Tests/StringExtensionsTests.cs | 18 ++++++++ .../AggregateExceptionExtensions.cs | 8 +--- .../Extensions/AsyncEnumerableExtensions.cs | 2 + PowerKit/Extensions/PathExtensions.cs | 45 ++++++++++++++++--- PowerKit/Extensions/StringExtensions.cs | 20 +++++++++ 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/PowerKit.Tests/PathExtensionsTests.cs b/PowerKit.Tests/PathExtensionsTests.cs index b812716..44efd7d 100644 --- a/PowerKit.Tests/PathExtensionsTests.cs +++ b/PowerKit.Tests/PathExtensionsTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using FluentAssertions; using PowerKit.Extensions; using Xunit; @@ -7,6 +8,32 @@ namespace PowerKit.Tests; public class PathExtensionsTests { + [Fact] + public void GetInvalidFileNameChars_Test() + { + // Act & assert + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('/'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\\'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\0'); + Path.GetInvalidFileNameChars(crossPlatform: true).Should().Contain('\x01'); + Path.GetInvalidFileNameChars(crossPlatform: false) + .Should() + .BeEquivalentTo(Path.GetInvalidFileNameChars()); + } + + [Fact] + public void GetInvalidPathChars_Test() + { + // Act & assert + Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('\0'); + Path.GetInvalidPathChars(crossPlatform: true).Should().Contain('|'); + Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('/'); + Path.GetInvalidPathChars(crossPlatform: true).Should().NotContain('\\'); + Path.GetInvalidPathChars(crossPlatform: false) + .Should() + .BeEquivalentTo(Path.GetInvalidPathChars()); + } + [Fact] public void EscapeFileName_Test() { diff --git a/PowerKit.Tests/StringExtensionsTests.cs b/PowerKit.Tests/StringExtensionsTests.cs index 8b0b4a0..a60e97a 100644 --- a/PowerKit.Tests/StringExtensionsTests.cs +++ b/PowerKit.Tests/StringExtensionsTests.cs @@ -15,6 +15,15 @@ public void SubstringUntil_Test() "xhello".SubstringUntil("x").Should().Be(""); } + [Fact] + public void SubstringUntilLast_Test() + { + // Act & assert + "hello world foo".SubstringUntilLast(" ").Should().Be("hello world"); + "hello".SubstringUntilLast("x").Should().Be("hello"); + "a.b.c".SubstringUntilLast(".").Should().Be("a.b"); + } + [Fact] public void SubstringAfter_Test() { @@ -24,6 +33,15 @@ public void SubstringAfter_Test() "hellox".SubstringAfter("x").Should().Be(""); } + [Fact] + public void SubstringAfterLast_Test() + { + // Act & assert + "hello world foo".SubstringAfterLast(" ").Should().Be("foo"); + "hello".SubstringAfterLast("x").Should().Be(""); + "a.b.c".SubstringAfterLast(".").Should().Be("c"); + } + [Fact] public void Truncate_Test() { diff --git a/PowerKit/Extensions/AggregateExceptionExtensions.cs b/PowerKit/Extensions/AggregateExceptionExtensions.cs index 00ca0c9..c0d0fea 100644 --- a/PowerKit/Extensions/AggregateExceptionExtensions.cs +++ b/PowerKit/Extensions/AggregateExceptionExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; namespace PowerKit.Extensions; @@ -7,10 +6,7 @@ internal static class AggregateExceptionExtensions { extension(AggregateException exception) { - public Exception? TryGetSingle() - { - var exceptions = exception.Flatten().InnerExceptions; - return exceptions.Count == 1 ? exceptions[0] : null; - } + public Exception? TryGetSingle() => + exception.Flatten().InnerExceptions is [var single] ? single : null; } } diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs index f91ed0e..b7c11b8 100644 --- a/PowerKit/Extensions/AsyncEnumerableExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -46,7 +46,9 @@ var item in source ) { foreach (var result in transform(item)) + { yield return result; + } } } diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 5800651..bc5691e 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -1,16 +1,18 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; namespace PowerKit.Extensions; internal static class PathExtensions { - // This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS). - // We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters, - // not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux, - // which would make some additional characters invalid that are otherwise valid on Linux. - private static readonly HashSet InvalidFileNameChars = + // Characters that are invalid in file names across all major filesystems + // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what + // the OS-specific Path.GetInvalidFileNameChars() returns. + // This is useful when working with files that may be accessed from + // different operating systems, such as NTFS drives on Linux. + private static readonly HashSet CrossPlatformInvalidFileNameChars = [ '\0', // Null character - invalid on all filesystems '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - @@ -28,15 +30,44 @@ internal static class PathExtensions '|', // Pipe on Windows ]; + private static readonly HashSet CrossPlatformInvalidPathChars = + [ + '\0', // Null character - invalid on all filesystems + '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', // invalid on Windows + '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', // (NTFS/FAT32) + '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F', + '*', // Wildcard on Windows + '?', // Wildcard on Windows + '"', // Reserved on Windows + '<', // Redirection on Windows + '>', // Redirection on Windows + '|', // Pipe on Windows + ]; + extension(Path) { - public static string EscapeFileName(string fileName) + public static char[] GetInvalidFileNameChars(bool crossPlatform) => + crossPlatform + ? CrossPlatformInvalidFileNameChars.ToArray() + : Path.GetInvalidFileNameChars(); + + public static char[] GetInvalidPathChars(bool crossPlatform) => + crossPlatform + ? CrossPlatformInvalidPathChars.ToArray() + : Path.GetInvalidPathChars(); + + public static string EscapeFileName(string fileName, bool crossPlatform = true) { + var invalidChars = crossPlatform + ? (IReadOnlyCollection)CrossPlatformInvalidFileNameChars + : Path.GetInvalidFileNameChars(); + var buffer = new StringBuilder(fileName.Length); foreach (var ch in fileName) { - buffer.Append(!InvalidFileNameChars.Contains(ch) ? ch : '_'); + buffer.Append(!invalidChars.Contains(ch) ? ch : '_'); } // File names cannot end with a dot or whitespace (invalid on Windows, ambiguous on other filesystems) diff --git a/PowerKit/Extensions/StringExtensions.cs b/PowerKit/Extensions/StringExtensions.cs index 57f6c66..2320da9 100644 --- a/PowerKit/Extensions/StringExtensions.cs +++ b/PowerKit/Extensions/StringExtensions.cs @@ -17,6 +17,16 @@ public string SubstringUntil( _ => str, }; + public string SubstringUntilLast( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.LastIndexOf(sub, comparison) switch + { + >= 0 and var index => str[..index], + _ => str, + }; + public string SubstringAfter( string sub, StringComparison comparison = StringComparison.Ordinal @@ -27,6 +37,16 @@ public string SubstringAfter( _ => "", }; + public string SubstringAfterLast( + string sub, + StringComparison comparison = StringComparison.Ordinal + ) => + str.LastIndexOf(sub, comparison) switch + { + >= 0 and var index => str[(index + sub.Length)..], + _ => "", + }; + public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str; public string SeparateWords(char separator) From afbe49ace3e74ff16713afa354fb84751f2f81a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:09:41 +0000 Subject: [PATCH 39/43] Cache cross-platform invalid char arrays to avoid repeated allocations Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/f860d5c2-2b94-48ae-a785-334b01ce9d09 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/Extensions/PathExtensions.cs | 31 ++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index bc5691e..2065519 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -1,18 +1,12 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; namespace PowerKit.Extensions; internal static class PathExtensions { - // Characters that are invalid in file names across all major filesystems - // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what - // the OS-specific Path.GetInvalidFileNameChars() returns. - // This is useful when working with files that may be accessed from - // different operating systems, such as NTFS drives on Linux. - private static readonly HashSet CrossPlatformInvalidFileNameChars = + private static readonly char[] CrossPlatformInvalidFileNameCharsArray = [ '\0', // Null character - invalid on all filesystems '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - @@ -30,7 +24,16 @@ internal static class PathExtensions '|', // Pipe on Windows ]; - private static readonly HashSet CrossPlatformInvalidPathChars = + // Characters that are invalid in file names across all major filesystems + // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what + // the OS-specific Path.GetInvalidFileNameChars() returns. + // This is useful when working with files that may be accessed from + // different operating systems, such as NTFS drives on Linux. + private static readonly HashSet CrossPlatformInvalidFileNameChars = new( + CrossPlatformInvalidFileNameCharsArray + ); + + private static readonly char[] CrossPlatformInvalidPathCharsArray = [ '\0', // Null character - invalid on all filesystems '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - @@ -45,23 +48,27 @@ internal static class PathExtensions '|', // Pipe on Windows ]; + private static readonly HashSet CrossPlatformInvalidPathChars = new( + CrossPlatformInvalidPathCharsArray + ); + extension(Path) { public static char[] GetInvalidFileNameChars(bool crossPlatform) => crossPlatform - ? CrossPlatformInvalidFileNameChars.ToArray() + ? CrossPlatformInvalidFileNameCharsArray : Path.GetInvalidFileNameChars(); public static char[] GetInvalidPathChars(bool crossPlatform) => crossPlatform - ? CrossPlatformInvalidPathChars.ToArray() + ? CrossPlatformInvalidPathCharsArray : Path.GetInvalidPathChars(); public static string EscapeFileName(string fileName, bool crossPlatform = true) { var invalidChars = crossPlatform - ? (IReadOnlyCollection)CrossPlatformInvalidFileNameChars - : Path.GetInvalidFileNameChars(); + ? CrossPlatformInvalidFileNameChars + : new HashSet(Path.GetInvalidFileNameChars()); var buffer = new StringBuilder(fileName.Length); From 3891af9b88919ef9a47e02873a7266a813836a2d Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:23:52 +0300 Subject: [PATCH 40/43] Update PowerKit/Extensions/AsyncEnumerableExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PowerKit/Extensions/AsyncEnumerableExtensions.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/PowerKit/Extensions/AsyncEnumerableExtensions.cs b/PowerKit/Extensions/AsyncEnumerableExtensions.cs index b7c11b8..b3ac565 100644 --- a/PowerKit/Extensions/AsyncEnumerableExtensions.cs +++ b/PowerKit/Extensions/AsyncEnumerableExtensions.cs @@ -20,16 +20,14 @@ public async IAsyncEnumerable TakeAsync( var currentCount = 0; - await foreach ( - var item in source - .WithCancellation(cancellationToken) - .ConfigureAwait(false) + await using var enumerator = source.GetAsyncEnumerator(cancellationToken); + + while ( + currentCount < count + && await enumerator.MoveNextAsync().ConfigureAwait(false) ) { - if (currentCount >= count) - yield break; - - yield return item; + yield return enumerator.Current; currentCount++; } } From b082066437a86f668a1718ef1b7c6207d330d464 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:29:05 +0000 Subject: [PATCH 41/43] Derive CrossPlatformInvalidPathChars from CrossPlatformInvalidFileNameChars to reduce duplication Agent-Logs-Url: https://github.com/Tyrrrz/PowerKit/sessions/56e3e0eb-b42d-49cc-a361-0b7b771dd982 Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PowerKit/Extensions/PathExtensions.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 2065519..2884b17 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; namespace PowerKit.Extensions; @@ -33,20 +34,12 @@ internal static class PathExtensions CrossPlatformInvalidFileNameCharsArray ); + // Path chars are the same as file name chars, except path separators + // and the colon (drive letter separator) are valid in paths. private static readonly char[] CrossPlatformInvalidPathCharsArray = - [ - '\0', // Null character - invalid on all filesystems - '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - - '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', // invalid on Windows - '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', // (NTFS/FAT32) - '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', '\x1E', '\x1F', - '*', // Wildcard on Windows - '?', // Wildcard on Windows - '"', // Reserved on Windows - '<', // Redirection on Windows - '>', // Redirection on Windows - '|', // Pipe on Windows - ]; + CrossPlatformInvalidFileNameCharsArray + .Where(ch => ch != '/' && ch != '\\' && ch != ':') + .ToArray(); private static readonly HashSet CrossPlatformInvalidPathChars = new( CrossPlatformInvalidPathCharsArray From 381cb5f68332ac46f02bc10b7e5fec16924f8dcf Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:40:43 +0300 Subject: [PATCH 42/43] Update PathExtensions.cs --- PowerKit/Extensions/PathExtensions.cs | 38 +++++++++++---------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 2884b17..5ae9962 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -5,9 +5,14 @@ namespace PowerKit.Extensions; -internal static class PathExtensions +file static class PathEx { - private static readonly char[] CrossPlatformInvalidFileNameCharsArray = + // Characters that are invalid in file names across all major filesystems + // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what + // the OS-specific Path.GetInvalidFileNameChars() returns. + // This is useful when working with files that may be accessed from + // different operating systems, such as NTFS drives on Linux. + private static readonly char[] CrossPlatformInvalidFileNameChars = [ '\0', // Null character - invalid on all filesystems '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - @@ -25,44 +30,31 @@ internal static class PathExtensions '|', // Pipe on Windows ]; - // Characters that are invalid in file names across all major filesystems - // (Windows NTFS/FAT32, Linux ext4/XFS, macOS HFS+/APFS), beyond what - // the OS-specific Path.GetInvalidFileNameChars() returns. - // This is useful when working with files that may be accessed from - // different operating systems, such as NTFS drives on Linux. - private static readonly HashSet CrossPlatformInvalidFileNameChars = new( - CrossPlatformInvalidFileNameCharsArray - ); - // Path chars are the same as file name chars, except path separators // and the colon (drive letter separator) are valid in paths. - private static readonly char[] CrossPlatformInvalidPathCharsArray = - CrossPlatformInvalidFileNameCharsArray + private static readonly char[] CrossPlatformInvalidPathChars = + CrossPlatformInvalidFileNameChars .Where(ch => ch != '/' && ch != '\\' && ch != ':') .ToArray(); +} - private static readonly HashSet CrossPlatformInvalidPathChars = new( - CrossPlatformInvalidPathCharsArray - ); - +internal static class PathExtensions +{ extension(Path) { public static char[] GetInvalidFileNameChars(bool crossPlatform) => crossPlatform - ? CrossPlatformInvalidFileNameCharsArray + ? PathEx.CrossPlatformInvalidFileNameChars : Path.GetInvalidFileNameChars(); public static char[] GetInvalidPathChars(bool crossPlatform) => crossPlatform - ? CrossPlatformInvalidPathCharsArray + ? PathEx.CrossPlatformInvalidPathChars : Path.GetInvalidPathChars(); public static string EscapeFileName(string fileName, bool crossPlatform = true) { - var invalidChars = crossPlatform - ? CrossPlatformInvalidFileNameChars - : new HashSet(Path.GetInvalidFileNameChars()); - + var invalidChars = new HashSet(Path.GetInvalidFileNameChars(crossPlatform)); var buffer = new StringBuilder(fileName.Length); foreach (var ch in fileName) From d81f728d8585363c4e3399989b8646f3594b0e36 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:41:29 +0300 Subject: [PATCH 43/43] Update PathExtensions.cs --- PowerKit/Extensions/PathExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PowerKit/Extensions/PathExtensions.cs b/PowerKit/Extensions/PathExtensions.cs index 5ae9962..003ddc7 100644 --- a/PowerKit/Extensions/PathExtensions.cs +++ b/PowerKit/Extensions/PathExtensions.cs @@ -12,7 +12,7 @@ file static class PathEx // the OS-specific Path.GetInvalidFileNameChars() returns. // This is useful when working with files that may be accessed from // different operating systems, such as NTFS drives on Linux. - private static readonly char[] CrossPlatformInvalidFileNameChars = + public static readonly char[] CrossPlatformInvalidFileNameChars = [ '\0', // Null character - invalid on all filesystems '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', // ASCII control characters - @@ -32,7 +32,7 @@ file static class PathEx // Path chars are the same as file name chars, except path separators // and the colon (drive letter separator) are valid in paths. - private static readonly char[] CrossPlatformInvalidPathChars = + public static readonly char[] CrossPlatformInvalidPathChars = CrossPlatformInvalidFileNameChars .Where(ch => ch != '/' && ch != '\\' && ch != ':') .ToArray();