diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b43ddf6e..8c2061c0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" groups: actions-deps: patterns: @@ -19,7 +19,7 @@ updates: - package-ecosystem: "nuget" directory: "/" schedule: - interval: "daily" + interval: "weekly" target-branch: "develop" open-pull-requests-limit: 1 groups: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f807c2e5..f9e60049 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - name: Create packages run: dotnet pack --configuration Release --output ./packages - name: Upload a Build Artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: NuGet packages path: packages/*.* @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v5 - - uses: actions/download-artifact@v6 + - uses: actions/download-artifact@v7 with: name: NuGet packages path: packages diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6b92982..c73c4fab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,7 @@ jobs: dotnet-version: | 6.0.x 8.0.x - 9.0.x 10.0.x - name: Build & Test in Release Mode - run: dotnet test --configuration Release --logger "GitHubActions" \ No newline at end of file + run: dotnet test --configuration Release --report-github \ No newline at end of file diff --git a/CommonUtilities.slnx b/CommonUtilities.slnx index da8c736f..09d7c975 100644 --- a/CommonUtilities.slnx +++ b/CommonUtilities.slnx @@ -19,5 +19,5 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index 9fa329ed..b80be57f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,20 @@ $(MSBuildThisFileDirectory) - preview - disable true true $(MSBuildThisFileDirectory) $(RepoRootPath)bin\Packages\$(Configuration)\ - .NET Common Utilities - Copyright © AnakinRaW 2025 + preview + disable + enable + true + + + AnakinRaW Common Utilities + Copyright © AnakinRaW 2026 AnakinRaW AnakinRaW https://github.com/AnakinRaW/CommonUtilities @@ -19,8 +23,7 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/LICENSE b/LICENSE index e37e0b34..cdb952d1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 AnakinRaW +Copyright (c) 2026 AnakinRaW Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/global.json b/global.json new file mode 100644 index 00000000..8f73781c --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/src/CommonUtilities.DownloadManager.csproj b/src/CommonUtilities.DownloadManager/src/CommonUtilities.DownloadManager.csproj index b3c74e58..059279dc 100644 --- a/src/CommonUtilities.DownloadManager/src/CommonUtilities.DownloadManager.csproj +++ b/src/CommonUtilities.DownloadManager/src/CommonUtilities.DownloadManager.csproj @@ -1,16 +1,17 @@ - + - Simple Download Manager supporting the local file system and HTTP downlaods. + CommonUtilities.DownloadManager + AnakinRaW.CommonUtilities.DownloadManager + Simple DownloadAsync Manager supporting the local file system and HTTP downlaods. - netstandard2.0;netstandard2.1;net9.0 + true + netstandard2.0;netstandard2.1;net10.0 AnakinRaW.CommonUtilities.DownloadManager AnakinRaW.CommonUtilities.DownloadManager - enable - True - true + en @@ -21,6 +22,11 @@ true + + + + + all @@ -30,13 +36,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + diff --git a/src/CommonUtilities.DownloadManager/src/DownloadFailedException.cs b/src/CommonUtilities.DownloadManager/src/DownloadFailedException.cs index bf5be327..c58d51ae 100644 --- a/src/CommonUtilities.DownloadManager/src/DownloadFailedException.cs +++ b/src/CommonUtilities.DownloadManager/src/DownloadFailedException.cs @@ -38,7 +38,7 @@ public override string Message /// Initializes a new instance of the class from the specified download failures. /// /// The failures which occurred during a file download. - public DownloadFailedException(IEnumerable downloadFailures) + internal DownloadFailedException(IEnumerable downloadFailures) { DownloadFailures = downloadFailures; } diff --git a/src/CommonUtilities.DownloadManager/src/DownloadKind.cs b/src/CommonUtilities.DownloadManager/src/DownloadKind.cs index e50550cd..932d1f2a 100644 --- a/src/CommonUtilities.DownloadManager/src/DownloadKind.cs +++ b/src/CommonUtilities.DownloadManager/src/DownloadKind.cs @@ -14,5 +14,5 @@ public enum DownloadKind /// /// The provider supports downloading files from the Internet. /// - Internet, + Internet } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/src/DownloadManager.cs b/src/CommonUtilities.DownloadManager/src/DownloadManager.cs index 55f7b156..8dd40c72 100644 --- a/src/CommonUtilities.DownloadManager/src/DownloadManager.cs +++ b/src/CommonUtilities.DownloadManager/src/DownloadManager.cs @@ -29,7 +29,7 @@ public sealed class DownloadManager : IDownloadManager /// /// Initializes a new instance of the class. /// - /// + /// The service provider. public DownloadManager(IServiceProvider serviceProvider) : this(DownloadManagerConfiguration.Default, serviceProvider) { @@ -172,7 +172,7 @@ private async Task DownloadWithRetry( bool validationSuccess; try { - validationSuccess = await validator.Validate(outputStream, summary.DownloadedSize, cancellationToken) + validationSuccess = await validator.ValidateAsync(outputStream, summary.DownloadedSize, cancellationToken) .ConfigureAwait(false); } catch (Exception e) diff --git a/src/CommonUtilities.DownloadManager/src/LeastRecentlyUsedDownloadProviders.cs b/src/CommonUtilities.DownloadManager/src/LeastRecentlyUsedDownloadProviders.cs index 9af186b6..16d5a4cd 100644 --- a/src/CommonUtilities.DownloadManager/src/LeastRecentlyUsedDownloadProviders.cs +++ b/src/CommonUtilities.DownloadManager/src/LeastRecentlyUsedDownloadProviders.cs @@ -27,6 +27,7 @@ public string? LastSuccessfulProvider if (value == null) return; field = value; + // ReSharper disable once InconsistentlySynchronizedField _preferredProviders.AddOrUpdate(value, 1, (_, existingVal) => ++existingVal); } } diff --git a/src/CommonUtilities.DownloadManager/src/Providers/HttpClientDownloader.cs b/src/CommonUtilities.DownloadManager/src/Providers/HttpClientDownloader.cs index fc2609d9..ef78a70d 100644 --- a/src/CommonUtilities.DownloadManager/src/Providers/HttpClientDownloader.cs +++ b/src/CommonUtilities.DownloadManager/src/Providers/HttpClientDownloader.cs @@ -43,8 +43,8 @@ protected override async Task DownloadAsyncCore( { if (response is not null) { -#if NET - await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +#if NET || NETSTANDARD2_1_OR_GREATER + await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); #else using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); #endif diff --git a/src/CommonUtilities.DownloadManager/src/Providers/WebClientDownloader.cs b/src/CommonUtilities.DownloadManager/src/Providers/WebClientDownloader.cs index 4031c19f..9bf6836e 100644 --- a/src/CommonUtilities.DownloadManager/src/Providers/WebClientDownloader.cs +++ b/src/CommonUtilities.DownloadManager/src/Providers/WebClientDownloader.cs @@ -68,7 +68,7 @@ protected override async Task DownloadAsyncCore( try { summary.DownloadedSize = await StreamUtilities.CopyStreamWithProgressAsync( - responseStream, + responseStream!, totalStreamLength, outputStream, progress, diff --git a/src/CommonUtilities.DownloadManager/src/Validation/AlwaysValidDownloadValidator.cs b/src/CommonUtilities.DownloadManager/src/Validation/AlwaysValidDownloadValidator.cs index 3eac4b98..3bcdf862 100644 --- a/src/CommonUtilities.DownloadManager/src/Validation/AlwaysValidDownloadValidator.cs +++ b/src/CommonUtilities.DownloadManager/src/Validation/AlwaysValidDownloadValidator.cs @@ -19,7 +19,7 @@ private AlwaysValidDownloadValidator() } /// - public Task Validate(Stream stream, long downloadedBytes, CancellationToken token = default) + public Task ValidateAsync(Stream stream, long downloadedBytes, CancellationToken token = default) { return Task.FromResult(true); } diff --git a/src/CommonUtilities.DownloadManager/src/Validation/HashDownloadValidator.cs b/src/CommonUtilities.DownloadManager/src/Validation/HashDownloadValidator.cs index 636952e5..f29d196f 100644 --- a/src/CommonUtilities.DownloadManager/src/Validation/HashDownloadValidator.cs +++ b/src/CommonUtilities.DownloadManager/src/Validation/HashDownloadValidator.cs @@ -37,7 +37,7 @@ public HashDownloadValidator(byte[]? hash, HashTypeKey hashType, IServiceProvide } /// - public async Task Validate(Stream stream, long downloadedBytes, CancellationToken token = default) + public async Task ValidateAsync(Stream stream, long downloadedBytes, CancellationToken token = default) { if (stream == null) throw new ArgumentNullException(nameof(stream)); diff --git a/src/CommonUtilities.DownloadManager/src/Validation/IDownloadValidator.cs b/src/CommonUtilities.DownloadManager/src/Validation/IDownloadValidator.cs index 9d8f626f..24c231f5 100644 --- a/src/CommonUtilities.DownloadManager/src/Validation/IDownloadValidator.cs +++ b/src/CommonUtilities.DownloadManager/src/Validation/IDownloadValidator.cs @@ -19,5 +19,5 @@ public interface IDownloadValidator /// The number of bytes downloaded. /// The cancellation token. /// if the download is valid; otherwise, . - Task Validate(Stream stream, long downloadedBytes, CancellationToken token = default); + Task ValidateAsync(Stream stream, long downloadedBytes, CancellationToken token = default); } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/src/Validation/SizeDownloadValidator.cs b/src/CommonUtilities.DownloadManager/src/Validation/SizeDownloadValidator.cs index ace67457..4b22c4e4 100644 --- a/src/CommonUtilities.DownloadManager/src/Validation/SizeDownloadValidator.cs +++ b/src/CommonUtilities.DownloadManager/src/Validation/SizeDownloadValidator.cs @@ -21,7 +21,7 @@ public SizeDownloadValidator(long expectedDownloadBytes) } /// - public Task Validate(Stream stream, long downloadedBytes, CancellationToken token = default) + public Task ValidateAsync(Stream stream, long downloadedBytes, CancellationToken token = default) { return Task.FromResult(_expectedDownloadBytes == downloadedBytes); } diff --git a/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj b/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj index 4ead415b..f91c1181 100644 --- a/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj +++ b/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj @@ -1,10 +1,11 @@ - + net10.0;net8.0 $(TargetFrameworks);net481 false true + Exe @@ -12,20 +13,17 @@ AnakinRaW.CommonUtilities.DownloadManager.Test - - enable - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -37,8 +35,12 @@ - + + + + + \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/test/DownloadManagerTest.cs b/src/CommonUtilities.DownloadManager/test/DownloadManagerTest.cs index 58cb97b9..e02a0225 100644 --- a/src/CommonUtilities.DownloadManager/test/DownloadManagerTest.cs +++ b/src/CommonUtilities.DownloadManager/test/DownloadManagerTest.cs @@ -15,7 +15,7 @@ namespace AnakinRaW.CommonUtilities.DownloadManager.Test; -public class DownloadManagerTest : CommonTestBase +public class DownloadManagerTest : TestBaseWithFileSystem { private const string Destination = "file.txt"; @@ -464,7 +464,7 @@ void ProgressMethod(DownloadUpdate status) private class ThrowingValidator : IDownloadValidator { - public Task Validate(Stream stream, long downloadedBytes, CancellationToken token = default) + public Task ValidateAsync(Stream stream, long downloadedBytes, CancellationToken token = default) { throw new Exception("Test"); } diff --git a/src/CommonUtilities.DownloadManager/test/LeastRecentlyUsedDownloadProvidersTest.cs b/src/CommonUtilities.DownloadManager/test/LeastRecentlyUsedDownloadProvidersTest.cs index d0f7a09c..f8ed92d4 100644 --- a/src/CommonUtilities.DownloadManager/test/LeastRecentlyUsedDownloadProvidersTest.cs +++ b/src/CommonUtilities.DownloadManager/test/LeastRecentlyUsedDownloadProvidersTest.cs @@ -6,7 +6,7 @@ namespace AnakinRaW.CommonUtilities.DownloadManager.Test; -public class LeastRecentlyUsedDownloadProvidersTest : CommonTestBase +public class LeastRecentlyUsedDownloadProvidersTest : TestBaseWithFileSystem { private readonly LeastRecentlyUsedDownloadProviders _provider = new(); diff --git a/src/CommonUtilities.DownloadManager/test/Providers/DownloadProviderTestBase.cs b/src/CommonUtilities.DownloadManager/test/Providers/DownloadProviderTestBase.cs index 1458a7db..a155f998 100644 --- a/src/CommonUtilities.DownloadManager/test/Providers/DownloadProviderTestBase.cs +++ b/src/CommonUtilities.DownloadManager/test/Providers/DownloadProviderTestBase.cs @@ -8,7 +8,7 @@ namespace AnakinRaW.CommonUtilities.DownloadManager.Test.Providers; -public abstract class DownloadProviderTestBase : CommonTestBase +public abstract class DownloadProviderTestBase : TestBaseWithFileSystem { protected abstract Type ExpectedSourceNotFoundExceptionType { get; } @@ -20,7 +20,7 @@ public abstract class DownloadProviderTestBase : CommonTestBase public async Task DownloadAsync_SourceNotFound_Throws() { var source = CreateSource(false); - await Assert.ThrowsAsync(ExpectedSourceNotFoundExceptionType, async () => await Download(source, new MemoryStream(), null)); + await Assert.ThrowsAsync(ExpectedSourceNotFoundExceptionType, async () => await TestDownloadAsync(source, new MemoryStream(), null, TestContext.Current.CancellationToken)); } [Theory] @@ -32,7 +32,7 @@ public async Task DownloadAsync(bool createDefaultOptions) var source = CreateSource(true); var options = createDefaultOptions ? new DownloadOptions() : null; - var result = await Download(source, outStream, options); + var result = await TestDownloadAsync(source, outStream, options, TestContext.Current.CancellationToken); Assert.True(result.DownloadedSize > 0); Assert.Equal(result.DownloadedSize, outStream.Length); } @@ -45,10 +45,10 @@ public async Task DownloadAsync_DownloadCancelled_Throws(bool createDefaultOptio var outStream = new MemoryStream(); var source = CreateSource(true); var options = createDefaultOptions ? new DownloadOptions() : null; - await Assert.ThrowsAnyAsync(async () => await Download(source, outStream, options, new CancellationToken(true))); + await Assert.ThrowsAnyAsync(async () => await TestDownloadAsync(source, outStream, options, new CancellationToken(true))); } - protected async Task Download(Uri source, Stream outStream, DownloadOptions? options, CancellationToken token = default) + protected async Task TestDownloadAsync(Uri source, Stream outStream, DownloadOptions? options, CancellationToken token = default) { var provider = CreateProvider(); @@ -65,7 +65,7 @@ protected async Task Download(Uri source, Stream outStream, Down void Callback(DownloadUpdate status) { - Task.Delay(100).Wait(); + Task.Delay(100, TestContext.Current.CancellationToken).Wait(TestContext.Current.CancellationToken); callBackFired = true; } } diff --git a/src/CommonUtilities.DownloadManager/test/Providers/FileDownloadTest.cs b/src/CommonUtilities.DownloadManager/test/Providers/FileDownloadTest.cs index 16b71c67..6b1f4a7b 100644 --- a/src/CommonUtilities.DownloadManager/test/Providers/FileDownloadTest.cs +++ b/src/CommonUtilities.DownloadManager/test/Providers/FileDownloadTest.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; using AnakinRaW.CommonUtilities.DownloadManager.Providers; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Xunit; namespace AnakinRaW.CommonUtilities.DownloadManager.Test.Providers; @@ -41,7 +41,7 @@ public async Task DownloadAsync_ExpectedData() var source = CreateSource(true); var outStream = new MemoryStream(); - var result = await Download(source, outStream, null, CancellationToken.None); + var result = await TestDownloadAsync(source, outStream, null, CancellationToken.None); Assert.Equal(Data.Length, result.DownloadedSize); var copyData = Encoding.Default.GetString(outStream.ToArray()); @@ -53,7 +53,7 @@ public async Task DownloadAsync_UncPath() { var source = new Uri("file://server/test.file"); Assert.True(source.IsUnc); - await Assert.ThrowsAsync(ExpectedSourceNotFoundExceptionType, async () => await Download(source, new MemoryStream(), null)); + await Assert.ThrowsAsync(ExpectedSourceNotFoundExceptionType, async () => await TestDownloadAsync(source, new MemoryStream(), null)); } [Theory] @@ -65,6 +65,6 @@ public async Task DownloadAsync_NotAFileSource_Throws(string uri) { var source = new Uri(uri); var outStream = new MemoryStream(); - await Assert.ThrowsAsync(async () => await Download(source, outStream, null, CancellationToken.None)); + await Assert.ThrowsAsync(async () => await TestDownloadAsync(source, outStream, null, CancellationToken.None)); } } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/test/Providers/InternetDownloadTest.cs b/src/CommonUtilities.DownloadManager/test/Providers/InternetDownloadTest.cs index 6dbf15ac..6cb5d536 100644 --- a/src/CommonUtilities.DownloadManager/test/Providers/InternetDownloadTest.cs +++ b/src/CommonUtilities.DownloadManager/test/Providers/InternetDownloadTest.cs @@ -28,7 +28,7 @@ public async Task DownloadAsync_WithUserAgent() UserAgent = "AnakinRaw.DownloadManager.Test" }; - var result = await Download(source, outStream, options); + var result = await TestDownloadAsync(source, outStream, options, TestContext.Current.CancellationToken); Assert.True(result.DownloadedSize > 0); Assert.Equal(result.DownloadedSize, outStream.Length); } @@ -39,7 +39,7 @@ public async Task DownloadAsync_RequiredUserAgentNotSet_Throws() var outStream = new MemoryStream(); var source = new Uri("https://api.github.com/repos/AnakinRaW/CommonUtilities/releases/latest"); - var exception = await Assert.ThrowsAnyAsync(async () => await Download(source, outStream, null)); + var exception = await Assert.ThrowsAnyAsync(async () => await TestDownloadAsync(source, outStream, null, TestContext.Current.CancellationToken)); AssertRequiredUserAgentMissingException(exception); } } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/test/StreamUtilitiesTest.cs b/src/CommonUtilities.DownloadManager/test/StreamUtilitiesTest.cs index febcb410..0c5bd1a6 100644 --- a/src/CommonUtilities.DownloadManager/test/StreamUtilitiesTest.cs +++ b/src/CommonUtilities.DownloadManager/test/StreamUtilitiesTest.cs @@ -30,8 +30,9 @@ public async Task CopyStreamWithProgressAsync_StreamLengthAndCorrectCopy() Assert.Equal(3, bytesRead); output.Seek(0, SeekOrigin.Begin); var outputData = new byte[3]; - output.Read(outputData, 0, 3); + var n = output.Read(outputData, 0, 3); Assert.Equal(inputData, outputData); + Assert.Equal(3, n); } [Fact] diff --git a/src/CommonUtilities.DownloadManager/test/Validation/AlwaysValidDownloadValidatorTest.cs b/src/CommonUtilities.DownloadManager/test/Validation/AlwaysValidDownloadValidatorTest.cs index dd5b10ea..72bcc79a 100644 --- a/src/CommonUtilities.DownloadManager/test/Validation/AlwaysValidDownloadValidatorTest.cs +++ b/src/CommonUtilities.DownloadManager/test/Validation/AlwaysValidDownloadValidatorTest.cs @@ -11,7 +11,7 @@ public class AlwaysValidDownloadValidatorTest public async Task Validate_IsValid_NullStream_CancelledToken_NegativeBytes() { var validator = AlwaysValidDownloadValidator.Instance; - var result = await validator.Validate(null!, -1, new CancellationToken(true)); + var result = await validator.ValidateAsync(null!, -1, new CancellationToken(true)); Assert.True(result); } } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/test/Validation/HashDownloadValidatorTest.cs b/src/CommonUtilities.DownloadManager/test/Validation/HashDownloadValidatorTest.cs index 1b83269c..013ccc0b 100644 --- a/src/CommonUtilities.DownloadManager/test/Validation/HashDownloadValidatorTest.cs +++ b/src/CommonUtilities.DownloadManager/test/Validation/HashDownloadValidatorTest.cs @@ -13,7 +13,7 @@ namespace AnakinRaW.CommonUtilities.DownloadManager.Test.Validation; -public class HashDownloadValidatorTest : CommonTestBase +public class HashDownloadValidatorTest : TestBaseWithFileSystem { protected override void SetupServices(IServiceCollection serviceCollection) { @@ -76,14 +76,14 @@ public void Ctor_InvalidateCorrect(HashTypeKey type, byte[] data) public async Task Validate_NullStream_Throws() { var validator = new HashDownloadValidator(null, HashTypeKey.None, ServiceProvider); - await Assert.ThrowsAsync(async () => await validator.Validate(null!, 0)); + await Assert.ThrowsAsync(async () => await validator.ValidateAsync(null!, 0, TestContext.Current.CancellationToken)); } [Fact] public async Task Validate_NoneHashType() { var validator = new HashDownloadValidator(null, HashTypeKey.None, ServiceProvider); - var result = await validator.Validate(new MemoryStream(new byte[3]), 0); + var result = await validator.ValidateAsync(new MemoryStream(new byte[3]), 0, TestContext.Current.CancellationToken); Assert.True(result); } @@ -92,7 +92,7 @@ public async Task Validate_StreamNotSeekable_ThrowsNotSupportedException() { var validator = new HashDownloadValidator(null, HashTypeKey.None, ServiceProvider); await Assert.ThrowsAsync(async () => - await validator.Validate(new NonSeekableStream(), 0)); + await validator.ValidateAsync(new NonSeekableStream(), 0, TestContext.Current.CancellationToken)); } [Theory] @@ -110,7 +110,7 @@ public async Task Validate_HashesToNotMatch(HashTypeKey hashType, byte[] notExpe // notExpectedHash is always empty var validator = new HashDownloadValidator(notExpectedHash, hashType, ServiceProvider); - var result = await validator.Validate(dlStream, actualDownloadedBytes); + var result = await validator.ValidateAsync(dlStream, actualDownloadedBytes, TestContext.Current.CancellationToken); Assert.False(result); } @@ -138,7 +138,7 @@ public async Task Validate_HashesMatch(HashTypeKey hashType, string expectedHash var expectedHash = ConvertHexStringToByteArray(expectedHashString); var validator = new HashDownloadValidator(expectedHash, hashType, ServiceProvider); - var result = await validator.Validate(dlStream, actualDownloadedBytes); + var result = await validator.ValidateAsync(dlStream, actualDownloadedBytes, TestContext.Current.CancellationToken); Assert.True(result); if (hashType != HashTypeKey.None) @@ -163,37 +163,18 @@ public static byte[] ConvertHexStringToByteArray(string hexString) } - class NonSeekableStream : Stream + private class NonSeekableStream : Stream { - public override void Flush() - { - throw new NotImplementedException(); - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotImplementedException(); - } - - public override void SetLength(long value) - { - throw new NotImplementedException(); - } - - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotImplementedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotImplementedException(); - } - public override bool CanRead => false; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => 0; public override long Position { get; set; } + + public override void Flush() => throw new NotImplementedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotImplementedException(); + public override void SetLength(long value) => throw new NotImplementedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/CommonUtilities.DownloadManager/test/Validation/SizeDownloadValidatorTest.cs b/src/CommonUtilities.DownloadManager/test/Validation/SizeDownloadValidatorTest.cs index af22cff5..d50f8ac4 100644 --- a/src/CommonUtilities.DownloadManager/test/Validation/SizeDownloadValidatorTest.cs +++ b/src/CommonUtilities.DownloadManager/test/Validation/SizeDownloadValidatorTest.cs @@ -10,7 +10,7 @@ public class SizeDownloadValidatorTest public async Task Validate_IsValid() { var validator = new SizeDownloadValidator(123); - var result = await validator.Validate(null!, 123); + var result = await validator.ValidateAsync(null!, 123, TestContext.Current.CancellationToken); Assert.True(result); } @@ -20,7 +20,7 @@ public async Task Validate_IsValid() public async Task Validate_IsInvalid(int actualValue) { var validator = new SizeDownloadValidator(123); - var result = await validator.Validate(null!, actualValue); + var result = await validator.ValidateAsync(null!, actualValue, TestContext.Current.CancellationToken); Assert.False(result); } } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Commonutilities.FileSystem.csproj b/src/CommonUtilities.FileSystem/src/Commonutilities.FileSystem.csproj index 06387b7c..ac6a9e5d 100644 --- a/src/CommonUtilities.FileSystem/src/Commonutilities.FileSystem.csproj +++ b/src/CommonUtilities.FileSystem/src/Commonutilities.FileSystem.csproj @@ -1,15 +1,17 @@ - + + CommonUtilities.FileSystem + AnakinRaW.CommonUtilities.FileSystem Helper classes and methods targeting the file system. - netstandard2.0;netstandard2.1;net9.0 + true + netstandard2.0;netstandard2.1;net10.0 AnakinRaW.CommonUtilities.FileSystem AnakinRaW.CommonUtilities.FileSystem - enable - True + en @@ -22,18 +24,25 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/CommonUtilities.FileSystem/src/CompilerServices/CallerArgumentExpressionAttribute.cs b/src/CommonUtilities.FileSystem/src/CompilerServices/CallerArgumentExpressionAttribute.cs index 4d6a90ea..33eb37f8 100644 --- a/src/CommonUtilities.FileSystem/src/CompilerServices/CallerArgumentExpressionAttribute.cs +++ b/src/CommonUtilities.FileSystem/src/CompilerServices/CallerArgumentExpressionAttribute.cs @@ -1,5 +1,7 @@ #if !NET +#pragma warning disable IDE0130 namespace System.Runtime.CompilerServices; +#pragma warning restore IDE0130 [AttributeUsage(AttributeTargets.Parameter)] internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute diff --git a/src/CommonUtilities.FileSystem/src/Extensions/FileExtensions.cs b/src/CommonUtilities.FileSystem/src/Extensions/FileExtensions.cs index 6fdcfb5f..18d69ba3 100644 --- a/src/CommonUtilities.FileSystem/src/Extensions/FileExtensions.cs +++ b/src/CommonUtilities.FileSystem/src/Extensions/FileExtensions.cs @@ -70,7 +70,7 @@ public static bool MoveToEx(this IFileInfo source, string destination, bool over if (destination == null) throw new ArgumentNullException(nameof(destination)); - return MoveEx(source.FileSystem.File, source.FullName, destination, overwrite); + return source.FileSystem.File.MoveEx(source.FullName, destination, overwrite); } /// diff --git a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.Join.cs b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.Join.cs index cefac2de..a056de79 100644 --- a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.Join.cs +++ b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.Join.cs @@ -110,11 +110,11 @@ public static string Join(this IPath _, ReadOnlySpan path1, ReadOnlySpan path1, ReadOnlySpan path2, ReadOnlySpan path3) { if (path1.Length == 0) - return Join(_, path2, path3); + return _.Join(path2, path3); if (path2.Length == 0) - return Join(_, path1, path3); + return _.Join(path1, path3); if (path3.Length == 0) - return Join(_, path1, path2); + return _.Join(path1, path2); return JoinInternal(path1, path2, path3); } @@ -187,13 +187,13 @@ public static string Join(this IPath _, ReadOnlySpan path1, ReadOnlySpan path1, ReadOnlySpan path2, ReadOnlySpan path3, ReadOnlySpan path4) { if (path1.Length == 0) - return Join(_, path2, path3, path4); + return _.Join(path2, path3, path4); if (path2.Length == 0) - return Join(_, path1, path3, path4); + return _.Join(path1, path3, path4); if (path3.Length == 0) - return Join(_, path1, path2, path4); + return _.Join(path1, path2, path4); if (path4.Length == 0) - return Join(_, path1, path2, path3); + return _.Join(path1, path2, path3); return JoinInternal(path1, path2, path3, path4); } @@ -266,7 +266,7 @@ public static string Join(this IPath _, ReadOnlySpan path1, ReadOnlySpan)paths); + return _.Join((ReadOnlySpan)paths); } /// @@ -342,11 +342,11 @@ public static bool TryJoin( return true; if (path1.Length == 0) - return TryJoin(_, path2, path3, destination, out charsWritten); + return _.TryJoin(path2, path3, destination, out charsWritten); if (path2.Length == 0) - return TryJoin(_, path1, path3, destination, out charsWritten); + return _.TryJoin(path1, path3, destination, out charsWritten); if (path3.Length == 0) - return TryJoin(_, path1, path2, destination, out charsWritten); + return _.TryJoin(path1, path2, destination, out charsWritten); var neededSeparators = HasTrailingDirectorySeparator(path1) || HasLeadingDirectorySeparator(path2) ? 0 : 1; var needsSecondSeparator = !(HasTrailingDirectorySeparator(path2) || HasLeadingDirectorySeparator(path3)); @@ -357,7 +357,7 @@ public static bool TryJoin( if (destination.Length < charsNeeded) return false; - var result = TryJoin(_, path1, path2, destination, out charsWritten); + var result = _.TryJoin(path1, path2, destination, out charsWritten); Debug.Assert(result, "should never fail joining first two paths"); if (needsSecondSeparator) diff --git a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs index 28bac173..e671209d 100644 --- a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs +++ b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs @@ -184,7 +184,7 @@ public static bool IsPathFullyQualified(this IPath _, string path) { if (path == null) throw new ArgumentNullException(nameof(path)); - return IsPathFullyQualified(_, path.AsSpan()); + return _.IsPathFullyQualified(path.AsSpan()); } /// diff --git a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs index bc0aba10..439261bd 100644 --- a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs +++ b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs @@ -135,8 +135,6 @@ public static bool IsDriveRelative(this IPath fsPath, ReadOnlySpan path, [ driveLetter = path[0]; return true; } - - return false; } return false; } diff --git a/src/CommonUtilities.FileSystem/src/FileSystemUtilities.cs b/src/CommonUtilities.FileSystem/src/FileSystemUtilities.cs index f9d2f73b..2dc0ade0 100644 --- a/src/CommonUtilities.FileSystem/src/FileSystemUtilities.cs +++ b/src/CommonUtilities.FileSystem/src/FileSystemUtilities.cs @@ -19,7 +19,7 @@ public static class FileSystemUtilities /// When set to , if all retries are unsuccessful the causing exception will be thrown. /// Callback which gets invoked if an /// or is was thrown during the execution. - /// if the operation was successful. otherwise. + /// if the operation was successful; otherwise, . /// is . public static bool ExecuteFileSystemActionWithRetry(int retryCount, int retryDelay, Action fileAction, bool throwOnFailure = true, Func? errorAction = null) diff --git a/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs b/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs index 8c1f3e30..bb99d03d 100644 --- a/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs +++ b/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs @@ -6,7 +6,7 @@ namespace AnakinRaW.CommonUtilities.FileSystem.Normalization; /// -/// Enables customized path normalization. +/// Provides methods for normalizing file system paths according to specified rules. /// public static class PathNormalizer { diff --git a/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs b/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs index 88952b52..ffc9445b 100644 --- a/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs +++ b/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs @@ -1,12 +1,14 @@ using System; using System.Buffers; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace AnakinRaW.CommonUtilities.FileSystem.Utilities; // Copied from https://github.com/dotnet/runtime +[DebuggerDisplay("{DebuggerDisplay,nq}")] internal ref struct ValueStringBuilder { private char[]? _arrayToReturnToPool; @@ -69,8 +71,8 @@ public ref char GetPinnableReference(bool terminate) { if (terminate) { - EnsureCapacity(Length + 1); - _chars[Length] = '\0'; + EnsureCapacity(_pos + 1); + _chars[_pos] = '\0'; } return ref MemoryMarshal.GetReference(_chars); } @@ -84,6 +86,11 @@ public ref char this[int index] } } + // ToString() clears the builder, so we need a side-effect free debugger display. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + [ExcludeFromCodeCoverage] + private string DebuggerDisplay => AsSpan().ToString(); + public override string ToString() { var s = _chars.Slice(0, _pos).ToString(); @@ -102,8 +109,8 @@ public ReadOnlySpan AsSpan(bool terminate) { if (terminate) { - EnsureCapacity(Length + 1); - _chars[Length] = '\0'; + EnsureCapacity(_pos + 1); + _chars[_pos] = '\0'; } return _chars.Slice(0, _pos); } diff --git a/src/CommonUtilities.FileSystem/src/Validation/CurrentSystemFileNameValidator.cs b/src/CommonUtilities.FileSystem/src/Validation/CurrentSystemFileNameValidator.cs index 3a675746..d07cb6ce 100644 --- a/src/CommonUtilities.FileSystem/src/Validation/CurrentSystemFileNameValidator.cs +++ b/src/CommonUtilities.FileSystem/src/Validation/CurrentSystemFileNameValidator.cs @@ -12,6 +12,10 @@ public sealed class CurrentSystemFileNameValidator : FileNameValidator /// Returns a singleton instance of the class. /// public static readonly CurrentSystemFileNameValidator Instance = new(); + + private readonly FileNameValidator _innerValidator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsFileNameValidator.Instance + : LinuxFileNameValidator.Instance; private CurrentSystemFileNameValidator() { @@ -20,8 +24,6 @@ private CurrentSystemFileNameValidator() /// public override FileNameValidationResult IsValidFileName(ReadOnlySpan fileName) { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? WindowsFileNameValidator.Instance.IsValidFileName(fileName) - : LinuxFileNameValidator.Instance.IsValidFileName(fileName); + return _innerValidator.IsValidFileName(fileName); } } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Validation/FileNameValidator.cs b/src/CommonUtilities.FileSystem/src/Validation/FileNameValidator.cs index 6ba24aa5..81b3883c 100644 --- a/src/CommonUtilities.FileSystem/src/Validation/FileNameValidator.cs +++ b/src/CommonUtilities.FileSystem/src/Validation/FileNameValidator.cs @@ -21,5 +21,6 @@ public FileNameValidationResult IsValidFileName(string? fileName) /// Checks whether a string represent a valid file name /// /// The string to validate. + /// The result of the validation. public abstract FileNameValidationResult IsValidFileName(ReadOnlySpan fileName); } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs b/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs index 480f1777..cf1a1277 100644 --- a/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs +++ b/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs @@ -36,7 +36,7 @@ private WindowsFileNameValidator() /// /// The string to validate. /// Determines whether the check shall include Windows reserved file names (e.g, AUX, LPT1, etc.). - /// + /// if the file name is valid; otherwise, . public FileNameValidationResult IsValidFileName(ReadOnlySpan fileName, bool checkWindowsReservedNames) { if (fileName.Length == 0) diff --git a/src/CommonUtilities.FileSystem/src/Windows/WindowsFileSystemExtensions.cs b/src/CommonUtilities.FileSystem/src/Windows/WindowsFileSystemExtensions.cs index 15a3447a..3c5e3ce1 100644 --- a/src/CommonUtilities.FileSystem/src/Windows/WindowsFileSystemExtensions.cs +++ b/src/CommonUtilities.FileSystem/src/Windows/WindowsFileSystemExtensions.cs @@ -135,7 +135,7 @@ public static bool DeleteWithRetry(this IFileInfo file, out bool rebootRequired, rebootRequired = false; return success; } - rebootRequired = DeleteAfterReboot(file); + rebootRequired = file.DeleteAfterReboot(); return false; } @@ -173,7 +173,7 @@ public static bool DeleteWithRetry(this IDirectoryInfo directory, out bool reboo return success; } - rebootRequired = DeleteAfterReboot(directory); + rebootRequired = directory.DeleteAfterReboot(); return false; } } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Windows/WindowsPathExtensions.cs b/src/CommonUtilities.FileSystem/src/Windows/WindowsPathExtensions.cs index 2cd00d87..040317e9 100644 --- a/src/CommonUtilities.FileSystem/src/Windows/WindowsPathExtensions.cs +++ b/src/CommonUtilities.FileSystem/src/Windows/WindowsPathExtensions.cs @@ -38,7 +38,7 @@ public static bool UserHasDirectoryAccessRights(this IDirectoryInfo directoryInf { if (!directoryInfo.Exists) throw new DirectoryNotFoundException($"Unable to find {directoryInfo.FullName}"); - isInRoleWithAccess = TestAccessRightsOnWindows(directoryInfo, accessRights); + isInRoleWithAccess = directoryInfo.TestAccessRightsOnWindows(accessRights); } catch (UnauthorizedAccessException) { diff --git a/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj b/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj index f98eaedc..9ffe7cdf 100644 --- a/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj +++ b/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj @@ -1,11 +1,11 @@ - + net10.0;net8.0 $(TargetFrameworks);net481 false true - enable + Exe @@ -15,21 +15,21 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - all runtime; build; native; contentfiles; analyzers; buildtransitive - - runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -41,8 +41,12 @@ - + + + + + diff --git a/src/CommonUtilities.FileSystem/test/DirectoryCopierTest.cs b/src/CommonUtilities.FileSystem/test/DirectoryCopierTest.cs index 4249085a..d47770e1 100644 --- a/src/CommonUtilities.FileSystem/test/DirectoryCopierTest.cs +++ b/src/CommonUtilities.FileSystem/test/DirectoryCopierTest.cs @@ -30,15 +30,15 @@ public void MoveDirectory_CopyDirectory_ThrowsArgumentNullExceptions() public async Task MoveDirectoryAsync_CopyDirectoryAsync_ThrowsArgumentNullExceptionsAsync() { var copier = new DirectoryCopier(_fileSystem); - await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync(null!, "path")); - await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", null!)); - await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", "path", null, null, 0)); - await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", "path", null, null, -1)); - - await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync(null!, "path")); - await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", null!)); - await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", "path", null, null, 0)); - await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", "path", null, null, -1)); + await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync(null!, "path", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", null!, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", "path", null, null, 0, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.MoveDirectoryAsync("source", "path", null, null, -1, cancellationToken: TestContext.Current.CancellationToken)); + + await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync(null!, "path", cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", null!, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", "path", null, null, 0, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await copier.CopyDirectoryAsync("source", "path", null, null, -1, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -101,7 +101,7 @@ public async Task CopyDirectoryAsync() // https://github.com/Testably/Testably.Abstractions/issues/549 var fsStream = _fileSystem.FileStream.New("test/1.txt", FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - await copier.CopyDirectoryAsync("test", "other", progress, Exclude2); + await copier.CopyDirectoryAsync("test", "other", progress, Exclude2, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1.0, progressValue); Assert.True(_fileSystem.File.Exists("other/1.txt")); @@ -207,7 +207,7 @@ public async Task MoveDirectoryAsync() }); - var delSuc = await copier.MoveDirectoryAsync("test", "other", progress); + var delSuc = await copier.MoveDirectoryAsync("test", "other", progress, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1.0, progressValue); Assert.True(delSuc); Assert.False(_fileSystem.Directory.Exists("test")); diff --git a/src/CommonUtilities.FileSystem/test/DirectoryInfoExtensionsTest.cs b/src/CommonUtilities.FileSystem/test/DirectoryInfoExtensionsTest.cs index dc93941d..17338f70 100644 --- a/src/CommonUtilities.FileSystem/test/DirectoryInfoExtensionsTest.cs +++ b/src/CommonUtilities.FileSystem/test/DirectoryInfoExtensionsTest.cs @@ -1,7 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions.Testing; using Xunit; @@ -199,7 +199,7 @@ public async Task MoveToAsync_ThrowsDirectoryNotFound() _fileSystem.Initialize(); var dirToMove = _fileSystem.DirectoryInfo.New("test"); await Assert.ThrowsAsync(async () => - await dirToMove.MoveToAsync("test1", null, DirectoryOverwriteOption.NoOverwrite)); + await dirToMove.MoveToAsync("test1", null, DirectoryOverwriteOption.NoOverwrite, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -211,7 +211,7 @@ public async Task MoveToAsync_NoOverwrite_ThrowsIOException() .WithSubdirectory("other"); var dirToMove = _fileSystem.DirectoryInfo.New("test"); - await Assert.ThrowsAsync(async () => await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.NoOverwrite)); + await Assert.ThrowsAsync(async () => await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.NoOverwrite, cancellationToken: TestContext.Current.CancellationToken)); } @@ -228,7 +228,7 @@ public async Task MoveToAsync_CleanOverride() var dirToMove = _fileSystem.DirectoryInfo.New("test"); - var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.CleanOverwrite); + var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.CleanOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.True(delSuc); Assert.False(_fileSystem.Directory.Exists("test")); Assert.True(_fileSystem.Directory.Exists("other")); @@ -252,7 +252,7 @@ public async Task MoveToAsync_MergeOverride() var dirToMove = _fileSystem.DirectoryInfo.New("test"); - var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.MergeOverwrite); + var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.MergeOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.True(delSuc); Assert.False(_fileSystem.Directory.Exists("test")); Assert.True(_fileSystem.Directory.Exists("other")); @@ -289,7 +289,7 @@ public async Task MoveToAsync_WithProgress() { progressValue = d; }); - var delSuc = await dirToMove.MoveToAsync("other", progress, DirectoryOverwriteOption.CleanOverwrite); + var delSuc = await dirToMove.MoveToAsync("other", progress, DirectoryOverwriteOption.CleanOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.True(delSuc); Assert.Equal(1.0, progressValue); @@ -307,7 +307,7 @@ public async Task MoveToAsync_CannotDeleteSource() var fs = _fileSystem.FileStream.New("test/1.txt", FileMode.Open, FileAccess.Read, FileShare.Read); - var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.CleanOverwrite); + var delSuc = await dirToMove.MoveToAsync("other", null, DirectoryOverwriteOption.CleanOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.False(delSuc); fs.Dispose(); @@ -395,7 +395,7 @@ public void Copy_AcrossDrives() public async Task CopyAsync_ThrowsDirectoryNotFound() { var dirToCopy = _fileSystem.DirectoryInfo.New("test"); - await Assert.ThrowsAsync(async () => await dirToCopy.CopyAsync("test1", null, DirectoryOverwriteOption.NoOverwrite)); + await Assert.ThrowsAsync(async () => await dirToCopy.CopyAsync("test1", null, DirectoryOverwriteOption.NoOverwrite, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -408,7 +408,7 @@ public async Task CopyAsync_NoOverwrite_ThrowsIOException() var dirToCopy = _fileSystem.DirectoryInfo.New("test"); - await Assert.ThrowsAsync(async () => await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.NoOverwrite)); + await Assert.ThrowsAsync(async () => await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.NoOverwrite, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -423,7 +423,7 @@ public async Task CopyAsync_CleanOverwrite() var dirToCopy = _fileSystem.DirectoryInfo.New("test"); - await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.CleanOverwrite); + await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.CleanOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.True(_fileSystem.Directory.Exists("test")); Assert.True(_fileSystem.Directory.Exists("other")); Assert.Equal(3, _fileSystem.DirectoryInfo.New("other").GetFiles("*", SearchOption.AllDirectories).Length); @@ -445,7 +445,7 @@ public async Task CopyAsync_MergeOverwrite() var dirToCopy = _fileSystem.DirectoryInfo.New("test"); - await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.MergeOverwrite); + await dirToCopy.CopyAsync("other", null, DirectoryOverwriteOption.MergeOverwrite, cancellationToken: TestContext.Current.CancellationToken); Assert.True(_fileSystem.Directory.Exists("test")); Assert.True(_fileSystem.Directory.Exists("other")); Assert.Equal(5, _fileSystem.DirectoryInfo.New("other").GetFiles("*", SearchOption.AllDirectories).Length); diff --git a/src/CommonUtilities.FileSystem/test/FileInfoExtensionsTest.cs b/src/CommonUtilities.FileSystem/test/FileInfoExtensionsTest.cs index 2c4682b6..c3a8fb91 100644 --- a/src/CommonUtilities.FileSystem/test/FileInfoExtensionsTest.cs +++ b/src/CommonUtilities.FileSystem/test/FileInfoExtensionsTest.cs @@ -1,6 +1,6 @@ using System.IO; using System.Runtime.InteropServices; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions.Testing; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/FileSystemInfoExtensionTest.cs b/src/CommonUtilities.FileSystem/test/FileSystemInfoExtensionTest.cs index 5c0f8c53..d1ccfdd0 100644 --- a/src/CommonUtilities.FileSystem/test/FileSystemInfoExtensionTest.cs +++ b/src/CommonUtilities.FileSystem/test/FileSystemInfoExtensionTest.cs @@ -1,4 +1,4 @@ -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions.Testing; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.AreEqual.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.AreEqual.cs index 2b040fa5..1b454047 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.AreEqual.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.AreEqual.cs @@ -1,6 +1,6 @@ -using AnakinRaW.CommonUtilities.Testing; -using System; +using System; using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs index 0b3f3218..92f3ef97 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; // ReSharper disable InconsistentNaming diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs index 464f5d83..5b13789c 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs @@ -1,7 +1,7 @@ using System; using System.IO; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; // ReSharper disable InconsistentNaming diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs index 3beb813c..a49b3e1a 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs @@ -1,6 +1,6 @@ using System; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Testably.Abstractions.Testing; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasLeadingPathSeparator.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasLeadingPathSeparator.cs index e33c6220..61d863ac 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasLeadingPathSeparator.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasLeadingPathSeparator.cs @@ -1,6 +1,6 @@ using System; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; // ReSharper disable InconsistentNaming diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasTrailingPathSeparator.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasTrailingPathSeparator.cs index 3422769f..831ab599 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasTrailingPathSeparator.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.HasTrailingPathSeparator.cs @@ -1,6 +1,6 @@ using System; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; #if NET diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsChildOf.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsChildOf.cs index 360083da..5e34019f 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsChildOf.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsChildOf.cs @@ -1,6 +1,6 @@ -using AnakinRaW.CommonUtilities.Testing; -using System; +using System; using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions.Testing; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs index c5951ee7..68d1a8b5 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs @@ -1,6 +1,6 @@ -using AnakinRaW.CommonUtilities.Testing; -using System; +using System; using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsPathFullyQualified.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsPathFullyQualified.cs index 71886a34..d7426f2c 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsPathFullyQualified.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsPathFullyQualified.cs @@ -1,6 +1,6 @@ -using AnakinRaW.CommonUtilities.Testing; -using System; +using System; using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; @@ -13,6 +13,7 @@ public class IsPathFullyQualifiedTest [Fact] public void IsPathFullyQualified_NullArgument() { + // ReSharper disable once RedundantCast Assert.Throws(() => _fileSystem.Path.IsPathFullyQualified(((string?)null)!)); } diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.Join.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.Join.cs index 9cafe96b..2fc37d2c 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.Join.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.Join.cs @@ -190,6 +190,8 @@ public void TryJoinThreePaths(string? path1, string? path2, string? path3, strin { "a", null, null, "b", $"a{Sep}b" } }; + // ReSharper disable RedundantExplicitParamsArrayCreation + [Theory, MemberData(nameof(TestData_JoinFourPaths))] public void JoinFourPaths(string? path1, string? path2, string? path3, string? path4, string expected) { @@ -247,4 +249,6 @@ public void JoinStringArray_8(string? path1, string? path2, string? path3, strin Assert.Equal(_fileSystem.Path.Join(fourJoined, fourJoined), _fileSystem.Path.Join((ReadOnlySpan)new[] { path1, path2, path3, path4, path1, path2, path3, path4 })); } + + // ReSharper restore RedundantExplicitParamsArrayCreation } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs b/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs index dbef7e45..8e5db2b4 100644 --- a/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs +++ b/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs @@ -13,6 +13,7 @@ public void Normalize_Throws() { Assert.Throws(() => { + // ReSharper disable once RedundantCast PathNormalizer.Normalize((string)null!, new PathNormalizeOptions()); }); Assert.Throws(() => diff --git a/src/CommonUtilities.FileSystem/test/Windows/WindowsPathServiceTest.cs b/src/CommonUtilities.FileSystem/test/Windows/WindowsPathServiceTest.cs index 0326e748..5c97c38d 100644 --- a/src/CommonUtilities.FileSystem/test/Windows/WindowsPathServiceTest.cs +++ b/src/CommonUtilities.FileSystem/test/Windows/WindowsPathServiceTest.cs @@ -1,7 +1,7 @@ using System.IO; using System.Security.AccessControl; using AnakinRaW.CommonUtilities.FileSystem.Windows; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Testably.Abstractions; using Xunit; #if NET diff --git a/src/CommonUtilities.Registry/src/CommonUtilities.Registry.csproj b/src/CommonUtilities.Registry/src/CommonUtilities.Registry.csproj index bcd076db..e9e6ffb2 100644 --- a/src/CommonUtilities.Registry/src/CommonUtilities.Registry.csproj +++ b/src/CommonUtilities.Registry/src/CommonUtilities.Registry.csproj @@ -1,15 +1,17 @@ - + + CommonUtilities.Registry + AnakinRaW.CommonUtilities.Registry Platform independent abstraction layer for the Windows Registry. - netstandard2.0 + true + netstandard2.0;net10.0 AnakinRaW.CommonUtilities.Registry AnakinRaW.CommonUtilities.Registry - enable - True + en @@ -20,13 +22,20 @@ true + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + diff --git a/src/CommonUtilities.Registry/src/Extensions/RegistryKeyExtensions.cs b/src/CommonUtilities.Registry/src/Extensions/RegistryKeyExtensions.cs index cd90ee8c..fc2ec187 100644 --- a/src/CommonUtilities.Registry/src/Extensions/RegistryKeyExtensions.cs +++ b/src/CommonUtilities.Registry/src/Extensions/RegistryKeyExtensions.cs @@ -62,8 +62,8 @@ private static async Task WaitForRegistryKeyChangeAsync( try { - InMemoryRegistryKeyData.RegistryChanged += OnRegistryChanged; - inMemoryRegistryKey.Disposing += OnKeyDisposing; + InMemoryRegistryKeyData.RegistryChanged += OnRegistryChanged!; + inMemoryRegistryKey.Disposing += OnKeyDisposing!; // Handle potential race when registering the disposed event if (inMemoryRegistryKey.IsDisposed) @@ -73,8 +73,8 @@ private static async Task WaitForRegistryKeyChangeAsync( } finally { - InMemoryRegistryKeyData.RegistryChanged -= OnRegistryChanged; - inMemoryRegistryKey.Disposing -= OnKeyDisposing; + InMemoryRegistryKeyData.RegistryChanged -= OnRegistryChanged!; + inMemoryRegistryKey.Disposing -= OnKeyDisposing!; } return; diff --git a/src/CommonUtilities.Registry/src/InMemoryRegistryChangeKind.cs b/src/CommonUtilities.Registry/src/InMemoryRegistryChangeKind.cs index 195b3966..dc33386d 100644 --- a/src/CommonUtilities.Registry/src/InMemoryRegistryChangeKind.cs +++ b/src/CommonUtilities.Registry/src/InMemoryRegistryChangeKind.cs @@ -4,5 +4,5 @@ internal enum InMemoryRegistryChangeKind { TreeCreate, TreeDelete, - Value, + Value } \ No newline at end of file diff --git a/src/CommonUtilities.Registry/src/InMemoryRegistryChangedEventArgs.cs b/src/CommonUtilities.Registry/src/InMemoryRegistryChangedEventArgs.cs index f0bab56a..4b3d0f60 100644 --- a/src/CommonUtilities.Registry/src/InMemoryRegistryChangedEventArgs.cs +++ b/src/CommonUtilities.Registry/src/InMemoryRegistryChangedEventArgs.cs @@ -2,7 +2,8 @@ namespace AnakinRaW.CommonUtilities.Registry; -internal class InMemoryRegistryChangedEventArgs(InMemoryRegistryKeyData key, InMemoryRegistryChangeKind kind) : EventArgs +internal sealed class InMemoryRegistryChangedEventArgs(InMemoryRegistryKeyData key, InMemoryRegistryChangeKind kind) + : EventArgs { public InMemoryRegistryKeyData KeyData { get; } = key; public InMemoryRegistryChangeKind Kind { get; } = kind; diff --git a/src/CommonUtilities.Registry/src/RegistryKeyBase.cs b/src/CommonUtilities.Registry/src/RegistryKeyBase.cs index a0eefaf4..c11dd1df 100644 --- a/src/CommonUtilities.Registry/src/RegistryKeyBase.cs +++ b/src/CommonUtilities.Registry/src/RegistryKeyBase.cs @@ -1,6 +1,8 @@ using System; using System.Globalization; +#if NETSTANDARD2_0 using System.Linq; +#endif namespace AnakinRaW.CommonUtilities.Registry; @@ -67,7 +69,7 @@ public abstract class RegistryKeyBase : IRegistryKey var nonNullableType = Nullable.GetUnderlyingType(type) ?? type; if (nonNullableType.IsEnum) - return (T)Enum.Parse(nonNullableType, result.ToString(), true); + return (T)Enum.Parse(nonNullableType, result.ToString()!, true); return (T)Convert.ChangeType(result, nonNullableType, CultureInfo.InvariantCulture); } diff --git a/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj b/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj index cb770513..227e8107 100644 --- a/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj +++ b/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj @@ -5,7 +5,7 @@ $(TargetFrameworks);net481 false true - enable + Exe @@ -26,12 +26,13 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -43,8 +44,12 @@ - + + + + + diff --git a/src/CommonUtilities.Registry/test/CompilerHelpers/Attributes.cs b/src/CommonUtilities.Registry/test/CompilerHelpers/Attributes.cs new file mode 100644 index 00000000..e1dc9a46 --- /dev/null +++ b/src/CommonUtilities.Registry/test/CompilerHelpers/Attributes.cs @@ -0,0 +1,45 @@ +#if !NET5_0_OR_GREATER +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming +namespace System.Runtime.Versioning; + +/// +/// Base type for all platform-specific API attributes. +/// + +internal abstract class OSPlatformAttribute(string platformName) : Attribute +{ + public string PlatformName { get; } = platformName; +} + +/// +/// Records the platform that the project targeted. +/// +[AttributeUsage(AttributeTargets.Assembly)] +internal sealed class TargetPlatformAttribute(string platformName) : OSPlatformAttribute(platformName); + +/// +/// Records the operating system (and minimum version) that supports an API. Multiple attributes can be +/// applied to indicate support on multiple operating systems. +/// +/// +/// Callers can apply a +/// or use guards to prevent calls to APIs on unsupported operating systems. +/// +/// A given platform should only be specified once. +/// +[AttributeUsage(AttributeTargets.Assembly | + AttributeTargets.Class | + AttributeTargets.Constructor | + AttributeTargets.Enum | + AttributeTargets.Event | + AttributeTargets.Field | + AttributeTargets.Interface | + AttributeTargets.Method | + AttributeTargets.Module | + AttributeTargets.Property | + AttributeTargets.Struct, + AllowMultiple = true, Inherited = false)] +internal sealed class SupportedOSPlatformAttribute(string platformName) : OSPlatformAttribute(platformName); +// ReSharper restore InconsistentNaming +#endif \ No newline at end of file diff --git a/src/CommonUtilities.Registry/test/Extensions/RegistryKeyExtensionsTestBase.cs b/src/CommonUtilities.Registry/test/Extensions/RegistryKeyExtensionsTestBase.cs index fa8b58da..905ed32a 100644 --- a/src/CommonUtilities.Registry/test/Extensions/RegistryKeyExtensionsTestBase.cs +++ b/src/CommonUtilities.Registry/test/Extensions/RegistryKeyExtensionsTestBase.cs @@ -20,7 +20,7 @@ public abstract class RegistryKeyExtensionsTestBase public async Task AwaitRegKeyChange() { using var test = CreateTestKey(); - var changeWatcherTask = test.Key.WaitForChangeAsync(); + var changeWatcherTask = test.Key.WaitForChangeAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.False(changeWatcherTask.IsCompleted); test.Key.SetValue("a", "b"); await changeWatcherTask; @@ -32,13 +32,13 @@ public async Task AwaitRegKeyChange() public async Task AwaitRegKeyChange_CreateOtherUnrelatedKey_DoesNotNotify() { using var test = CreateTestKey(); - var changeWatcherTask = test.Key.WaitForChangeAsync(); + var changeWatcherTask = test.Key.WaitForChangeAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.False(changeWatcherTask.IsCompleted); using var other = CreateTestKey(); other.Key.CreateSubKey("otherSub"); - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } @@ -46,12 +46,12 @@ public async Task AwaitRegKeyChange_CreateOtherUnrelatedKey_DoesNotNotify() public async Task AwaitRegKeyChange_SubkeyFilterDoesNotNotifyOnValueChanges() { using var test = CreateTestKey(); - var changeWatcherTask = test.Key.WaitForChangeAsync(change: RegistryChangeNotificationFilters.Subkey); + var changeWatcherTask = test.Key.WaitForChangeAsync(change: RegistryChangeNotificationFilters.Subkey, cancellationToken: TestContext.Current.CancellationToken); Assert.False(changeWatcherTask.IsCompleted); test.Key.SetValue("a", "b"); - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } @@ -63,8 +63,8 @@ public async Task AwaitRegKeyChange_TwoAtOnce_SameKeyHandle() try { - var changeWatcherTask1 = test.Key.WaitForChangeAsync(); - var changeWatcherTask2 = test.Key.WaitForChangeAsync(); + var changeWatcherTask1 = test.Key.WaitForChangeAsync(cancellationToken: TestContext.Current.CancellationToken); + var changeWatcherTask2 = test.Key.WaitForChangeAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.False(changeWatcherTask1.IsCompleted); Assert.False(changeWatcherTask2.IsCompleted); @@ -89,7 +89,7 @@ public async Task AwaitRegKeyChange_NoChange() Assert.False(changeWatcherTask.IsCompleted); // Give a bit of time to confirm the task will not complete. - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } @@ -145,7 +145,7 @@ public async Task AwaitRegKeyChange_SelfDeleted_AlwaysNotifies(RegistryChangeNot try { - var changeWatcherTask = subKey.WaitForChangeAsync(watchSubtree: false ,change: filter); + var changeWatcherTask = subKey.WaitForChangeAsync(watchSubtree: false ,change: filter, TestContext.Current.CancellationToken); test.Key.DeleteKey(GetRegistryKeySubName(subKey.Name), false); await changeWatcherTask; } @@ -212,7 +212,7 @@ public async Task AwaitRegKeyChange_SubKeyDeleted_ValueFilterDoesNotNotify(bool var changeWatcherTask = test.Key.WaitForChangeAsync(watchSubtree: watchSubtree, RegistryChangeNotificationFilters.Value, cancellationToken: test.FinishedToken); test.Key.DeleteKey(GetRegistryKeySubName(subKey.Name), false); - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } finally @@ -252,7 +252,7 @@ public async Task AwaitRegKeyChange_SubSubKeyDeleted_NoWatchSubtree() var changeWatcherTask = test.Key.WaitForChangeAsync(watchSubtree: false, cancellationToken: test.FinishedToken); test.Key.DeleteKey("sub\\subsub", false); - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } finally @@ -271,7 +271,8 @@ public async Task AwaitRegKeyChange_ParentKeyDeletedWhileAwaiting() try { // Only watch for value changes, not tree changes, so we don't notify - var changeWatcherTask = subKey.WaitForChangeAsync(watchSubtree: false, RegistryChangeNotificationFilters.Value); + var changeWatcherTask = subKey.WaitForChangeAsync(watchSubtree: false, + RegistryChangeNotificationFilters.Value, cancellationToken: TestContext.Current.CancellationToken); // Delete the parent key test.Key.DeleteKey(string.Empty, true); @@ -300,7 +301,7 @@ public async Task AwaitRegKeyChange_NoWatchSubtree() // We do not expect changes to sub-keys to complete the task, so give a bit of time to confirm // the task doesn't complete. - var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(changeWatcherTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(changeWatcherTask, completedTask); } finally @@ -335,7 +336,7 @@ public async Task AwaitRegKeyChange_KeyDisposedWhileWatching() Task watchingTask; using (var test = CreateTestKey()) { - watchingTask = test.Key.WaitForChangeAsync(); + watchingTask = test.Key.WaitForChangeAsync(cancellationToken: TestContext.Current.CancellationToken); } // We expect the task to quietly complete (without throwing any exception). @@ -382,7 +383,7 @@ public async Task AwaitRegKeyChange_CallingThreadDestroyed() thread.Join(); // Verify that the watching task is still watching. - var completedTask = await Task.WhenAny(watchingTask, Task.Delay(AsyncDelay)); + var completedTask = await Task.WhenAny(watchingTask, Task.Delay(AsyncDelay, TestContext.Current.CancellationToken)); Assert.NotSame(watchingTask, completedTask); test.CreateSubKey().Dispose(); diff --git a/src/CommonUtilities.Registry/test/Extensions/WindowsKeyExtensionsTest.cs b/src/CommonUtilities.Registry/test/Extensions/WindowsKeyExtensionsTest.cs index ad03bf7f..05c01967 100644 --- a/src/CommonUtilities.Registry/test/Extensions/WindowsKeyExtensionsTest.cs +++ b/src/CommonUtilities.Registry/test/Extensions/WindowsKeyExtensionsTest.cs @@ -1,10 +1,12 @@ #if Windows +using System.Runtime.Versioning; using AnakinRaW.CommonUtilities.Registry.Windows; namespace AnakinRaW.CommonUtilities.Registry.Test.Extensions; // ReSharper disable once UnusedMember.Global +[SupportedOSPlatform("windows")] public class WindowsKeyExtensionsTest : RegistryKeyExtensionsTestBase { protected override RegKeyTest CreateTestKey() diff --git a/src/CommonUtilities.Registry/test/RegistryKey_DeleteKey_Recursive.cs b/src/CommonUtilities.Registry/test/RegistryKey_DeleteKey_Recursive.cs index 8aba1041..c25eca30 100644 --- a/src/CommonUtilities.Registry/test/RegistryKey_DeleteKey_Recursive.cs +++ b/src/CommonUtilities.Registry/test/RegistryKey_DeleteKey_Recursive.cs @@ -1,6 +1,6 @@ using System; using System.Linq; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Extensions; using Xunit; namespace AnakinRaW.CommonUtilities.Registry.Test; diff --git a/src/CommonUtilities.Registry/test/RegistryKey_TypeLimit.cs b/src/CommonUtilities.Registry/test/RegistryKey_TypeLimit.cs index 2938a299..c8811aca 100644 --- a/src/CommonUtilities.Registry/test/RegistryKey_TypeLimit.cs +++ b/src/CommonUtilities.Registry/test/RegistryKey_TypeLimit.cs @@ -1,5 +1,5 @@ using System; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Extensions; using Xunit; namespace AnakinRaW.CommonUtilities.Registry.Test; @@ -17,13 +17,14 @@ public void SetValue_InvalidDataTypes_WindowsCompatibility() // Should throw because only String[] (REG_MULTI_SZ) and byte[] (REG_BINARY) are supported. // RegistryKey.SetValue does not support arrays of type UInt32[]. - AssertExtensions.Throws(null, () => TestRegistryKey.SetValue("IntArray", value: new[] { 1, 2, 3 })); + AssertExtensions.Throws(null, () => TestRegistryKey.SetValue("IntArray", value: (int[])[1, 2, 3 + ])); } else { TestRegistryKey.SetValue("StringArr", value: new string[1]); - Assert.Equal([null!], TestRegistryKey.GetValue("StringArr")!); - TestRegistryKey.SetValue("IntArray", value: new[] { 1, 2, 3 }); + Assert.Equal((string[])[null!], TestRegistryKey.GetValue("StringArr")!); + TestRegistryKey.SetValue("IntArray", value: (int[])[1, 2, 3]); Assert.Equal([1, 2, 3], TestRegistryKey.GetValue("IntArray")!); } } diff --git a/src/CommonUtilities.Registry/test/TestData.cs b/src/CommonUtilities.Registry/test/TestData.cs index 953febf6..cdbcb48b 100644 --- a/src/CommonUtilities.Registry/test/TestData.cs +++ b/src/CommonUtilities.Registry/test/TestData.cs @@ -32,10 +32,10 @@ static TestData() ["Test_06", (uint)rand.Next(0, int.MaxValue)], ["Test_07", (long)rand.Next(int.MinValue, int.MaxValue)], ["Test_08", (ulong)rand.Next(0, int.MaxValue)], - ["Test_09", new decimal(((double)decimal.MaxValue) * rand.NextDouble())], - ["Test_10", new decimal(((double)decimal.MinValue) * rand.NextDouble())], - ["Test_11", new decimal(((double)decimal.MinValue) * rand.NextDouble())], - ["Test_12", new decimal(((double)decimal.MaxValue) * rand.NextDouble())], + ["Test_09", new decimal((double)decimal.MaxValue * rand.NextDouble())], + ["Test_10", new decimal((double)decimal.MinValue * rand.NextDouble())], + ["Test_11", new decimal((double)decimal.MinValue * rand.NextDouble())], + ["Test_12", new decimal((double)decimal.MaxValue * rand.NextDouble())], ["Test_13", int.MaxValue *rand.NextDouble()], ["Test_14", int.MinValue * rand.NextDouble()], ["Test_15", int.MaxValue * (float)rand.NextDouble()], diff --git a/src/CommonUtilities.Registry/test/WindowsRegistryTests.cs b/src/CommonUtilities.Registry/test/WindowsRegistryTests.cs index c78fc9a2..1fb98f5b 100644 --- a/src/CommonUtilities.Registry/test/WindowsRegistryTests.cs +++ b/src/CommonUtilities.Registry/test/WindowsRegistryTests.cs @@ -3,8 +3,8 @@ using AnakinRaW.CommonUtilities.Registry.Windows; using Microsoft.Win32; using Xunit; -using AnakinRaW.CommonUtilities.Testing; using System; +using AnakinRaW.CommonUtilities.Testing.Attributes; #if NET diff --git a/src/CommonUtilities.SimplePipeline/src/CommonUtilities.SimplePipeline.csproj b/src/CommonUtilities.SimplePipeline/src/CommonUtilities.SimplePipeline.csproj index 318ea7f4..cc8f91ca 100644 --- a/src/CommonUtilities.SimplePipeline/src/CommonUtilities.SimplePipeline.csproj +++ b/src/CommonUtilities.SimplePipeline/src/CommonUtilities.SimplePipeline.csproj @@ -1,15 +1,17 @@ - + + CommonUtilities.SimplePipeline + AnakinRaW.CommonUtilities.SimplePipeline Implements pipeline runners to run steps synchronized or in parallel. - netstandard2.0 - enable - true + true + netstandard2.0;netstandard2.1;net10.0 AnakinRaW.CommonUtilities.SimplePipeline AnakinRaW.CommonUtilities.SimplePipeline + en @@ -20,13 +22,17 @@ true + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -41,4 +47,8 @@ + + + + \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Extensions.cs b/src/CommonUtilities.SimplePipeline/src/Extensions.cs index fc6bdd49..5d5637f2 100644 --- a/src/CommonUtilities.SimplePipeline/src/Extensions.cs +++ b/src/CommonUtilities.SimplePipeline/src/Extensions.cs @@ -7,15 +7,27 @@ internal static class Extensions { extension(Exception error) { - public bool IsExceptionType() where T : Exception + internal bool IsExceptionType() where T : Exception { return error switch { - T _ => true, + T => true, AggregateException aggregateException => aggregateException.InnerExceptions.Any(p => p.IsExceptionType()), _ => false }; } + + internal T? FindException() where T : Exception + { + return error switch + { + T t => t, + AggregateException aggregateException => aggregateException.InnerExceptions + .Select(p => p.FindException()) + .FirstOrDefault(p => p is not null), + _ => null + }; + } } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/IParallelStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/IParallelStepRunner.cs deleted file mode 100644 index 09b7bad5..00000000 --- a/src/CommonUtilities.SimplePipeline/src/IParallelStepRunner.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; - -namespace AnakinRaW.CommonUtilities.SimplePipeline; - -/// -/// A specialized which allows for synchronous waiting. -/// -public interface IParallelStepRunner : IStepRunner -{ - /// - /// Gets an aggregated exception of all failed steps or if no step failed. - /// - public AggregateException? Exception { get; } - - /// - /// Gets the number of parallel workers the uses. - /// - public int WorkerCount { get; } - - /// - /// Synchronously waits for this stepRunner for all of its steps to be finished. - /// - /// If any of the steps failed with an exception. - void Wait(); - - /// - /// Synchronously waits for this stepRunner for all of its steps to be finished. - /// - /// The time duration to wait. - /// If expired. - /// If any of the steps failed with an exception. - void Wait(TimeSpan waitDuration); -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/IStep.cs b/src/CommonUtilities.SimplePipeline/src/IStep.cs index 112be06c..3e78c9bd 100644 --- a/src/CommonUtilities.SimplePipeline/src/IStep.cs +++ b/src/CommonUtilities.SimplePipeline/src/IStep.cs @@ -1,5 +1,7 @@ using System; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; namespace AnakinRaW.CommonUtilities.SimplePipeline; @@ -9,13 +11,37 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; public interface IStep : IDisposable { /// - /// The exception, if any, that happened while running this step. + /// Gets a value indicating whether the step has been cancelled. /// + /// + /// if the step was cancelled; otherwise, . + /// + public bool IsCancelled { get; } + + /// + /// Gets the exception that occurred during the execution of the step, if any. + /// + /// + /// An representing the error that occurred during the step's execution, + /// or if no error occurred. + /// Exception? Error { get; } /// /// Run the step's action. /// /// Provided to allow step cancellation. - void Run(CancellationToken token); + Task RunAsync(CancellationToken token); + + /// + /// Gets an awaiter used to await this . + /// + TaskAwaiter GetAwaiter(); + + /// + /// Configures an awaiter used to await this . + /// + /// + /// An object used to await this task. + ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext); } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/IStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/IStepRunner.cs index 415fff55..1101b314 100644 --- a/src/CommonUtilities.SimplePipeline/src/IStepRunner.cs +++ b/src/CommonUtilities.SimplePipeline/src/IStepRunner.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -15,6 +16,34 @@ public interface IStepRunner /// event EventHandler? Error; + /// + /// Gets an aggregated exception of all failed steps or if no step failed. + /// + public AggregateException? Exception { get; } + + /// + /// Gets the number of parallel workers the uses. + /// + public int WorkerCount { get; } + + /// + /// Gets a value indicating whether the is currently executing steps. + /// + /// + /// if the runner is actively executing steps; otherwise, . + /// + bool IsRunning { get; } + + /// + /// Gets a value indicating whether the step runner has been cancelled. + /// + /// + /// This property returns if the step runner was cancelled during its execution, + /// typically due to a cancellation request via a . + /// Otherwise, it returns . + /// + bool IsCancelled { get; } + /// /// Gets a read-only list of only those steps were executed by the . /// @@ -33,4 +62,51 @@ public interface IStepRunner /// The step to app. /// /// is . void AddStep(IStep step); + + /// + /// Synchronously waits for this stepRunner for all of its steps to be finished. + /// + /// If any of the steps failed with an exception. + void Wait(); + + /// + /// Synchronously waits for this stepRunner for all of its steps to be finished. + /// + /// The time duration to wait. + /// If expired. + /// If any of the steps failed with an exception. + /// + /// is a negative number other than -1 milliseconds, which represents an infinite time-out. + /// or is greater than Int32.MaxValue. + /// + void Wait(TimeSpan waitDuration); + + /// + /// Gets an awaiter used to await this . + /// + /// + /// + /// This method enables the to be used with the await keyword. + /// + /// + /// If called before has been invoked, the awaiter will block until + /// the runner is started and has completed execution of all steps. + /// + /// + /// If called during execution, the awaiter will block until all steps have finished. + /// + /// + /// The awaiter does not throw exceptions for failed steps. Any errors that occurred during + /// execution are available through the property. + /// + /// + /// A instance that can be used to await the runner's completion. + TaskAwaiter GetAwaiter(); + + /// + /// Configures an awaiter used to await this . + /// + /// + /// An object used to await this task. + ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext); } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/IPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/IPipeline.cs index 469a948c..db3341fa 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/IPipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/IPipeline.cs @@ -5,26 +5,24 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// -/// Represents an execution pipeline can run multiple instanes. +/// Represents an execution pipeline that can be prepared and run. /// public interface IPipeline : IDisposable -{ +{ /// /// Prepares the pipeline for execution. /// - /// - /// Preparation can only be done once per instance. - /// - /// A task that completes when the preparation is completed. - Task PrepareAsync(); - + /// Token to cancel the preparation. + /// Cancellation was requested. + /// The pipeline was disposed. + Task PrepareAsync(CancellationToken token = default); + /// - /// Runs pipeline synchronously. + /// Runs the pipeline asynchronously. /// - /// Provided to allow cancellation. - /// A task that represents the operation completion. - /// The pipeline was cancelled was requested for cancellation. - /// The pipeline may throw this exception if one or many steps failed. + /// Token to cancel the execution. + /// Cancellation was requested. + /// The pipeline was disposed. Task RunAsync(CancellationToken token = default); /// diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelPipeline.cs deleted file mode 100644 index 77d7d582..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelPipeline.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -namespace AnakinRaW.CommonUtilities.SimplePipeline; - -/// -/// A simple pipeline that runs all steps on the thread pool in parallel. -/// -public abstract class ParallelPipeline : StepRunnerPipeline -{ - private readonly int _workerCount; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider for dependency injection within the pipeline. - /// The number of worker threads to be used for parallel execution. - /// A value indicating whether the pipeline should fail fast. - /// is . - protected ParallelPipeline(IServiceProvider serviceProvider, int workerCount = 4, bool failFast = true) : base(serviceProvider, failFast) - { - _workerCount = workerCount; - } - - /// - protected sealed override ParallelStepRunner CreateRunner() - { - return new ParallelStepRunner(_workerCount, ServiceProvider); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs deleted file mode 100644 index 40c3f69f..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -namespace AnakinRaW.CommonUtilities.SimplePipeline; - -/// -/// A simple pipeline that runs all steps on the thread pool in parallel. Allows to run the pipeline even if preparation is not completed. -/// -/// -/// Useful, if preparation is work intensive. -/// -public abstract class ParallelProducerConsumerPipeline : Pipeline -{ - private readonly ParallelProducerConsumerStepRunner _stepRunner; - - private Exception? _preparationException; - - /// - protected override bool FailFast { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The service provider for dependency injection within the pipeline. - /// The number of worker threads to be used for parallel execution. - /// A value indicating whether the pipeline should fail fast. - protected ParallelProducerConsumerPipeline(int workerCount, bool failFast, IServiceProvider serviceProvider) : base(serviceProvider) - { - FailFast = failFast; - _stepRunner = new ParallelProducerConsumerStepRunner(workerCount, serviceProvider); - } - - /// - public sealed override async Task RunAsync(CancellationToken token = default) - { - ThrowIfDisposed(); - - LinkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); - - if (!Prepared) - { - Task.Run(async () => - { - try - { - await PrepareAsync().ConfigureAwait(false); - } - catch (Exception e) - { - PipelineFailed = true; - _preparationException = e; - - if (FailFast) - Cancel(); - } - finally - { - _stepRunner.Finish(); - } - }, LinkedCancellationTokenSource.Token).Forget(); - } - - try - { - await RunCoreAsync(LinkedCancellationTokenSource.Token).ConfigureAwait(false); - LinkedCancellationTokenSource.Token.ThrowIfCancellationRequested(); - } - catch (Exception) - { - PipelineFailed = true; - throw; - } - finally - { - if (LinkedCancellationTokenSource is not null) - { - LinkedCancellationTokenSource.Dispose(); - LinkedCancellationTokenSource = null; - } - } - } - - /// - /// Builds the steps in the order they should be executed within the pipeline. - /// - /// A list of steps in the order they should be executed. - protected abstract IAsyncEnumerable BuildSteps(); - - /// - protected override async Task PrepareCoreAsync() - { - await foreach (var step in BuildSteps().ConfigureAwait(false)) - _stepRunner.AddStep(step); - _stepRunner.Finish(); - return true; - } - - /// - protected override async Task RunCoreAsync(CancellationToken token) - { - try - { - _stepRunner.Error += OnError; - await _stepRunner.RunAsync(token).ConfigureAwait(false); - } - finally - { - _stepRunner.Error -= OnError; - } - - if (!PipelineFailed) - return; - - if (_preparationException is not null) - throw _preparationException; - - ThrowIfAnyStepsFailed(_stepRunner.ExecutedSteps); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs index 02651235..a0842c80 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs @@ -1,158 +1,243 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading; using System.Threading.Tasks; namespace AnakinRaW.CommonUtilities.SimplePipeline; /// -/// Base implementation for an +/// Base implementation for an . /// public abstract class Pipeline : DisposableObject, IPipeline { + private Task? _preparationTask; + private Task? _runTask; + +#if NET10_0_OR_GREATER + private readonly Lock _reentryLock = new(); +#else + private readonly object _reentryLock = new(); +#endif + /// - /// The cancellation token source used by this pipeline to send cancellation request. + /// Gets the used to cancel the execution of the pipeline. + /// Returns if the execution is not started or already finished. /// - protected CancellationTokenSource? LinkedCancellationTokenSource; - + protected CancellationTokenSource? CancellationTokenSource; + /// - /// Returns the service provider of the . + /// Gets the for the pipeline. /// protected readonly IServiceProvider ServiceProvider; - + /// - /// Returns the logger of the . + /// Gets the for the pipeline or if no logger is registered. /// protected readonly ILogger? Logger; /// - /// Gets a value indicating whether the preparation of the was successful. + /// Gets a value indicating whether the pipeline has been successfully prepared. /// - protected bool Prepared { get; set; } + /// + /// if the pipeline preparation task has completed successfully; otherwise, . + /// + protected internal bool IsPrepared => +#if NETSTANDARD2_0 || NETFRAMEWORK + _preparationTask is { Status: TaskStatus.RanToCompletion, IsCompleted: true }; +#else + _preparationTask?.IsCompletedSuccessfully is true; +#endif /// - /// Gets a value indicating whether the execution of the pipeline has encountered a failure. + /// Gets a value indicating whether the pipeline has encountered a failure during its execution. /// - public bool PipelineFailed { get; protected set; } - + /// + /// This property is set to if an exception occurs during the execution of the pipeline. + /// + public bool Failed { get; protected set; } + /// - /// Gets a value indicating the pipeline shall abort execution on the first received error. + /// Gets a value indicating whether the pipeline has been cancelled. /// - protected virtual bool FailFast => false; - + /// + /// This property is set to when the pipeline is explicitly cancelled + /// or when an is thrown during execution. + /// + public bool Cancelled { get; protected set; } + /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with the specified service provider. /// + /// The used to resolve dependencies for the pipeline. /// is . protected Pipeline(IServiceProvider serviceProvider) { ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); Logger = serviceProvider.GetService()?.CreateLogger(GetType()); } - - /// - public async Task PrepareAsync() + + /// + /// Returns a string representation of the current instance. + /// + /// + /// A that represents the name of the current pipeline type. + /// + [ExcludeFromCodeCoverage] + public override string ToString() => GetType().Name; + + /// + /// Prepares the pipeline for execution. + /// + /// A to observe while waiting for the preparation to complete. + /// A that represents the asynchronous preparation operation. + /// The pipeline already is prepared or preparation has been started. + /// The pipeline has been disposed. + /// The operation is canceled. + public Task PrepareAsync(CancellationToken token = default) { ThrowIfDisposed(); - if (!Prepared) + token.ThrowIfCancellationRequested(); + lock (_reentryLock) { + if (_preparationTask is not null) + throw new InvalidOperationException("Pipeline preparation has already been started."); + try { - await PrepareCoreAsync().ConfigureAwait(false); + _preparationTask = PrepareCoreAsync(token); } - finally + catch (Exception ex) { - Prepared = true; + _preparationTask = Task.FromException(ex); + throw; } + return _preparationTask; } } - /// - public virtual async Task RunAsync(CancellationToken token = default) + /// + /// Executes the pipeline asynchronously. + /// + /// A to observe while waiting for the task to complete. + /// A that represents the asynchronous operation. + /// Thrown when the pipeline has already been started or is executing. + /// Thrown when the pipeline has been disposed. + public Task RunAsync(CancellationToken token = default) { ThrowIfDisposed(); - - await PrepareAsync().ConfigureAwait(false); - - try + lock (_reentryLock) { - try - { - LinkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); - await RunCoreAsync(LinkedCancellationTokenSource.Token).ConfigureAwait(false); - LinkedCancellationTokenSource.Token.ThrowIfCancellationRequested(); - } - finally - { - if (LinkedCancellationTokenSource is not null) - { - LinkedCancellationTokenSource.Dispose(); - LinkedCancellationTokenSource = null; - } - } - } - catch (Exception) - { - PipelineFailed = true; - throw; + if (_runTask is not null) + throw new InvalidOperationException("Pipeline has already been started."); + + _runTask = RunCoreAsync(token); + return _runTask; } } - /// + /// + /// Cancels the execution of the pipeline. + /// + /// + /// This method ensures that the pipeline's execution is stopped by canceling the associated + /// . Once canceled, the property is set to true. + /// + /// Thrown if the pipeline or its associated resources have already been disposed. public void Cancel() { - LinkedCancellationTokenSource?.Cancel(); - } - - /// - [ExcludeFromCodeCoverage] - public override string ToString() - { - return GetType().Name; + var cts = CancellationTokenSource; + if (cts != null) + { + cts.Cancel(); + Cancelled = true; + } } /// /// Performs the actual preparation of this instance. /// - /// if the planning was successful; otherwise. - protected abstract Task PrepareCoreAsync(); + protected abstract Task PrepareCoreAsync(CancellationToken token); /// - /// Implements the run logic of this instance. + /// Implements the actual execution logic of this instance. /// /// It's assured this instance is already prepared when this method gets called. - /// Provided to allow cancellation. - protected abstract Task RunCoreAsync(CancellationToken token); + protected abstract Task ExecuteAsync(CancellationToken token); /// - /// Throws an if any of the passed steps ended with an error that is not the result of cancellation. + /// Orchestrates and executes the pipeline. /// - /// The steps that were executed by the pipeline. - /// If any of has an error that is not the result of cancellation. - protected void ThrowIfAnyStepsFailed(IEnumerable steps) + /// + /// + /// Override this method to customize execution flow. + /// + /// + /// The default implementation calls if needed, + /// sets up cancellation, and delegates to . + /// + /// + protected virtual async Task RunCoreAsync(CancellationToken token) { - var failedBuildSteps = steps - .Where(p => p.Error != null && !p.Error.IsExceptionType()) - .ToList(); + CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + + try + { + await WaitForPreparationAsync(CancellationTokenSource.Token).ConfigureAwait(false); + await ExecuteAsync(CancellationTokenSource.Token).ConfigureAwait(false); + CancellationTokenSource.Token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + Cancelled = true; + throw; + } + catch + { + Failed = true; + throw; + } + finally + { + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + } + } - if (failedBuildSteps.Any()) - throw new StepFailureException(failedBuildSteps); + /// + /// Releases resources used by the pipeline, including any tasks and cancellation tokens. + /// + protected override void DisposeResources() + { + lock (_reentryLock) + { + _preparationTask?.Dispose(); + _runTask?.Dispose(); + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + + // Safe because we explicitly check the methods if disposed + _preparationTask = null; + _runTask = null; + } + + base.DisposeResources(); } /// - /// The default event handler that can be used when an error occurs within a step. - /// is set to . When is , the pipeline gets cancelled. + /// Waits for preparation or starts it if not yet started. /// - /// The sender of the event. - /// The event arguments. - protected virtual void OnError(object sender, StepRunnerErrorEventArgs e) + protected Task WaitForPreparationAsync(CancellationToken token) { - PipelineFailed = true; - if (FailFast || e.Cancel) - Cancel(); + Task task; + + lock (_reentryLock) + { + _preparationTask ??= PrepareCoreAsync(token); + task = _preparationTask; + } + return task.WaitAsync(token); + } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/ProducerConsumerPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/ProducerConsumerPipeline.cs new file mode 100644 index 00000000..2f73a947 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/ProducerConsumerPipeline.cs @@ -0,0 +1,148 @@ +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AnakinRaW.CommonUtilities.SimplePipeline; + +/// +/// A pipeline that runs preparation and execution in parallel using a producer/consumer pattern. +/// +/// +/// Steps are added to the runner while execution is already in progress. +/// Useful when preparation is work-intensive. +/// +public abstract class ProducerConsumerPipeline : StepRunnerPipelineBase +{ + private readonly int _workerCount; + + /// + /// Initializes a new instance of the class with the specified worker count and service provider. + /// + /// The number of workers to be used in the pipeline. Must be between 1 and 64 inclusive. + /// The used to resolve dependencies for the pipeline. + /// is less than 1 or greater than 64. + /// is . + protected ProducerConsumerPipeline(int workerCount, IServiceProvider serviceProvider) : base(serviceProvider) + { + if (workerCount is < 1 or > 64) + throw new ArgumentOutOfRangeException(nameof(workerCount), "worker count must be between 1 and 64 inclusive"); + _workerCount = workerCount; + } + + /// + /// Asynchronously builds a collection of steps to be executed by the pipeline. + /// + /// A to observe while waiting for the steps to be built. + /// An asynchronous enumerable of instances representing the steps to be executed. + /// + /// This method is intended to be overridden in derived classes to provide the logic for preparing the steps + /// that will be executed by the pipeline. The steps are produced asynchronously, allowing for efficient + /// preparation of steps in scenarios where preparation is computationally intensive or involves I/O operations. + /// + protected abstract IAsyncEnumerable BuildStepsAsync(CancellationToken token); + + /// + /// Creates an instance of to execute the steps in the pipeline using a producer/consumer pattern. + /// + /// + /// The is initialized with the specified number of workers and the service provider. + /// This method ensures that the runner is properly configured to handle the producer/consumer pattern for step execution. + /// + /// + /// A configured instance of . + /// + protected sealed override ProducerConsumerStepRunner CreateRunner() + { + return new ProducerConsumerStepRunner(_workerCount, ServiceProvider); + } + + /// + /// Prepares the pipeline by initializing the step runner and adding steps to it asynchronously. + /// + /// A to observe while waiting for the preparation to complete. + /// A that represents the asynchronous preparation operation. + /// + /// This method initializes the step runner and asynchronously builds and adds steps to the runner. + /// Once all steps are added, the runner is marked as finished. + /// + protected sealed override async Task PrepareCoreAsync(CancellationToken token) + { + _ = StepRunner; + await foreach (var step in BuildStepsAsync(token).ConfigureAwait(false)) + { + token.ThrowIfCancellationRequested(); + if (!StepRunner.TryAddStep(step) && !Cancelled) + throw new InvalidOperationException("Unable to add write steps to underlying runner"); + } + StepRunner.Finish(); + } + + /// + /// Executes the core logic of the pipeline asynchronously. + /// + /// A to observe while waiting for the task to complete. + /// A that represents the asynchronous operation. + /// + /// This method initializes the runner, links the provided cancellation token, and ensures the pipeline gets prepared. + /// It handles cancellation and failure scenarios, ensuring proper cleanup of resources. + /// + /// Thrown when the operation is canceled. + /// Thrown when an error occurs during execution. + protected sealed override async Task RunCoreAsync(CancellationToken token) + { + CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + var linkedToken = CancellationTokenSource.Token; + + if (!IsPrepared) + { + Task.Run(() => RunPreparationAsync(linkedToken), CancellationToken.None).Forget(); + } + + try + { + await ExecuteAsync(linkedToken).ConfigureAwait(false); + await WaitForPreparationAsync(token).ConfigureAwait(false); + linkedToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + Cancelled = true; + throw; + } + catch + { + Failed = true; + throw; + } + finally + { + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + } + } + + private async Task RunPreparationAsync(CancellationToken token) + { + try + { + await WaitForPreparationAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Cancelled = true; + Cancel(); + } + catch (Exception) + { + Failed = true; + if (FailFast) + Cancel(); + } + finally + { + StepRunner.Finish(); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/SequentialPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/SequentialPipeline.cs index 97e2a292..421f7128 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/SequentialPipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/SequentialPipeline.cs @@ -6,20 +6,28 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// /// A simple pipeline that runs all steps sequentially. /// -public abstract class SequentialPipeline : StepRunnerPipeline +public abstract class SequentialPipeline : StepRunnerPipeline { /// /// Initializes a new instance of the class. /// /// The service provider for dependency injection within the pipeline. - /// A value indicating whether the pipeline should fail fast. /// is . - protected SequentialPipeline(IServiceProvider serviceProvider, bool failFast = true) : base(serviceProvider, failFast) + protected SequentialPipeline(IServiceProvider serviceProvider) : base(serviceProvider) { } - /// - protected sealed override SequentialStepRunner CreateRunner() + /// + /// Creates an instance of to execute the steps in the pipeline sequentially. + /// + /// + /// This method returns a . + /// The runner ensures that all steps are executed one after another in a sequential manner. + /// + /// + /// An instance of that executes steps sequentially. + /// + protected sealed override IStepRunner CreateRunner() { return new SequentialStepRunner(ServiceProvider); } diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipeline.cs index 0e27fa19..8b1cf9d1 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipeline.cs @@ -1,84 +1,64 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; namespace AnakinRaW.CommonUtilities.SimplePipeline; /// -/// Base class for a pipeline implementation utilizing an as its primary execution engine. +/// Base class for pipelines that use an with sequential preparation and execution. /// -/// The type of the step stepRunner. -public abstract class StepRunnerPipeline : Pipeline where TRunner : IStepRunner +/// +/// +/// This class follows a sequential pattern: preparation completes fully before execution begins. +/// +/// +/// For pipelines that need to run preparation and execution in parallel (producer/consumer pattern), +/// use instead. +/// +/// +public abstract class StepRunnerPipeline(IServiceProvider serviceProvider) : StepRunnerPipelineBase(serviceProvider) { - private IStepRunner _buildStepRunner = null!; - - /// - protected override bool FailFast { get; } - /// - /// Initializes a new instance of the class. + /// Creates a collection of steps to be executed by the . /// - /// The service provider the pipeline. - /// A value indicating whether the pipeline should fail fast. + /// + /// A that can be used to cancel the step creation process. + /// + /// + /// A task that represents the asynchronous operation. The task result contains a list of steps + /// () to be added to the . + /// /// - /// The parameter determines whether the pipeline should stop executing immediately upon encountering the first failure. + /// This method is abstract and must be implemented by derived classes to define the specific steps + /// required for the pipeline. The steps are created during the preparation phase of the pipeline. /// - /// is . - protected StepRunnerPipeline(IServiceProvider serviceProvider, bool failFast = true) : base(serviceProvider) - { - FailFast = failFast; - } - - /// - [ExcludeFromCodeCoverage] - public override string ToString() - { - return GetType().Name; - } - - /// - /// Creates the step stepRunner for the pipeline. - /// - /// The step stepRunner instance. - protected abstract TRunner CreateRunner(); + protected abstract Task> CreateRunnerSteps(CancellationToken token); /// - /// Builds the steps that should be executed within the pipeline. + /// Prepares the pipeline by initializing the step runner and adding the steps to be executed. /// + /// A to observe while waiting for the task to complete. /// - /// The order of the steps might be relevant, depending on the type of . + /// This method initializes the step runner and sequentially adds the steps created by + /// to the runner. It ensures that the preparation phase + /// is fully completed before the execution phase begins. /// - /// A task that returns a list of steps. - protected abstract Task> BuildSteps(); - - /// - protected override async Task PrepareCoreAsync() + /// + /// The step runner is not properly initialized before adding steps. + /// + /// A that represents the asynchronous operation. + protected sealed override async Task PrepareCoreAsync(CancellationToken token) { - _buildStepRunner = CreateRunner() ?? throw new InvalidOperationException("RunnerFactory created null value!"); - var steps = await BuildSteps().ConfigureAwait(false); - foreach (var step in steps) - _buildStepRunner.AddStep(step); - return true; + _ = StepRunner; + var steps = await CreateRunnerSteps(token).ConfigureAwait(false); + foreach (var step in steps) + StepRunner.AddStep(step); } /// - protected override async Task RunCoreAsync(CancellationToken token) + protected sealed override Task RunCoreAsync(CancellationToken token) { - try - { - _buildStepRunner.Error += OnError; - await _buildStepRunner.RunAsync(token).ConfigureAwait(false); - } - finally - { - _buildStepRunner.Error -= OnError; - } - - if (!PipelineFailed) - return; - - ThrowIfAnyStepsFailed(_buildStepRunner.ExecutedSteps); + return base.RunCoreAsync(token); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipelineBase.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipelineBase.cs new file mode 100644 index 00000000..c165aaa1 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/StepRunnerPipelineBase.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AnakinRaW.CommonUtilities.SimplePipeline; + +/// +/// Represents a base class for pipelines that execute steps using a . +/// +/// The type of the step runner used to execute the steps. +/// +/// +/// Running the pipeline may throw a if one or many steps produce errors. +/// +/// +/// This class provides functionality for managing a step runner, executing steps, handling errors, +/// and disposing resources. Derived classes must implement the method +/// to provide a specific step runner implementation. +/// +/// +public abstract class StepRunnerPipelineBase : Pipeline where TStepRunner : class, IStepRunner +{ + private readonly Lazy _stepRunnerLazy; + + /// + /// Initializes a new instance of the class with the specified service provider. + /// + /// The used to resolve dependencies for the pipeline. + /// is . + protected StepRunnerPipelineBase(IServiceProvider serviceProvider) : base(serviceProvider) + { + _stepRunnerLazy = new Lazy(EnsureRunner, LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Gets the step runner used to execute the steps in the pipeline. + /// + /// + /// The step runner of type . + /// + /// + /// + /// The step runner is lazily initialized when accessed for the first time, using . + /// It is guaranteed to be non- once initialized. + /// + /// + /// Derived classes can use this property to add steps to the runner or to + /// perform operations specific to the step runner implementation. + /// + /// + protected internal TStepRunner StepRunner => _stepRunnerLazy.Value; + + private TStepRunner EnsureRunner() + { + return CreateRunner() ?? throw new InvalidOperationException("CreateRunner must not return null."); + } + + /// + /// Gets a value indicating whether the step runner has been initialized. + /// + /// + /// if the step runner has been initialized; otherwise, . + /// + /// + /// When this property returns , the property is guaranteed + /// to return a non- value. + /// + protected internal bool IsStepRunnerInitialized => _stepRunnerLazy.IsValueCreated; + + /// + /// Gets or sets a value indicating whether the pipeline should terminate execution immediately + /// upon encountering an error. + /// + /// + /// if the pipeline should stop execution on the first error; otherwise, . + /// + /// + /// When set to , the pipeline will cancel further processing as soon as an error occurs. + /// This is useful for scenarios where continuing execution after an error is not desirable. + /// + public bool FailFast { get; protected set; } = false; + + /// + /// Creates an instance of the step runner used to execute the steps in the pipeline. + /// + /// An instance of representing the step runner. + /// + /// Derived classes must implement this method to provide a specific implementation of the step runner. + /// The returned step runner must not be + /// + protected abstract TStepRunner CreateRunner(); + + /// + /// Executes the pipeline asynchronously, running all steps added to . + /// + /// A to observe while waiting for the task to complete. + /// + /// A that represents the asynchronous execution of the pipeline. + /// + /// + /// This method attaches an error handler to the step runner, executes the steps asynchronously, + /// and ensures that any failed steps throw an exception after execution. + /// + /// + /// Thrown when if any executed step failed excluding those which represent a cancelled Step. + /// + protected sealed override async Task ExecuteAsync(CancellationToken token) + { + OnExecuteStarted(); + try + { + StepRunner.Error += OnRunnerExecutionError!; + await StepRunner.RunAsync(token).ConfigureAwait(false); + } + finally + { + StepRunner.Error -= OnRunnerExecutionError!; + } + OnRunnerExecuted(); + + var failedSteps = GetFailedSteps(StepRunner.ExecutedSteps).ToList(); + if (failedSteps.Count > 0) + throw new StepFailureException(failedSteps); + OnExecuteCompleted(); + } + + /// + /// Retrieves the steps that have failed during execution. + /// + /// The collection of steps to evaluate for failures. + /// + /// A collection of steps that encountered errors during execution. + /// Each step in the returned collection has a non-null property. + /// + /// + /// This method filters the provided steps to identify those that have an associated error. + /// Derived classes can override this method to customize the logic for determining failed steps. + /// + protected virtual IEnumerable GetFailedSteps(IEnumerable steps) + { + return steps.Where(step => step is { Error: not null }); + } + + /// + /// Called when the execution of the pipeline starts, before the step runner begins processing steps. + /// + /// + /// Override this method to perform custom logic at the start of execution. + /// + protected virtual void OnExecuteStarted() + { + } + + /// + /// Called after the step runner has completed executing all steps, before checking for step failures. + /// + /// + /// Override this method to perform custom logic after the runner has executed but before failure validation. + /// + protected virtual void OnRunnerExecuted() + { + } + + /// + /// Called when the execution of the pipeline has completed successfully. + /// + /// + /// This method is only called if no is thrown. + /// Override this method to perform custom logic upon successful completion. + /// + protected virtual void OnExecuteCompleted() + { + } + + /// + /// Handles errors that occur during the execution of the . + /// + /// The source of the event, typically the instance. + /// The containing details about the error. + /// + /// This method updates the pipeline's state based on the error details, including whether the pipeline + /// should be cancelled or marked as failed. If is enabled or the error indicates + /// cancellation, the pipeline will be cancelled immediately. + /// + protected virtual void OnRunnerExecutionError(object sender, StepRunnerErrorEventArgs e) + { + var isCancel = IsCancel(e); + Cancelled |= isCancel; + Failed |= !isCancel; + + if (FailFast || isCancel) + Cancel(); + } + + private static bool IsCancel(StepRunnerErrorEventArgs e) + { + return e.Cancel || e.Exception.IsExceptionType(); + } + + /// + /// Releases the resources used by the instance. + /// + /// + /// This method ensures that any resources associated with the pipeline, including the step runner, are properly disposed of. + /// + protected override void DisposeResources() + { + base.DisposeResources(); + if (IsStepRunnerInitialized) + StepRunner.Error -= OnRunnerExecutionError!; + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Progress/AggregatedProgressReporter.cs b/src/CommonUtilities.SimplePipeline/src/Progress/AggregatedProgressReporter.cs index 22fbd48a..c5760fbc 100644 --- a/src/CommonUtilities.SimplePipeline/src/Progress/AggregatedProgressReporter.cs +++ b/src/CommonUtilities.SimplePipeline/src/Progress/AggregatedProgressReporter.cs @@ -110,15 +110,14 @@ protected AggregatedProgressReporter( { if (!_progressSteps.Add(step)) continue; - step.Progress += OnStepProgress; + step.Progress += OnStepProgress!; TotalSize += step.Size; } } private void OnStepProgress(object sender, ProgressEventArgs e) { - if (sender is not TStep step) - throw new InvalidCastException($"Cannot cast '{sender.GetType()}' to {typeof(TStep)}"); + var step = (TStep)sender; if (!_progressSteps.Contains(step)) return; @@ -134,7 +133,7 @@ private void OnStepProgress(object sender, ProgressEventArgs e) protected override void DisposeResources() { foreach (var step in _progressSteps) - step.Progress -= OnStepProgress; + step.Progress -= OnStepProgress!; _progressSteps.Clear(); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/AsyncStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/AsyncStepRunner.cs new file mode 100644 index 00000000..96b922ac --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/src/Runners/AsyncStepRunner.cs @@ -0,0 +1,303 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; + +/// +/// Represents an asynchronous step runner that manages the execution of steps in a pipeline. +/// +/// +/// +/// This class provides functionality to add steps, execute them asynchronously, and handle errors during execution. +/// It supports multiple workers for parallel step execution and ensures proper cancellation and error handling. +/// +/// +/// Thread Safety: This class and its derivatives are not thread-safe for concurrent operations +/// such as adding steps from a different thread while step execution is processing. +/// +/// +public class AsyncStepRunner : IStepRunner +{ + /// + public event EventHandler? Error; + + private readonly ConcurrentQueue _pendingSteps = new(); + private readonly ConcurrentBag _executedSteps = []; + private readonly ConcurrentBag _exceptions = []; + private readonly TaskCompletionSource _completionSource = new(); + private Task? _exposedTask; + + /// + public AggregateException? Exception => _exceptions.IsEmpty ? null : new AggregateException(_exceptions); + + /// + /// Gets a value indicating whether the steps in the pipeline are executed sequentially. + /// + /// + /// if the steps are executed sequentially; otherwise, . + /// + /// + /// The execution is considered sequential when the is set to 1. + /// + public bool IsSequential => WorkerCount == 1; + + /// + public bool IsRunning { get; private set; } + + /// + public int WorkerCount { get; } + + /// + public IReadOnlyCollection ExecutedSteps => _executedSteps.ToArray(); + + /// + public bool IsCancelled { get; private set; } + + /// + /// Gets the logger instance used for logging messages related to the execution of the step runner. + /// + protected ILogger? Logger { get; } + + /// + /// Initializes a new instance of the class with the specified number of workers and a service provider. + /// + /// The number of workers to use for executing steps. Must be between 1 and 64, inclusive. + /// The service provider used to resolve dependencies required by the runner. + /// Thrown when is less than 1 or greater than 64. + /// Thrown when is . + public AsyncStepRunner(int workerCount, IServiceProvider serviceProvider) + { + if (workerCount is < 1 or > 64) + throw new ArgumentOutOfRangeException(nameof(workerCount)); + if (serviceProvider == null) + throw new ArgumentNullException(nameof(serviceProvider)); + Logger = serviceProvider.GetService()?.CreateLogger(GetType()); + WorkerCount = workerCount; + } + + /// + public virtual void AddStep(IStep step) + { + if (step == null) + throw new ArgumentNullException(nameof(step)); + _pendingSteps.Enqueue(step); + } + + /// + public Task RunAsync(CancellationToken token) + { + if (IsRunning) + throw new InvalidOperationException("The step runner is already running."); + + var task = CreateRunnerTask(token); + _completionSource.TrySetResult(task); + return GetRunnerTask(); + } + + /// + public TaskAwaiter GetAwaiter() + { + return GetRunnerTask().GetAwaiter(); + } + + /// + public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) + { + return GetRunnerTask().ConfigureAwait(continueOnCapturedContext); + } + + private Task GetRunnerTask() + { + var existing = Volatile.Read(ref _exposedTask); + if (existing is not null) + return existing; + + var tcsTask = _completionSource.Task; + if (tcsTask is { IsCompleted: true, Status: TaskStatus.RanToCompletion }) + { + var result = tcsTask.Result; + var original = Interlocked.CompareExchange(ref _exposedTask, result, null); + return original ?? result; + } + + var newTask = CreateAwaitableTask(); + var prev = Interlocked.CompareExchange(ref _exposedTask, newTask, null); + return prev ?? newTask; + } + + private async Task CreateAwaitableTask() + { + var task = await _completionSource.Task.ConfigureAwait(false); + await task.ConfigureAwait(false); + } + + /// + public void Wait() + { + Wait(Timeout.InfiniteTimeSpan); + } + + /// + public void Wait(TimeSpan timeout) + { + var totalMilliseconds = (long)timeout.TotalMilliseconds; + if (totalMilliseconds is < -1 or > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + var task = GetRunnerTask(); + + var completed = true; + try + { + completed = task.Wait(timeout); + } + catch + { + // Ignore + } + + if (!completed) + throw new TimeoutException(); + + var exception = Exception; + if (exception != null) + throw exception; + } + + /// + /// Asynchronously retrieves the next step to be executed from the pending steps queue. + /// + /// A to observe while waiting for the next step. + /// + /// A representing the asynchronous operation. + /// The result contains the next to be executed, or if no steps are available. + /// + /// + /// This method is designed to be overridden in derived classes to customize the behavior of step retrieval. + /// + protected virtual ValueTask TakeNextStepAsync(CancellationToken cancellationToken) + { + return new ValueTask(_pendingSteps.TryDequeue(out var step) ? step : null); + } + + /// + /// Allows an overriding class to handle step errors and raises the event. + /// + /// The exception that caused the error. + /// The event args to use. + protected virtual void OnError(Exception exception, StepRunnerErrorEventArgs stepError) + { + Error?.Invoke(this, stepError); + IsCancelled |= stepError.Cancel; + } + + /// + /// Throws an if the given token was requested for cancellation. + /// + /// The token to check for cancellation. + /// If the token was requested for cancellation. + protected void ThrowIfCancelled(CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (IsCancelled) + throw new OperationCanceledException(token); + } + + /// + /// Allows an overriding class to perform cleanup actions once the runner was requested to stop execution. + /// + protected virtual void OnRunnerStopped() + { + } + + private async Task CreateRunnerTask(CancellationToken token) + { + try + { + IsRunning = true; + if (WorkerCount == 1) + await Task.Run(() => RunWorkerAsync(token), CancellationToken.None).ConfigureAwait(false); + else + { + var workers = new Task[WorkerCount]; + for (var i = 0; i < WorkerCount; i++) + workers[i] = Task.Factory.StartNew( + () => RunWorkerAsync(token), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap(); + await Task.WhenAll(workers).ConfigureAwait(false); + } + } + finally + { + IsRunning = false; + } + } + + private async Task RunWorkerAsync(CancellationToken token) + { + var alreadyCancelled = false; + try + { + while (await TakeNextStepAsync(token).ConfigureAwait(false) is { } step) + { + try + { + ThrowIfCancelled(token); + _executedSteps.Add(step); + await step.RunAsync(token).ConfigureAwait(false); + } + catch (StopRunnerException e) + { + _exceptions.Add(e); + Logger?.LogTrace("Stop subsequent steps"); + IsCancelled = true; + + var error = new StepRunnerErrorEventArgs(e, step) + { + Cancel = true + }; + OnError(e, error); + + OnRunnerStopped(); + + break; + } + catch (Exception e) + { + _exceptions.Add(e); + if (!alreadyCancelled) + { + if (e.IsExceptionType()) + Logger?.LogTrace("Step {Step} cancelled", step); + else + Logger?.LogTrace(e, "Step {Step} threw an exception: {Exception}: {EMessage}", step, e.GetType(), e.Message); + } + + var error = new StepRunnerErrorEventArgs(e, step) + { + Cancel = token.IsCancellationRequested || IsCancelled || e.IsExceptionType() + }; + if (error.Cancel) + alreadyCancelled = true; + OnError(e, error); + } + } + } + catch (OperationCanceledException e) + { + IsCancelled = true; + OnError(e, new StepRunnerErrorEventArgs(e, null) + { + Cancel = true + }); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelProducerConsumerStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/ParallelProducerConsumerStepRunner.cs deleted file mode 100644 index 3d045d50..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelProducerConsumerStepRunner.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Threading; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -/// -/// Runner engine, which executes all queued _steps parallel. Steps may be queued while step execution has been started. -/// The execution can finish only if was called explicitly. -/// -public sealed class ParallelProducerConsumerStepRunner : ParallelStepRunnerBase -{ - private BlockingCollection StepQueue { get; } = new(); - - /// - /// Initializes a new instance of the class with the specified number of workers. - /// - /// The number of parallel workers. - /// The service provider. - /// If the number of workers is below 1. - /// is . - public ParallelProducerConsumerStepRunner(int workerCount, IServiceProvider serviceProvider) : base(workerCount, serviceProvider) - { - } - - /// - /// Signals this instance does not expect any more steps. - /// - public void Finish() - { - StepQueue.CompleteAdding(); - } - - /// - public override void AddStep(IStep step) - { - if (step is null) - throw new ArgumentNullException(nameof(step)); - StepQueue.Add(step, CancellationToken.None); - } - - /// - protected override bool TakeNextStep(CancellationToken cancellationToken, [NotNullWhen(true)] out IStep? step) - { - step = null; - if (StepQueue.IsCompleted) - return false; - - try - { - step = StepQueue.Take(cancellationToken); - return true; - } - catch (InvalidOperationException) - { - return false; - } - } - - /// - protected override void OnRunnerStopped() - { - Finish(); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunner.cs deleted file mode 100644 index 637fc80b..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunner.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Threading; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -/// -/// Runner engine, which executes all queued steps parallel. -/// -public class ParallelStepRunner : ParallelStepRunnerBase -{ - private ConcurrentQueue StepQueue { get; } = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The number of parallel workers. - /// The service provider. - /// If the number of workers is below 1 or above 64. - /// is . - public ParallelStepRunner(int workerCount, IServiceProvider serviceProvider) : base(workerCount, serviceProvider) - { - } - - /// - public override void AddStep(IStep step) - { - if (step == null) - throw new ArgumentNullException(nameof(step)); - StepQueue.Enqueue(step); - } - - /// - protected override bool TakeNextStep(CancellationToken cancellationToken, [NotNullWhen(true)] out IStep? step) - { - return StepQueue.TryDequeue(out step); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunnerBase.cs b/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunnerBase.cs deleted file mode 100644 index aa662d5d..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Runners/ParallelStepRunnerBase.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -/// -/// Base class for an that allows parallel step execution on the thread pool. -/// -public abstract class ParallelStepRunnerBase : StepRunnerBase, IParallelStepRunner -{ - private readonly ConcurrentBag _exceptions; - private readonly Task[] _tasks; - - /// - /// Gets the number of parallel workers. - /// - public int WorkerCount { get; } - - /// - public AggregateException? Exception => _exceptions.Count > 0 ? new AggregateException(_exceptions) : null; - - /// - /// Initializes a new instance of the class with the specified number of workers. - /// - /// The number of parallel workers. - /// The service provider. - /// If the number of workers is below 1 or above 64. - /// is . - protected ParallelStepRunnerBase(int workerCount, IServiceProvider serviceProvider) : base(serviceProvider) - { - if (workerCount is < 1 or > 64) - throw new ArgumentOutOfRangeException(nameof(workerCount)); - WorkerCount = workerCount; - _exceptions = []; - _tasks = new Task[workerCount]; - } - - /// - public override Task RunAsync(CancellationToken token) - { - for (var index = 0; index < WorkerCount; ++index) - _tasks[index] = Task.Factory.StartNew(() => RunSteps(token), TaskCreationOptions.LongRunning); - return Task.WhenAll(_tasks); - } - - /// - public void Wait() - { - Wait(Timeout.InfiniteTimeSpan); - } - - /// - public void Wait(TimeSpan timeout) - { - if (!Task.WaitAll(_tasks, timeout)) - throw new TimeoutException(); - - var exception = Exception; - if (exception != null) - throw exception; - } - - /// - protected override void OnError(Exception exception, StepRunnerErrorEventArgs stepError) - { - _exceptions.Add(exception); - base.OnError(exception, stepError); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/ProducerConsumerStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/ProducerConsumerStepRunner.cs new file mode 100644 index 00000000..eb4f52f0 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/src/Runners/ProducerConsumerStepRunner.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; + +/// +/// Represents a step runner that processes steps using a producer-consumer pattern. +/// +/// +/// +/// This class allows steps to be added dynamically while the runner is executing. +/// +/// +/// Call to signal completion of step additions, +/// otherwise the runner will block indefinitely unless cancelled via . +/// +/// +/// Thread Safety: While this class supports adding steps while the runner is executing, it is not +/// thread-safe by design. Adding steps from different threads or concurrently with cancellation/error handling +/// may lead to race conditions where the consequence might be that steps added after cancellation or an error +/// might still get scheduled for execution. +/// +/// +public class ProducerConsumerStepRunner(int workerCount, IServiceProvider serviceProvider) + : AsyncStepRunner(workerCount, serviceProvider) +{ + private readonly Channel _stepChannel = Channel.CreateUnbounded(); + + /// + /// Adds a step to the runner for execution. + /// + /// The step to add to the runner. + /// Thrown when the is . + /// Thrown when the runner has already been finished and cannot accept new steps. + public override void AddStep(IStep step) + { + if (!TryAddStep(step)) + throw new InvalidOperationException("Runner has been finished."); + } + + /// + /// Attempts to add a step to the runner for execution. + /// + /// The step to add to the runner. + /// + /// if the step was successfully added; otherwise, . + /// + /// Thrown when the is . + public bool TryAddStep(IStep step) + { + if (step == null) + throw new ArgumentNullException(nameof(step)); + return _stepChannel.Writer.TryWrite(step); + } + + /// + /// Signals this instance does not expect any more steps. + /// + /// + /// + /// This method must be called to allow to complete normally. + /// After calling this method, attempting to add more steps via + /// will throw an . + /// + /// + /// It is safe to call this method before, during, or after execution. + /// + /// + public void Finish() + { + _stepChannel.Writer.TryComplete(); + } + + /// + /// Asynchronously retrieves the next step to be executed from the internal queue. + /// + /// A token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the next to be executed, + /// or if no more steps are available. + /// + /// + /// This method waits for a step to become available in the queue. If the queue is empty and no more steps will be added, + /// it returns . The operation can be cancelled by the provided . + /// + /// The operation is cancelled via the . + protected override async ValueTask TakeNextStepAsync(CancellationToken cancellationToken) + { + while (await _stepChannel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (_stepChannel.Reader.TryRead(out var step)) + return step; + } + return null; + } + + /// + /// Handles errors that occur during the execution of a step in the producer-consumer step runner. + /// + /// The exception that was thrown during the execution of the step. + /// + /// The containing details about the error, including the step + /// that caused the error and whether the runner should cancel further processing. + /// + /// + /// This method is invoked when an error occurs during the execution of a step. If the + /// property is set to true, the runner will terminate further processing by calling . + /// + protected override void OnError(Exception exception, StepRunnerErrorEventArgs stepError) + { + base.OnError(exception, stepError); + if (stepError.Cancel) + Finish(); + } + + /// + /// Performs cleanup actions when the runner is requested to stop execution. + /// + /// + /// This method overrides the base implementation to ensure that the runner + /// completes its processing by signaling that no more steps are expected. + /// + protected override void OnRunnerStopped() + { + base.OnRunnerStopped(); + Finish(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/SequentialStepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/SequentialStepRunner.cs new file mode 100644 index 00000000..0ea4e8ea --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/src/Runners/SequentialStepRunner.cs @@ -0,0 +1,8 @@ +using System; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; + +/// +/// A that executes steps sequentially using a single worker. +/// +public class SequentialStepRunner(IServiceProvider serviceProvider) : AsyncStepRunner(1, serviceProvider); \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/StepRunner.cs b/src/CommonUtilities.SimplePipeline/src/Runners/StepRunner.cs deleted file mode 100644 index e04e368e..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Runners/StepRunner.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -/// -/// Runner engine, which executes all queued sequentially in the order they are queued. -/// -public sealed class SequentialStepRunner : StepRunnerBase -{ - private ConcurrentQueue StepQueue { get; } = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The service provider for this instance. - /// is . - public SequentialStepRunner(IServiceProvider services) : base(services) - { - } - - /// - public override Task RunAsync(CancellationToken token) - { - return Task.Run(() => - { - RunSteps(token); - }, CancellationToken.None); - } - - /// - public override void AddStep(IStep step) - { - if (step == null) - throw new ArgumentNullException(nameof(step)); - StepQueue.Enqueue(step); - } - - /// - protected override bool TakeNextStep(CancellationToken cancellationToken, [NotNullWhen(true)] out IStep? step) - { - return StepQueue.TryDequeue(out step); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Runners/StepRunnerBase.cs b/src/CommonUtilities.SimplePipeline/src/Runners/StepRunnerBase.cs deleted file mode 100644 index 2db72601..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Runners/StepRunnerBase.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Runners; - -/// -/// Base class for an . -/// -public abstract class StepRunnerBase : IStepRunner -{ - /// - public event EventHandler? Error; - - /// - /// Gets a modifiable bag of all executed steps. - /// - protected readonly ConcurrentBag ExecutedStepsBag = []; - - /// - /// Gets the logger instance of this stepRunner. - /// - protected ILogger? Logger { get; } - - /// - public IReadOnlyCollection ExecutedSteps => ExecutedStepsBag.ToArray(); - - internal bool IsCancelled { get; private set; } - - /// - /// Initializes a new instance of the class. - /// - /// The service provider for this instance. - /// is . - protected StepRunnerBase(IServiceProvider services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - Logger = services.GetService()?.CreateLogger(GetType()); - } - - /// - public abstract Task RunAsync(CancellationToken token); - - /// - public abstract void AddStep(IStep step); - - /// - /// Tries to get the next step from the step queue. - /// - /// The cancellation token - /// When this method returns, contains the next step to execute, or if there is no step to execute. - /// when there exists a next step; otherwise, . - protected abstract bool TakeNextStep(CancellationToken cancellationToken, [NotNullWhen(true)] out IStep? step); - - /// - /// Takes steps from the step queue and executes it. - /// - /// The cancellation token. - protected void RunSteps(CancellationToken token) - { - var alreadyCancelled = false; - try - { - while (TakeNextStep(token, out var step)) - { - try - { - ThrowIfCancelled(token); - - ExecutedStepsBag.Add(step); - step.Run(token); - } - catch (StopRunnerException) - { - OnRunnerStopped(); - Logger?.LogTrace("Stop subsequent steps"); - break; - } - catch (Exception e) - { - if (!alreadyCancelled) - { - if (e.IsExceptionType()) - Logger?.LogTrace("Step {Step} cancelled", step); - else - Logger?.LogTrace(e, "Step {Step} threw an exception: {Exception}: {EMessage}", step, e.GetType(), e.Message); - } - - var error = new StepRunnerErrorEventArgs(e, step) - { - Cancel = token.IsCancellationRequested || IsCancelled || e.IsExceptionType() - }; - if (error.Cancel) - alreadyCancelled = true; - OnError(e, error); - } - } - } - catch (OperationCanceledException e) - { - OnError(e, new StepRunnerErrorEventArgs(e, null)); - IsCancelled = true; - } - } - - /// - /// Allows an overriding class to handle step errors and raises the event. - /// - /// The exception that caused the error. - /// The event args to use. - protected virtual void OnError(Exception exception, StepRunnerErrorEventArgs stepError) - { - Error?.Invoke(this, stepError); - if (!stepError.Cancel) - return; - IsCancelled |= stepError.Cancel; - } - - /// - /// Throws an if the given token was requested for cancellation. - /// - /// The token to check for cancellation. - /// If the token was requested for cancellation. - protected void ThrowIfCancelled(CancellationToken token) - { - token.ThrowIfCancellationRequested(); - if (IsCancelled) - throw new OperationCanceledException(token); - } - - /// - /// Allows an overriding class to perform cleanup actions once the runner was requested to stop execution. - /// - protected virtual void OnRunnerStopped() - { - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/StepFailureException.cs b/src/CommonUtilities.SimplePipeline/src/StepFailureException.cs index f5ef830b..5955fd5b 100644 --- a/src/CommonUtilities.SimplePipeline/src/StepFailureException.cs +++ b/src/CommonUtilities.SimplePipeline/src/StepFailureException.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text; namespace AnakinRaW.CommonUtilities.SimplePipeline; @@ -10,7 +11,13 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// public sealed class StepFailureException : Exception { - private readonly IEnumerable _failedSteps; + /// + /// Gets the collection of steps that failed. + /// + /// + /// A read-only collection of instances representing the failed steps. + /// + public IReadOnlyCollection FailedSteps { get; } /// public override string Message => Error; @@ -22,11 +29,14 @@ private string Error { if (field is not null) return field; - - var stringBuilder = new StringBuilder(); - - foreach (var step in _failedSteps) - stringBuilder.Append($"Step '{step}' failed with error: {step.Error?.Message};"); + var stringBuilder = new StringBuilder($"{FailedSteps.Count} Failed Step(s)"); + if (FailedSteps.Count > 0) + { + stringBuilder.Append(':'); + stringBuilder.Append(' '); + } + foreach (var step in FailedSteps) + stringBuilder.Append($"Step '{step}' failed with error: {step.Error?.Message ?? "n/a"};"); field = stringBuilder.ToString().TrimEnd(';'); return field; } @@ -38,6 +48,8 @@ private string Error /// The failed steps. public StepFailureException(IEnumerable failedSteps) { - _failedSteps = failedSteps ?? throw new ArgumentNullException(nameof(failedSteps)); + if (failedSteps == null) + throw new ArgumentNullException(nameof(failedSteps)); + FailedSteps = failedSteps.ToList(); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Steps/AsyncStep.cs b/src/CommonUtilities.SimplePipeline/src/Steps/AsyncStep.cs deleted file mode 100644 index a14b6de8..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Steps/AsyncStep.cs +++ /dev/null @@ -1,86 +0,0 @@ -//using Microsoft.Extensions.DependencyInjection; -//using Microsoft.Extensions.Logging; -//using System; -//using System.Runtime.CompilerServices; -//using System.Threading; -//using System.Threading.Tasks; - -//namespace AnakinRaW.CommonUtilities.StepRunnerPipeline.Steps; - -///// -///// A that can be awaited on. -///// -//public abstract class AsyncStep : DisposableObject, IStep -//{ -// private readonly TaskCompletionSource _taskCompletion = new(); - -// /// -// /// Gets the service provider of this step. -// /// -// protected IServiceProvider Services { get; } - -// /// -// /// Gets the logger of this step. -// /// -// protected ILogger? Logger { get; } - -// /// -// public Exception? Error -// { -// get -// { -// if (_taskCompletion.Task.IsFaulted) -// return _taskCompletion.Task.Exception?.InnerException; - -// if (!_taskCompletion.Task.IsCompleted) -// return null; - -// if (_taskCompletion.Task.Result.IsFaulted) -// return _taskCompletion.Task.Result.Exception?.InnerException; - -// return null; -// } -// } - -// /// -// /// Initializes a new instance of the class. -// /// -// /// The service provider. -// /// is . -// protected AsyncStep(IServiceProvider serviceProvider) -// { -// Services = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); -// Logger = serviceProvider.GetService()?.CreateLogger(GetType()); -// } - -// /// -// /// Gets an awaiter used to await this . -// /// -// /// An awaiter instance. -// public TaskAwaiter GetAwaiter() -// { -// if (_taskCompletion.Task.IsCompleted) -// return _taskCompletion.Task.Result.GetAwaiter(); - -// return Task.Run(async () => -// { -// var task = await _taskCompletion.Task.ConfigureAwait(false); -// await task.ConfigureAwait(false); -// }).GetAwaiter(); -// } - -// /// -// /// Run the step's action and returns the operation as a task reference. -// /// -// /// -// /// The task that represents the operation of this step. -// protected abstract Task RunAsync(CancellationToken token); - -// /// -// public void Run(CancellationToken token) -// { -// Logger?.LogTrace($"BEGIN on thread-pool: {this}"); -// var task = RunAsync(token); -// _taskCompletion.SetResult(task); -// } -//} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Steps/PipelineStep.cs b/src/CommonUtilities.SimplePipeline/src/Steps/PipelineStep.cs index 94f4c41d..b57de664 100644 --- a/src/CommonUtilities.SimplePipeline/src/Steps/PipelineStep.cs +++ b/src/CommonUtilities.SimplePipeline/src/Steps/PipelineStep.cs @@ -1,8 +1,9 @@ -using System; -using System.Linq; -using System.Threading; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; namespace AnakinRaW.CommonUtilities.SimplePipeline.Steps; @@ -11,6 +12,9 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline.Steps; /// public abstract class PipelineStep : DisposableObject, IStep { + private readonly TaskCompletionSource _completionSource = new(); + private Task? _exposedTask; + /// /// Returns the service provider of this step. /// @@ -22,9 +26,28 @@ public abstract class PipelineStep : DisposableObject, IStep protected readonly ILogger? Logger; /// - /// Gets the exception that occurred during execution or if no error occurred. + /// Gets the exception that occurred during the execution of the step, if any. /// - public Exception? Error { get; internal set; } + /// + /// + /// If the step is cancelled by an + /// (which may also be wrapped inside an ), + /// this property contains the underlying cause of the cancellation when available, + /// otherwise it may be . + /// + /// + /// If the step throws a , this property is always . + /// This is because a does not indicate a failure of the step itself. + /// + /// + /// For all other failures, this property contains the exception that caused + /// the step to fail. + /// + /// + public Exception? Error { get; private set; } + + /// + public bool IsCancelled { get; private set; } /// /// Initializes a new instance of the class. @@ -36,55 +59,112 @@ protected PipelineStep(IServiceProvider serviceProvider) Services = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); Logger = serviceProvider.GetService()?.CreateLogger(GetType()); } - + + /// + public Task RunAsync(CancellationToken token) + { + var task = ExecuteStepAsync(token); + _completionSource.TrySetResult(task); + return GetStepTask(); + } + + /// + public TaskAwaiter GetAwaiter() + { + return GetStepTask().GetAwaiter(); + } + /// - public void Run(CancellationToken token) + public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) + { + return GetStepTask().ConfigureAwait(continueOnCapturedContext); + } + + /// + /// Returns a string that represents the current instance. + /// + /// + /// A string that represents the current instance, typically the name of the step's type. + /// + public override string ToString() + { + return GetType().Name; + } + + /// 0 + /// Executes this step. + /// + /// Provided to allow cancellation. + protected abstract Task RunCoreAsync(CancellationToken token); + + private async Task CreateAwaitableTask() + { + var task = await _completionSource.Task.ConfigureAwait(false); + await task.ConfigureAwait(false); + } + + private Task GetStepTask() + { + var existing = Volatile.Read(ref _exposedTask); + if (existing is not null) + return existing; + + var tcsTask = _completionSource.Task; + if (tcsTask is { IsCompleted: true, Status: TaskStatus.RanToCompletion }) + { + var result = tcsTask.Result; + var original = Interlocked.CompareExchange(ref _exposedTask, result, null); + return original ?? result; + } + + var newTask = CreateAwaitableTask(); + var prev = Interlocked.CompareExchange(ref _exposedTask, newTask, null); + return prev ?? newTask; + } + + private async Task ExecuteStepAsync(CancellationToken token) { Logger?.LogTrace("BEGIN: {Step}", this); try { - RunCore(token); + await RunCoreAsync(token).ConfigureAwait(false); Logger?.LogTrace("END: {Step}", this); } catch (OperationCanceledException ex) { Error = ex.InnerException; + IsCancelled = true; throw; } catch (StopRunnerException) { throw; } - catch (AggregateException ex) + catch (AggregateException e) { - if (!ex.IsExceptionType()) - LogFaultException(ex); + if (e.IsExceptionType()) + { + Error = e.FindException()?.InnerException; + IsCancelled = true; + } else - Error = ex.InnerExceptions.FirstOrDefault(p => p.IsExceptionType())?.InnerException; + { + Error = e; + LogFaultException(e); + } + throw; } catch (Exception e) { + Error = e; LogFaultException(e); throw; } } - /// - public override string ToString() - { - return GetType().Name; - } - - /// - /// Executes this step. - /// - /// Provided to allow cancellation. - protected abstract void RunCore(CancellationToken token); - private void LogFaultException(Exception ex) { - Error = ex; Logger?.LogError(ex, ex.InnerException?.Message ?? ex.Message); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Steps/RunPipelineStep.cs b/src/CommonUtilities.SimplePipeline/src/Steps/RunPipelineStep.cs index 7a13f29b..85f243ad 100644 --- a/src/CommonUtilities.SimplePipeline/src/Steps/RunPipelineStep.cs +++ b/src/CommonUtilities.SimplePipeline/src/Steps/RunPipelineStep.cs @@ -1,34 +1,55 @@ using System; -using System.Linq; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace AnakinRaW.CommonUtilities.SimplePipeline.Steps; /// -/// A step that executes a pipeline and waits for the pipeline to end. +/// Represents a pipeline step that executes a specific . /// -/// The pipeline to execute. -/// The service provider -public class RunPipelineStep(IPipeline pipeline, IServiceProvider serviceProvider) : SynchronizedStep(serviceProvider) +/// +/// This step is responsible for running the provided pipeline and managing its lifecycle, +/// including handling exceptions and disposing of resources. +/// +public sealed class RunPipelineStep : PipelineStep { - private readonly IPipeline _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + private readonly IPipeline _pipeline; - /// - protected override void RunSynchronized(CancellationToken token) + /// + /// Initializes a new instance of the class. + /// + /// The pipeline to be executed by this step. + /// The service provider used to resolve dependencies. + /// + /// Thrown when or is . + /// + public RunPipelineStep(IPipeline pipeline, IServiceProvider serviceProvider) : base(serviceProvider) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + /// + /// Executes the core logic of the pipeline step asynchronously. + /// + /// + /// This method is responsible for running the associated and handling its lifecycle. + /// It logs the start and completion of the pipeline execution, and captures any exceptions that occur during execution. + /// + /// A to observe while waiting for the task to complete. + /// A representing the asynchronous operation. + /// The pipeline execution encounters an error. + protected override async Task RunCoreAsync(CancellationToken token) { Logger?.LogTrace("Running {Pipeline}...", _pipeline); try { - // ReSharper disable once MethodSupportsCancellation - _pipeline.RunAsync(token).Wait(); + await _pipeline.RunAsync(token).ConfigureAwait(false); Logger?.LogTrace("Finished {Pipeline}", _pipeline); } - catch (AggregateException e) + catch (Exception e) { - var root = e.InnerExceptions.FirstOrDefault(); - if (root is not null) - throw root; + Logger?.LogError(e, "Pipeline {Pipeline} finished with exception: {Message}", _pipeline, e.Message); throw; } } diff --git a/src/CommonUtilities.SimplePipeline/src/Steps/SynchronizedStep.cs b/src/CommonUtilities.SimplePipeline/src/Steps/SynchronizedStep.cs deleted file mode 100644 index 618c1ba3..00000000 --- a/src/CommonUtilities.SimplePipeline/src/Steps/SynchronizedStep.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Threading; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Steps; - -/// -/// A step that can be waited for. -/// -public abstract class SynchronizedStep : PipelineStep -{ - /// - /// Event gets raised if this instance failed with an . - /// - public event EventHandler? Canceled; - - private readonly ManualResetEvent _handle; - - /// - /// Initializes a new instance of the class. - /// - /// The service provider. - /// is . - protected SynchronizedStep(IServiceProvider serviceProvider) : base(serviceProvider) - { - _handle = new ManualResetEvent(false); - } - - /// - /// Waits until the predefined stepRunner has finished. - /// - public void Wait() - { - Wait(Timeout.InfiniteTimeSpan); - } - - /// - /// Waits until the predefined stepRunner has finished. - /// - /// The time duration to wait. - /// If . - public void Wait(TimeSpan timeout) - { - if (!_handle.WaitOne(timeout)) - throw new TimeoutException(); - } - - /// - /// Executes this step. - /// - /// - protected abstract void RunSynchronized(CancellationToken token); - - /// - protected override void DisposeResources() - { - base.DisposeResources(); - _handle.Dispose(); - } - - /// - protected sealed override void RunCore(CancellationToken token) - { - try - { - RunSynchronized(token); - } - catch (Exception ex) - { - if (ex.IsExceptionType()) - Canceled?.Invoke(this, EventArgs.Empty); - throw; - } - finally - { - _handle.Set(); - } - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Steps/WaitStep.cs b/src/CommonUtilities.SimplePipeline/src/Steps/WaitStep.cs index d8a56160..8f2b4dd2 100644 --- a/src/CommonUtilities.SimplePipeline/src/Steps/WaitStep.cs +++ b/src/CommonUtilities.SimplePipeline/src/Steps/WaitStep.cs @@ -1,16 +1,17 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace AnakinRaW.CommonUtilities.SimplePipeline.Steps; /// -/// A step that waits for a given to finish. +/// A step that waits for a given to finish. /// public sealed class WaitStep : PipelineStep { - private readonly IParallelStepRunner _stepRunner; + private readonly IStepRunner _stepRunner; /// /// Initializes a new instance of the class with the specified stepRunner. @@ -18,29 +19,26 @@ public sealed class WaitStep : PipelineStep /// The step runner. /// The service provider. /// or is . - public WaitStep(IParallelStepRunner stepRunner, IServiceProvider serviceProvider) : base(serviceProvider) + public WaitStep(IStepRunner stepRunner, IServiceProvider serviceProvider) : base(serviceProvider) { _stepRunner = stepRunner ?? throw new ArgumentNullException(nameof(stepRunner)); } /// [ExcludeFromCodeCoverage] - public override string ToString() => "Waiting for other steps"; + public override string ToString() => "Waiting for other step runner"; /// /// Waits for the instance's parallel stepRunner. /// /// Provided to allow cancellation. /// If awaiting the stepRunner failed with an exception. - protected override void RunCore(CancellationToken token) + protected override async Task RunCoreAsync(CancellationToken token) { - try + await _stepRunner; + if (_stepRunner.Exception is not null) { - _stepRunner.Wait(); - } - catch - { - Logger?.LogTrace("Wait step is stopping all subsequent steps"); + Logger?.LogTrace("The other step runner finished with failed steps. Stopping current runner."); throw new StopRunnerException(); } } diff --git a/src/CommonUtilities.SimplePipeline/src/StopRunnerException.cs b/src/CommonUtilities.SimplePipeline/src/StopRunnerException.cs index 645e2690..2154c29c 100644 --- a/src/CommonUtilities.SimplePipeline/src/StopRunnerException.cs +++ b/src/CommonUtilities.SimplePipeline/src/StopRunnerException.cs @@ -2,4 +2,31 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; -internal class StopRunnerException : Exception; \ No newline at end of file +/// +/// The exception that is thrown to signal an the termination of step execution. +/// +/// +/// This exception is typically thrown by an to indicate that its associated +/// should stop executing any further steps. +/// +public sealed class StopRunnerException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// This constructor creates a default instance of the exception without any additional context or message. + /// It is typically used to signal the termination of step execution in a pipeline. + /// + public StopRunnerException() : base("Stopping step runner.") + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public StopRunnerException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj b/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj index fd1fc46e..4dc7d23b 100644 --- a/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj +++ b/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj @@ -1,10 +1,11 @@ - + net10.0;net8.0 $(TargetFrameworks);net481 false true + Exe @@ -12,20 +13,16 @@ AnakinRaW.CommonUtilities.SimplePipeline.Test - - enable - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -37,8 +34,8 @@ - + diff --git a/src/CommonUtilities.SimplePipeline/test/ConfigureAwaitTestExtensions.cs b/src/CommonUtilities.SimplePipeline/test/ConfigureAwaitTestExtensions.cs new file mode 100644 index 00000000..8526973b --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/ConfigureAwaitTestExtensions.cs @@ -0,0 +1,115 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; + +// Based on https://github.com/dotnet/runtime TaskAwaiterTests.cs + +public static class ConfigureAwaitTestExtensions +{ + public static void AwaiterAndAwaitableEquality( + Func instanceFactory, + Func awaitableFactory, + Func configuredAwaitableFactory) + { + var instance = instanceFactory(); + + // TaskAwaiter + Assert.Equal(awaitableFactory(instance), awaitableFactory(instance)); + + // ConfiguredTaskAwaitable + Assert.Equal(configuredAwaitableFactory(instance, false), configuredAwaitableFactory(instance, false)); + Assert.NotEqual(configuredAwaitableFactory(instance, false), configuredAwaitableFactory(instance, true)); + Assert.NotEqual(configuredAwaitableFactory(instance, true), configuredAwaitableFactory(instance, false)); + + // ConfiguredTaskAwaitable.ConfiguredTaskAwaiter + Assert.Equal(configuredAwaitableFactory(instance, false).GetAwaiter(), configuredAwaitableFactory(instance, false).GetAwaiter()); + Assert.NotEqual(configuredAwaitableFactory(instance, false).GetAwaiter(), configuredAwaitableFactory(instance, true).GetAwaiter()); + Assert.NotEqual(configuredAwaitableFactory(instance, true).GetAwaiter(), configuredAwaitableFactory(instance, false).GetAwaiter()); + } + + public static void TestOnCompletedCompletesInAnotherSynchronizationContext( + bool? continueOnCapturedContext, + Func instanceFactory, + Func awaitableFactory, + Func configuredAwaitableFactory, + Action completeInstance) + { + var origCtx = SynchronizationContext.Current; + try + { + var validateCtx = new ValidateCorrectContextSynchronizationContext(); + Assert.Equal(0, validateCtx.PostCount); + SynchronizationContext.SetSynchronizationContext(validateCtx); + + var mre = new ManualResetEventSlim(); + + var instance = instanceFactory(); + + // Hook up a callback + var postedInContext = false; + var callback = () => + { + postedInContext = ValidateCorrectContextSynchronizationContext.IsPostedInContext; + mre.Set(); + }; + + if (continueOnCapturedContext.HasValue) + configuredAwaitableFactory(instance, continueOnCapturedContext.Value).GetAwaiter().OnCompleted(callback); + else + awaitableFactory(instance).OnCompleted(callback); + + Assert.False(mre.IsSet, "Callback should not yet have run."); + + // Complete the task in another context and wait for the callback to run + Task.Run(() => completeInstance(instance), TestContext.Current.CancellationToken); + mre.Wait(TestContext.Current.CancellationToken); + + // Validate the callback ran and in the correct context + var shouldHavePosted = !continueOnCapturedContext.HasValue || continueOnCapturedContext.Value; + Assert.Equal(shouldHavePosted ? 1 : 0, validateCtx.PostCount); + Assert.Equal(shouldHavePosted, postedInContext); + } + finally + { + SynchronizationContext.SetSynchronizationContext(origCtx); + } + } + + private class ValidateCorrectContextSynchronizationContext : SynchronizationContext + { + [ThreadStatic] + internal static bool IsPostedInContext; + + internal int PostCount; + private int _sendCount; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref PostCount); + Task.Run(() => + { + SetSynchronizationContext(this); + try + { + IsPostedInContext = true; + d(state); + } + finally + { + IsPostedInContext = false; + SetSynchronizationContext(null); + } + }); + } + + public override void Send(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _sendCount); + d(state); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/ExtensionsTest.cs b/src/CommonUtilities.SimplePipeline/test/ExtensionsTest.cs index ba25f272..dc2ac2aa 100644 --- a/src/CommonUtilities.SimplePipeline/test/ExtensionsTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/ExtensionsTest.cs @@ -1,26 +1,168 @@ -using System; +using AnakinRaW.CommonUtilities.Testing; +using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; -public class ExtensionsTest -{ - [Fact] - public void IsExceptionType() +public class ExtensionsTest : TestBaseWithServiceProvider +{ + public static IEnumerable ExceptionSearchTestCases() { - var e = new InvalidOperationException(); + // Direct match - same type + { + var ex = new InvalidOperationException("test"); + yield return [ex, typeof(InvalidOperationException), ex]; + } + { + var ex = new IOException("test"); + yield return [ex, typeof(IOException), ex]; + } + + // Direct match - base type + { + var ex = new InvalidOperationException("test"); + yield return [ex, typeof(Exception), ex]; + } + { + var ex = new IOException("test"); + yield return [ex, typeof(Exception), ex]; + } + + // No match + yield return [new InvalidOperationException("test"), typeof(IOException), null]; + yield return [new IOException("test"), typeof(InvalidOperationException), null]; + yield return [new InvalidOperationException("test"), typeof(NullReferenceException), null]; + + // Simple AggregateException + { + var invalidOp = new InvalidOperationException("test"); + var ioEx = new IOException("test"); + var agg = new AggregateException(invalidOp, ioEx); + yield return [agg, typeof(AggregateException), agg]; + yield return [agg, typeof(InvalidOperationException), invalidOp]; + yield return [agg, typeof(IOException), ioEx]; + yield return [agg, typeof(NullReferenceException), null]; + } - Assert.True(e.IsExceptionType()); - Assert.False(e.IsExceptionType()); + // Nested AggregateException + { + var invalidOp = new InvalidOperationException("test"); + var ioEx = new IOException("test"); + var nullRef = new NullReferenceException("test"); + var innerAgg = new AggregateException(invalidOp, ioEx); + var outerAgg = new AggregateException(innerAgg, nullRef); + yield return [outerAgg, typeof(AggregateException), outerAgg]; + yield return [outerAgg, typeof(InvalidOperationException), invalidOp]; + yield return [outerAgg, typeof(IOException), ioEx]; + yield return [outerAgg, typeof(NullReferenceException), nullRef]; + yield return [outerAgg, typeof(ArgumentException), null]; + } - var io = new IOException(); - Assert.True(io.IsExceptionType()); - Assert.True(io.IsExceptionType()); + // A(A(OCE)) - OCE without inner exception + { + var oce = new OperationCanceledException("test"); + yield return [new AggregateException(new AggregateException(oce)), typeof(OperationCanceledException), oce]; + } - var a = new AggregateException(new List { e, io }); - Assert.True(a.IsExceptionType()); - Assert.True(a.IsExceptionType()); + // A(A(OCE(E))) - OCE with inner exception + { + var oce = new OperationCanceledException("test", new TimeoutException("test")); + var agg = new AggregateException(new AggregateException(oce)); + yield return [agg, typeof(OperationCanceledException), oce]; + yield return [agg, typeof(TimeoutException), null]; // Not found via InnerExceptions! + } + + // Deeply nested + { + var oce = new OperationCanceledException("test", new TimeoutException("test")); + var agg = new AggregateException( + new AggregateException( + new AggregateException( + new AggregateException(oce)))); + yield return [agg, typeof(OperationCanceledException), oce]; + yield return [agg, typeof(TimeoutException), null]; // Not found via InnerExceptions! + yield return [agg, typeof(ArgumentException), null]; + } + } + + public static IEnumerable OceInnerExceptionTestCases() + { + // Direct OCE without inner + yield return [new OperationCanceledException("test"), null]; + + // Direct OCE with inner + { + var timeout = new TimeoutException("test"); + yield return [new OperationCanceledException("test", timeout), timeout]; + } + + // A(A(OCE)) - no inner + yield return [new AggregateException(new AggregateException(new OperationCanceledException("test"))), null]; + + // A(A(OCE(E))) - with inner + { + var timeout = new TimeoutException("test"); + yield return [new AggregateException(new AggregateException(new OperationCanceledException("test", timeout))), timeout]; + } + + // Deeply nested with inner + { + var timeout = new TimeoutException("test"); + yield return [new AggregateException( + new AggregateException( + new AggregateException( + new AggregateException( + new OperationCanceledException("test", timeout))))), timeout]; + } + } + + [Theory] + [MemberData(nameof(ExceptionSearchTestCases))] + public void IsExceptionType_ReturnsExpectedResult( + Exception source, + Type searchType, + Exception? expectedFound) + { + var method = typeof(Extensions) + .GetMethod(nameof(Extensions.IsExceptionType), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(searchType); + + var result = (bool)method.Invoke(null, [source])!; + + Assert.Equal(expectedFound is not null, result); + } + + [Theory] + [MemberData(nameof(ExceptionSearchTestCases))] + public void FindException_ReturnsExpectedResult(Exception source, Type searchType, Exception? expectedFound) + { + var method = typeof(Extensions) + .GetMethod(nameof(Extensions.FindException), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(searchType); + + var result = (Exception?)method.Invoke(null, [source]); + + Assert.Same(expectedFound, result); + } + + [Theory] + [MemberData(nameof(OceInnerExceptionTestCases))] + public void FindException_OperationCanceledException_ReturnsCorrectInnerException(Exception source, Exception? expectedInner) + { + var found = source.FindException(); + Assert.Same(expectedInner, found?.InnerException); + } + + [Fact] + public void FindException_ReturnsFirstMatch_WhenMultipleExist() + { + var io1 = new IOException("First"); + var io2 = new IOException("Second"); + var aggregate = new AggregateException(io1, io2); + var found = aggregate.FindException(); + Assert.Same(io1, found); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelPipelineTests.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelPipelineTests.cs index 744dd018..8414142b 100644 --- a/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelPipelineTests.cs +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelPipelineTests.cs @@ -1,29 +1,119 @@ -using System; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; -// ReSharper disable once UnusedMember.Global -public class ParallelPipelineTests : StepRunnerPipelineTest +public class ParallelPipelineTests : StepRunnerPipelineTestBase { - protected override Pipeline CreatePipeline(IList steps) + protected override ITrackingPipeline CreateTrackingPipeline(Func prepare, Func run) { - return CreatePipeline(steps, true); + var testStep = new TestStep(run, ServiceProvider); + return new TestParallelPipeline(ServiceProvider, [testStep], prepare, GetWorkerCount(GetRandomRunBehavior()), false); } - protected override StepRunnerPipeline CreatePipeline(IList steps, bool failFast) + protected override StepRunnerPipeline CreateStepRunnerPipeline(IList steps, bool failFast, RunnerBehavior runnerBehavior) { - return new TestParallelPipeline(steps, ServiceProvider, failFast: failFast); + return new TestParallelPipeline(ServiceProvider, steps, null, GetWorkerCount(runnerBehavior), failFast); } - - private class TestParallelPipeline(IList steps, IServiceProvider serviceProvider, int workerCount = 4, bool failFast = true) - : ParallelPipeline(serviceProvider, workerCount, failFast) + + protected override StepRunnerPipeline CreateTrackingStepRunnerPipeline( + IList steps, bool failFast, RunnerBehavior runnerBehavior, List callOrder, + string? throwOnMethod = null, Func, IEnumerable>? filterErrorStepsFunc = null) + { + return new TrackingParallelPipeline(ServiceProvider, steps, GetWorkerCount(runnerBehavior), failFast, callOrder, + throwOnMethod, filterErrorStepsFunc); + } + + #region Constructor Tests + + [Fact] + public void Ctor_NullServiceProvider_Throws() + { + Assert.Throws(() => new TestParallelPipeline(null!, [], null)); + } + + #endregion + + private class TestParallelPipeline : StepRunnerPipeline, ITrackingPipeline + { + private readonly IList _steps; + private readonly int _workerCount; + private readonly Func? _prepareAction; + + public TestParallelPipeline( + IServiceProvider serviceProvider, + IEnumerable steps, + Func? onPrepare, + int workerCount = 4, + bool failFast = true) + : base(serviceProvider) + { + _steps = steps.ToList(); + _workerCount = workerCount; + FailFast = failFast; + _prepareAction = onPrepare; + } + + protected override IStepRunner CreateRunner() + { + return new AsyncStepRunner(_workerCount, ServiceProvider); + } + + protected override async Task> CreateRunnerSteps(CancellationToken token) + { + if (_prepareAction is not null) + await _prepareAction(token); + return _steps; + } + } + + private class TrackingParallelPipeline : StepRunnerPipeline { - protected override Task> BuildSteps() + private readonly IList _steps; + private readonly int _workerCount; + private readonly Func, IEnumerable>? _filterErrorStepsFunc; + private readonly TrackingPipelineHelper _helper; + + public TrackingParallelPipeline( + IServiceProvider serviceProvider, + IList steps, + int workerCount, + bool failFast, + List callOrder, + string? throwOnMethod, + Func, IEnumerable>? filterErrorStepsFunc) + : base(serviceProvider) + { + _steps = steps; + _workerCount = workerCount; + _filterErrorStepsFunc = filterErrorStepsFunc; + _helper = new TrackingPipelineHelper(callOrder, throwOnMethod); + FailFast = failFast; + } + + protected override IEnumerable GetFailedSteps(IEnumerable steps) + { + return _filterErrorStepsFunc is null ? base.GetFailedSteps(steps) : _filterErrorStepsFunc(steps); + } + + protected override IStepRunner CreateRunner() { - return Task.FromResult(steps); + return new AsyncStepRunner(_workerCount, ServiceProvider); } + + protected override Task> CreateRunnerSteps(CancellationToken token) + { + return Task.FromResult(_steps); + } + + protected override void OnExecuteStarted() => _helper.OnExecuteStarted(); + protected override void OnRunnerExecuted() => _helper.OnRunnerExecuted(); + protected override void OnExecuteCompleted() => _helper.OnExecuteCompleted(); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelProducerConsumerPipelineTest.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelProducerConsumerPipelineTest.cs deleted file mode 100644 index e899338a..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Pipelines/ParallelProducerConsumerPipelineTest.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; - -public class ParallelProducerConsumerPipelineTest : PipelineTest -{ - [Fact] - public async Task RunAsync_DelayedAdd() - { - var tcs = new TaskCompletionSource(); - - var s1 = new TestStep(_ => - { - Task.Delay(3000).Wait(); - tcs.SetResult(0); - }, ServiceProvider); - - var s2Run = false; - var s2 = new TestStep(_ => - { - s2Run = true; - }, ServiceProvider); - - var pipeline = CreateConsumerPipeline(ValueFunction()); - - await pipeline.RunAsync(); - - Assert.True(s2Run); - - return; - - async IAsyncEnumerable ValueFunction() - { - yield return s1; - await tcs.Task; - yield return s2; - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task RunAsync_DelayedAdd_PrepareFails(bool failFast) - { - var mre = new ManualResetEventSlim(false); - - var ran = false; - var s1 = new TestStep(ct => - { - mre.Wait(ct); - ct.ThrowIfCancellationRequested(); - ran = true; - - }, ServiceProvider); - var s2 = new TestStep(_ => { }, ServiceProvider); - - var pipeline = CreateConsumerPipeline(ValueFunction(), failFast); - - var task = Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - - if (failFast) - await task; - - mre.Set(); - - await task; - - if (failFast) - Assert.False(ran); - else - Assert.True(ran); - - return; - -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - async IAsyncEnumerable ValueFunction() -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - { - yield return s1; - yield return s2; - throw new ApplicationException("test"); - } - } - - [Fact] - public async Task PrepareAsync_PrepareFails() - { - var s1 = new TestStep(_ => { }, ServiceProvider); - var s2 = new TestStep(_ => { }, ServiceProvider); - - var pipeline = CreateConsumerPipeline(ValueFunction()); - - await Assert.ThrowsAsync(pipeline.PrepareAsync); - - return; - -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - async IAsyncEnumerable ValueFunction() -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously - { - yield return s1; - yield return s2; - throw new ApplicationException("test"); - } - } - - - [Fact] - public async Task RunAsync_PrepareCancelled() - { - var cts = new CancellationTokenSource(); - var tcs = new TaskCompletionSource(); - - var s1 = new TestStep(_ => - { - Task.Delay(3000).Wait(); - tcs.SetResult(0); - }, ServiceProvider); - - var s2Run = false; - var s2 = new TestStep(_ => - { - s2Run = true; - }, ServiceProvider); - - var pipeline = CreateConsumerPipeline(ValueFunction()); - - await Assert.ThrowsAsync(async () => await pipeline.RunAsync(cts.Token)); - - Assert.False(s2Run); - - return; - - async IAsyncEnumerable ValueFunction() - { - yield return s1; - await tcs.Task; - cts.Cancel(); - await Task.Delay(1000); - yield return s2; - } - } - - protected override Pipeline CreatePipeline(IList steps) - { - return new TestParallelProducerConsumerPipeline(steps.ToAsyncEnumerable(), 4, true, ServiceProvider); - } - - private Pipeline CreateConsumerPipeline(IAsyncEnumerable steps, bool failFast = true) - { - return new TestParallelProducerConsumerPipeline(steps, 4, failFast, ServiceProvider); - } - - private class TestParallelProducerConsumerPipeline( - IAsyncEnumerable steps, - int workerCount, - bool failFast, - IServiceProvider serviceProvider) - : ParallelProducerConsumerPipeline(workerCount, failFast, serviceProvider) - { - protected override IAsyncEnumerable BuildSteps() - { - return steps; - } - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs index be3a333c..1a564a03 100644 --- a/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs @@ -2,168 +2,45 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.Testing; -using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; -public abstract class PipelineTest : CommonTestBase +public class PipelineTest : PipelineTestSuite { - protected abstract Pipeline CreatePipeline(IList steps); - - [Fact] - public async Task Prepare() + protected override Pipeline CreatePipeline(IList steps) { - var s = new TestStep(_ => { }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - await pipeline.PrepareAsync(); - await pipeline.PrepareAsync(); + return new TestPipeline( + _ => Task.CompletedTask, + async ct => + { + foreach (var step in steps) + { + await step.RunAsync(ct); + } + }, + ServiceProvider); } - [Fact] - public async Task Dispose() + protected override ITrackingPipeline CreateTrackingPipeline( + Func prepare, + Func run) { - var pipeline = CreatePipeline([]); - - pipeline.Dispose(); - - Assert.True(pipeline.IsDisposed); - await Assert.ThrowsAsync(pipeline.PrepareAsync); - await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); + return new TestPipeline(prepare, run, ServiceProvider); } - [Fact] - public async Task Run_RunMultipleTimesDoesNotPrepareAgain_StepRunOnlyOnce() + private class TestPipeline( + Func prepare, + Func run, + IServiceProvider serviceProvider) : Pipeline(serviceProvider), ITrackingPipeline { - var counter = 0; - var s = new TestStep(_ => { counter++; }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - await pipeline.RunAsync(); - await pipeline.RunAsync(); - - Assert.Equal(1, counter); - } - - [Fact] - public async Task PrepareThenRun() - { - var counter = 0; - var s = new TestStep(_ => { counter++; }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - await pipeline.PrepareAsync(); - await pipeline.RunAsync(); - Assert.Equal(1, counter); - } - - [Fact] - public async Task Run_Cancelled_ThrowsOperationCanceledException() - { - var counter = 0; - var s = new TestStep(_ => { counter++; }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - await Assert.ThrowsAsync(async () => await pipeline.RunAsync(cts.Token)); - Assert.Equal(0, counter); - } - - [Fact] - public async Task Prepare_Disposed_ThrowsObjectDisposedException() - { - var counter = 0; - var s = new TestStep(_ => { counter++; }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - pipeline.Dispose(); - pipeline.Dispose(); - - await Assert.ThrowsAsync(pipeline.PrepareAsync); - await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - - Assert.Equal(0, counter); - Assert.False(pipeline.PipelineFailed); - } - - [Fact] - public async Task Run_Disposed_ThrowsObjectDisposedException() - { - var counter = 0; - var s = new TestStep(_ => { counter++; }, ServiceProvider); - var pipeline = CreatePipeline([s]); - - await pipeline.PrepareAsync(); - pipeline.Dispose(); - - await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - Assert.Equal(0, counter); - Assert.False(pipeline.PipelineFailed); - } - - [Fact] - public async Task Cancel() - { - var waitToCancel = new TaskCompletionSource(); - var waitUntilCanceled = new ManualResetEvent(false); - - var token = CancellationToken.None; - - var step = new TestStep(ct => + protected override Task PrepareCoreAsync(CancellationToken token) { - token = ct; - waitToCancel.SetResult(0); - waitUntilCanceled.WaitOne(); - - }, ServiceProvider); - - var pipeline = CreatePipeline([step]); - - await pipeline.PrepareAsync(); - - var pipelineTask = pipeline.RunAsync(CancellationToken.None); - await waitToCancel.Task; - pipeline.Cancel(); - waitUntilCanceled.Set(); - - await Assert.ThrowsAsync(async () => await pipelineTask); - - Assert.True(pipeline.PipelineFailed); - - Assert.True(token.IsCancellationRequested); - } + return prepare(token); + } - [Fact] - public async Task Cancel_BeforeRun_HasNoEffect() - { - var ran = false; - var step = new TestStep(_ => ran = true, ServiceProvider); - - var pipeline = CreatePipeline([step]); - - await pipeline.PrepareAsync(); - pipeline.Cancel(); - - await pipeline.RunAsync(CancellationToken.None); - Assert.False(pipeline.PipelineFailed); - Assert.True(ran); - } - - [Fact] - public async Task RunAsync_TokenCancelledBeforeRun() - { - var ran = false; - var step = new TestStep(_ => ran = true, ServiceProvider); - - var pipeline = CreatePipeline([step]); - - await pipeline.PrepareAsync(); - - await Assert.ThrowsAsync(async () => await pipeline.RunAsync(new CancellationToken(true))); - - Assert.True(pipeline.PipelineFailed); - Assert.False(ran); + protected override Task ExecuteAsync(CancellationToken token) + { + return Task.Run(() => run(token), CancellationToken.None); + } } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTestSuite.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTestSuite.cs new file mode 100644 index 00000000..5c782490 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTestSuite.cs @@ -0,0 +1,728 @@ +using AnakinRaW.CommonUtilities.Testing; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +public abstract class PipelineTestSuite : TestBaseWithServiceProvider +{ + protected abstract Pipeline CreatePipeline(IList steps); + + protected abstract ITrackingPipeline CreateTrackingPipeline( + Func prepare, + Func run); + + protected virtual ITrackingPipeline CreateTrackingPipeline(Action prepare, Action run) + { + return CreateTrackingPipeline( + _ => Task.Run(prepare, CancellationToken.None), + _ => Task.Run(run, CancellationToken.None)); + } + + #region Initialization + + [Fact] + public void Ctor_WithValidServiceProvider_InitializesCorrectly() + { + var pipeline = CreatePipeline([]); + Assert.False(pipeline.Failed); + Assert.False(pipeline.Cancelled); + Assert.False(pipeline.IsDisposed); + Assert.False(pipeline.IsPrepared); + } + + #endregion + + #region PrepareAsync Tests + + [Fact] + public async Task PrepareAsync_CalledOnce_Succeeds() + { + var pipeline = CreatePipeline([]); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + + Assert.False(pipeline.Failed); + Assert.True(pipeline.IsPrepared); + } + + [Fact] + public async Task PrepareAsync_CalledMultipleTimes_ThrowsInvalidOperationException() + { + var prepareCount = 0; + var pipeline = CreateTrackingPipeline(() => prepareCount++, () => { }); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, prepareCount); + } + + [Fact] + public async Task PrepareAsync_ConcurrentCalls_OnlyFirstSucceeds() + { + var prepareCount = 0; + var barrier = new TaskCompletionSource(); + + var pipeline = CreateTrackingPipeline(async _ => + { + Interlocked.Increment(ref prepareCount); + await barrier.Task; + }, _ => Task.CompletedTask); + + var firstTask = pipeline.PrepareAsync(CancellationToken.None); + + await Task.Delay(50, TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + pipeline.PrepareAsync(CancellationToken.None)); + + barrier.SetResult(true); + await firstTask; + + Assert.Equal(1, prepareCount); + } + + [Fact] + public async Task PrepareAsync_FailedPreparation_CannotRetry() + { + var callCount = 0; + var pipeline = CreateTrackingPipeline(_ => + { + callCount++; + throw new ArgumentException("Preparation failed"); + }, _ => Task.CompletedTask); + + await Assert.ThrowsAsync(() => + pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + await Assert.ThrowsAsync(() => + pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, callCount); + Assert.False(((Pipeline)pipeline).IsPrepared); + } + + [Fact] + public async Task PrepareAsync_Cancelled_ThrowsOperationCancelledException() + { + var mre = new ManualResetEventSlim(false); + var cts = new CancellationTokenSource(); + + var pipeline = CreateTrackingPipeline(async ct => + { + await Task.Yield(); + mre.Wait(TestContext.Current.CancellationToken); + ct.ThrowIfCancellationRequested(); + }, _ => Task.CompletedTask); + + var prepareTask = pipeline.PrepareAsync(cts.Token); + cts.Cancel(); + mre.Set(); + + await Assert.ThrowsAsync(async () => await prepareTask); + Assert.False(((Pipeline)pipeline).IsPrepared); + } + + [Fact] + public async Task PrepareAsync_Disposed_ThrowsObjectDisposedException() + { + var pipeline = CreatePipeline([]); + pipeline.Dispose(); + + await Assert.ThrowsAsync( + () => pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PrepareAsync_WithCancelledToken_ThrowsOperationCanceledException() + { + var pipeline = CreatePipeline([]); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => pipeline.PrepareAsync(cts.Token)); + Assert.False(pipeline.IsPrepared); + } + + #endregion + + #region RunAsync Tests + + [Fact] + public async Task RunAsync_WithoutExplicitPrepare_PreparesAutomatically() + { + var prepareCount = 0; + var runCount = 0; + var pipeline = CreateTrackingPipeline(() => prepareCount++, () => runCount++); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, prepareCount); + Assert.Equal(1, runCount); + Assert.True(((Pipeline)pipeline).IsPrepared); + } + + [Fact] + public async Task RunAsync_PreparationThrowsNonCancellationException_SetsPipelineFailed() + { + var pipeline = CreateTrackingPipeline( + _ => throw new ArgumentException("Preparation failed"), + _ => Task.CompletedTask); + + await Assert.ThrowsAsync(() => + pipeline.RunAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RunAsync_PreparationFailed_RethrowsPreparationException() + { + var pipeline = CreateTrackingPipeline( + _ => throw new ArgumentException("Preparation failed"), + _ => Task.CompletedTask); + + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RunAsync_PreparationThrowsOperationCanceledException_SetsPipelineCancelled() + { + var pipeline = CreateTrackingPipeline( + _ => throw new OperationCanceledException(), + _ => Task.CompletedTask); + + await Assert.ThrowsAsync(() => + pipeline.RunAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RunAsync_CalledMultipleTimes_ThrowsInvalidOperationException() + { + var counter = 0; + var s = new TestStep(_ => + { + counter++; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([s]); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(async () => await pipeline.RunAsync(TestContext.Current.CancellationToken)); + + // RunAsync also prepares the pipeline, thus this throws too + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, counter); + } + + [Fact] + public async Task RunAsync_CalledWhilePrepareAsyncInProgress_WaitsForSamePreparation() + { + var preparationStarted = new TaskCompletionSource(); + var continuePreparation = new TaskCompletionSource(); + var executed = false; + var prepareCallCount = 0; + + var pipeline = CreateTrackingPipeline(Prepare, _ => + { + executed = true; + return Task.CompletedTask; + }); + + // Start PrepareAsync but don't await completion + var prepareTask = pipeline.PrepareAsync(CancellationToken.None); + + await preparationStarted.Task; + + var runTask = pipeline.RunAsync(CancellationToken.None); + + continuePreparation.SetResult(true); + + await prepareTask; + await runTask; + + Assert.Equal(1, prepareCallCount); + Assert.True(executed); + return; + + async Task Prepare(CancellationToken token) + { + Interlocked.Increment(ref prepareCallCount); + preparationStarted.SetResult(true); + await continuePreparation.Task; + } + } + + [Fact] + public async Task RunAsync_ConcurrentCalls_OnlyFirstSucceeds() + { + var runCount = 0; + var barrier = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + Interlocked.Increment(ref runCount); + await barrier.Task; + }, ServiceProvider); + + var pipeline = CreatePipeline([step]); + + var firstTask = pipeline.RunAsync(CancellationToken.None); + + await Task.Delay(50, TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync(() => + pipeline.RunAsync(CancellationToken.None)); + + barrier.SetResult(true); + await firstTask; + + Assert.Equal(1, runCount); + } + + [Fact] + public async Task RunAsync_AfterPrepare_ExecutesStep() + { + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([step]); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.True(executed); + } + + [Fact] + public async Task RunAsync_Successful_PipelineFailedAndCancelledIsFalse() + { + var s = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var pipeline = CreatePipeline([s]); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.False(pipeline.Failed); + Assert.False(pipeline.Cancelled); + } + + [Fact] + public async Task RunAsync_ThrowsNonCancellationException_SetsPipelineFailed() + { + var step = new TestStep(_ => throw new InvalidOperationException("Test error"), ServiceProvider); + var pipeline = CreatePipeline([step]); + + var record = await Record.ExceptionAsync(async () => await pipeline.RunAsync(TestContext.Current.CancellationToken)); + Assert.NotNull(record); + + + Assert.True(pipeline.Failed); + Assert.False(pipeline.Cancelled); + } + + [Fact] + public async Task RunAsync_ThrowsTaskCanceledException_SetsPipelineCancelled() + { + var step = new TestStep(_ => throw new TaskCanceledException(), ServiceProvider); + var pipeline = CreatePipeline([step]); + + await Assert.ThrowsAnyAsync(() => + pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Cancelled); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task RunAsync_ThrowsOperationCanceledException_SetsPipelineCancelled() + { + var step = new TestStep(_ => throw new OperationCanceledException(), ServiceProvider); + var pipeline = CreatePipeline([step]); + + await Assert.ThrowsAsync(() => + pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Cancelled); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task RunAsync_CancelledDuringPreparationViaExternalToken_SetsPipelineCancelled() + { + using var cts = new CancellationTokenSource(); + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + + var pipeline = CreateTrackingPipeline(async ct => + { + await Task.Yield(); + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + ct.ThrowIfCancellationRequested(); + }, _ => Task.CompletedTask); + + var runTask = pipeline.RunAsync(cts.Token); + await waitToCancel.Task; + + cts.Cancel(); + waitUntilCanceled.Set(); + + await Assert.ThrowsAnyAsync(() => runTask); + } + + [Fact] + public async Task RunAsync_ExternalTokenCancelledDuringExecution_SetsPipelineCancelled() + { + using var cts = new CancellationTokenSource(); + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + + var step = new TestStep(async ct => + { + await Task.Yield(); + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + ct.ThrowIfCancellationRequested(); + }, ServiceProvider); + + var pipeline = CreatePipeline([step]); + + var runTask = pipeline.RunAsync(cts.Token); + await waitToCancel.Task; + + cts.Cancel(); + waitUntilCanceled.Set(); + + await Assert.ThrowsAsync(() => runTask); + Assert.True(pipeline.Cancelled); + Assert.False(pipeline.Failed); + } + + #endregion + + #region Dispose Tests + + [Fact] + public async Task Dispose_DisposedPipeline_ThrowsOnPrepare() + { + var pipeline = CreatePipeline([]); + pipeline.Dispose(); + + Assert.True(pipeline.IsDisposed); + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Dispose_DisposedPipeline_ThrowsOnRun() + { + var pipeline = CreatePipeline([]); + pipeline.Dispose(); + + Assert.True(pipeline.IsDisposed); + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Dispose_CalledMultipleTimes_NoEffect() + { + var counter = 0; + var s = new TestStep(_ => + { + counter++; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([s]); + + pipeline.Dispose(); + pipeline.Dispose(); + pipeline.Dispose(); + + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, counter); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task Dispose_AfterPrepare_ThrowsOnRun() + { + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([step]); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + pipeline.Dispose(); + + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.False(executed); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task Dispose_AfterSuccessfulRun_NoException() + { + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var pipeline = CreatePipeline([step]); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + pipeline.Dispose(); + + Assert.True(pipeline.IsDisposed); + } + + [Fact] + public async Task Dispose_AfterFailedRun_NoException() + { + var step = new TestStep(_ => throw new InvalidOperationException(), ServiceProvider); + var pipeline = CreatePipeline([step]); + + var record = await Record.ExceptionAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + Assert.NotNull(record); + + pipeline.Dispose(); + + Assert.True(pipeline.IsDisposed); + } + + [Fact] + public async Task Dispose_DuringPreparation_ThrowsInvalidOperationException() + { + var preparationStarted = new TaskCompletionSource(); + var preparationDelay = new TaskCompletionSource(); + + var pipeline = CreateTrackingPipeline(async _ => + { + preparationStarted.SetResult(true); + await preparationDelay.Task; + }, _ => Task.CompletedTask); + + pipeline.PrepareAsync(TestContext.Current.CancellationToken).Forget(); + + await preparationStarted.Task; + + Assert.Throws(() => pipeline.Dispose()); + // The rest is undefined behavior... + } + + [Fact] + public void Dispose_DuringExecution_ThrowsInvalidOperationException() + { + var disposed = new TaskCompletionSource(); + var step = new TestStep(async _ => + { + await disposed.Task; + }, ServiceProvider); + var pipeline = CreatePipeline([step]); + + pipeline.RunAsync(TestContext.Current.CancellationToken).Forget(); + + Assert.Throws(() => pipeline.Dispose()); + // The rest is undefined behavior... + } + + #endregion + + #region Cancel Tests + + [Fact] + public async Task Cancel_DuringPreparation_HasNoEffect() + { + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + + var pipeline = CreateTrackingPipeline(async ct => + { + await Task.Yield(); + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + ct.ThrowIfCancellationRequested(); + }, _ => Task.CompletedTask); + + var prepareTask = pipeline.PrepareAsync(CancellationToken.None); + await waitToCancel.Task; + + pipeline.Cancel(); + waitUntilCanceled.Set(); + + var e = await Record.ExceptionAsync(() => prepareTask); + Assert.Null(e); + } + + [Fact] + public async Task Cancel_DuringWaitForPreparation_CancelsPipeline() + { + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + + var pipeline = CreateTrackingPipeline(async ct => + { + await Task.Yield(); + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + ct.ThrowIfCancellationRequested(); + }, _ => Task.CompletedTask); + + var runTask = pipeline.RunAsync(CancellationToken.None); + await waitToCancel.Task; + + pipeline.Cancel(); + waitUntilCanceled.Set(); + + await Assert.ThrowsAnyAsync(() => runTask); + } + + [Fact] + public async Task Cancel_DuringExecution_CancelsAndSetsPipelineFailed() + { + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + var capturedToken = CancellationToken.None; + + var step = new TestStep(async ct => + { + await Task.Yield(); + capturedToken = ct; + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + }, ServiceProvider); + + var pipeline = CreatePipeline([step]); + + await pipeline.PrepareAsync(CancellationToken.None); + + var pipelineTask = pipeline.RunAsync(CancellationToken.None); + await waitToCancel.Task; + + pipeline.Cancel(); + waitUntilCanceled.Set(); + + await Assert.ThrowsAsync(() => pipelineTask); + + Assert.False(pipeline.Failed); + Assert.True(pipeline.Cancelled); + Assert.True(capturedToken.IsCancellationRequested); + } + + [Fact] + public async Task Cancel_BeforeRun_HasNoEffect() + { + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([step]); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + pipeline.Cancel(); + + await pipeline.RunAsync(CancellationToken.None); + + Assert.False(pipeline.Failed); + Assert.False(pipeline.Cancelled); + Assert.True(executed); + } + + [Fact] + public async Task Cancel_BeforePrepare_NoException() + { + var pipeline = CreatePipeline([]); + + pipeline.Cancel(); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.False(pipeline.Failed); + Assert.False(pipeline.Cancelled); + } + + [Fact] + public async Task Cancel_AfterRun_NoEffect() + { + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var pipeline = CreatePipeline([step]); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + pipeline.Cancel(); // Should not throw + + Assert.False(pipeline.Failed); + Assert.False(pipeline.Cancelled); + } + + [Fact] + public async Task Cancel_CalledMultipleTimes_NoException() + { + var waitToCancel = new TaskCompletionSource(); + var waitUntilCanceled = new ManualResetEvent(false); + + var step = new TestStep(_ => + { + waitToCancel.SetResult(true); + waitUntilCanceled.WaitOne(); + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreatePipeline([step]); + + await pipeline.PrepareAsync(CancellationToken.None); + + var pipelineTask = pipeline.RunAsync(CancellationToken.None); + await waitToCancel.Task; + + pipeline.Cancel(); + pipeline.Cancel(); + pipeline.Cancel(); + + waitUntilCanceled.Set(); + + await Assert.ThrowsAsync(() => pipelineTask); + } + + + [Fact] + public void Cancel_OnDisposedPipeline_NoException() + { + var pipeline = CreatePipeline([]); + pipeline.Dispose(); + + pipeline.Cancel(); + + Assert.True(pipeline.IsDisposed); + } + + #endregion + + #region ToString Tests + + [Fact] + public void ToString_ReturnsTypeName() + { + var pipeline = CreatePipeline([]); + + var result = pipeline.ToString(); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + #endregion +} + +public interface ITrackingPipeline : IPipeline; \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/ProducerConsumerPipelineTest.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/ProducerConsumerPipelineTest.cs new file mode 100644 index 00000000..b56eb731 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/ProducerConsumerPipelineTest.cs @@ -0,0 +1,732 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using AnakinRaW.CommonUtilities.Testing.Extensions; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +public class ProducerConsumerPipelineTest : StepRunnerPipelineBaseTestSuite +{ + protected override StepRunnerPipelineBase CreateStepRunnerPipelineBase(IList steps, bool failFast, RunnerBehavior runnerBehavior) + { + return CreateConsumerPipeline(steps.ToAsyncEnumerable(), failFast, runnerBehavior); + } + + protected override ITrackingPipeline CreateTrackingPipeline(Func prepare, Func run) + { + IEnumerable steps = [new TestStep(run, ServiceProvider)]; + return new TestProducerConsumerPipeline(ServiceProvider, steps.ToAsyncEnumerable(), prepare, GetWorkerCount(GetRandomRunBehavior()), false); + } + + protected override StepRunnerPipelineBase CreateTrackingPipeline( + IList steps, bool failFast, RunnerBehavior runnerBehavior, List callOrder, + string? throwOnMethod = null, Func, IEnumerable>? filterErrorStepsFunc = null) + { + return new TrackingProducerConsumerPipeline(ServiceProvider, steps.ToAsyncEnumerable(), + GetWorkerCount(runnerBehavior), failFast, callOrder, throwOnMethod, filterErrorStepsFunc); + } + + private ProducerConsumerPipeline CreateConsumerPipeline(IAsyncEnumerable steps, bool failFast, RunnerBehavior runnerBehavior) + { + return new TestProducerConsumerPipeline(ServiceProvider, steps, null, GetWorkerCount(runnerBehavior), failFast); + } + + private ProducerConsumerPipeline CreateConsumerPipeline(IAsyncEnumerable steps) + { + return CreateConsumerPipeline(steps, Random.Bool(), GetRandomRunBehavior()); + } + + #region Constructor Tests + + [Fact] + public void Ctor_NullServiceProvider_Throws() + { + Assert.Throws(() => new TestProducerConsumerPipeline( + null!, AsyncEnumerable.Empty(), null, 4, true)); + } + + [Fact] + public void Ctor_InvalidWorkerCount_Throws() + { + Assert.Throws(() => new TestProducerConsumerPipeline( + ServiceProvider, AsyncEnumerable.Empty(), null, 0, true)); + Assert.Throws(() => new TestProducerConsumerPipeline( + ServiceProvider, AsyncEnumerable.Empty(), null, new Random().Next(int.MinValue, 0), true)); + Assert.Throws(() => new TestProducerConsumerPipeline( + ServiceProvider, AsyncEnumerable.Empty(), null, new Random().Next(65, int.MaxValue), true)); + } + + #endregion + + #region RunAsync + + [Fact] + public async Task RunAsync_ConcurrentCallsDuringBackgroundPreparation_OnlyFirstSucceeds() + { + var productionStarted = new TaskCompletionSource(); + var continueProduction = new TaskCompletionSource(); + var stepRunCount = 0; + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + // Start first RunAsync + var firstTask = pipeline.RunAsync(CancellationToken.None); + + // Wait for production/preparation to start + await productionStarted.Task; + + await Assert.ThrowsAsync(() => pipeline.RunAsync(CancellationToken.None)); + + continueProduction.SetResult(true); + await firstTask; + + Assert.Equal(1, stepRunCount); + Assert.False(pipeline.Failed); + + async IAsyncEnumerable ProduceStepsAsync() + { + productionStarted.SetResult(true); + await continueProduction.Task; + yield return new TestStep(_ => + { + Interlocked.Increment(ref stepRunCount); + return Task.CompletedTask; + }, ServiceProvider); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_PreparationFails_ThrowsPreparationException(bool failFast) + { + var barrier = new ManualResetEventSlim(false); + var canThrow = new TaskCompletionSource(false); + + var ran = false; + var s1 = new TestStep(ct => + { + canThrow.SetResult(1); + barrier.Wait(ct); + ct.ThrowIfCancellationRequested(); + ran = true; + return Task.CompletedTask; + }, ServiceProvider); + var s2 = new TestStep(null, ServiceProvider); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync(), failFast, GetRandomRunBehavior()); + + var task = Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + if (failFast) + await task; + + barrier.Set(); + await task; + + if (failFast) + Assert.False(ran); + else + Assert.True(ran); + + async IAsyncEnumerable ProduceStepsAsync() + { + yield return s1; + yield return s2; + await canThrow.Task; + throw new ApplicationException("test"); + } + } + + [Fact] + public async Task RunAsync_DelayedStepProduction_ExecutesAllSteps() + { + var tcs = new TaskCompletionSource(); + + var s1 = new TestStep(async _ => + { + await Task.Delay(100, TestContext.Current.CancellationToken); + tcs.SetResult(true); + }, ServiceProvider); + + var s2Run = false; + var s2 = new TestStep(_ => + { + s2Run = true; + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.True(s2Run); + + async IAsyncEnumerable ProduceStepsAsync() + { + yield return s1; + await tcs.Task; + yield return s2; + } + } + + [Theory] + [InlineData(RunnerBehavior.Concurrent)] + [InlineData(RunnerBehavior.Sequential)] + public async Task RunAsync_StepsProducedWhileRunning_AllExecuted(RunnerBehavior runnerBehavior) + { + var executedSteps = new ConcurrentQueue(); + const int stepCount = 10; + const int delay = 50; + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync(), Random.Bool(), runnerBehavior); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(stepCount, executedSteps.Count); + if (runnerBehavior is RunnerBehavior.Sequential) + Assert.Equal(executedSteps.ToList().OrderBy(x => x), executedSteps); + return; + + + async IAsyncEnumerable ProduceStepsAsync() + { + for (var i = 0; i < stepCount; i++) + { + var stepIndex = i; + yield return new TestStep(_ => + { + executedSteps.Enqueue(stepIndex); + return Task.CompletedTask; + }, ServiceProvider); + await Task.Delay(delay, TestContext.Current.CancellationToken); + } + } + } + + [Theory] + [InlineData(RunnerBehavior.Concurrent)] + [InlineData(RunnerBehavior.Sequential)] // Checks, production is started on ThreadPool + public async Task RunAsync_ConsumesStepsWhileProducing(RunnerBehavior runnerBehavior) + { + var executionStartedDuringProduction = false; + var firstStepExecutionStarted = new TaskCompletionSource(); + var canContinueProduction = new TaskCompletionSource(); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync(), Random.Bool(), runnerBehavior); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.True(executionStartedDuringProduction, "Step execution should start before production completes"); + return; + + async IAsyncEnumerable ProduceStepsAsync() + { + // Produce first step + yield return new TestStep(async _ => + { + firstStepExecutionStarted.SetResult(true); + await canContinueProduction.Task; + }, ServiceProvider); + + // Wait for first step to start executing + await firstStepExecutionStarted.Task; + + // At this point, execution has started while we're still producing + executionStartedDuringProduction = true; + + // Produce more steps + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + + // Allow first step to complete + canContinueProduction.SetResult(true); + + } + } + + [Fact] + public async Task RunAsync_CalledDuringStepProduction_WaitsForSamePreparation() + { + var preparationStarted = new TaskCompletionSource(); + var continuePreparation = new TaskCompletionSource(); + var stepExecuted = false; + var prepareCallCount = 0; + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + var prepareTask = pipeline.PrepareAsync(CancellationToken.None); + + await preparationStarted.Task; + + var runTask = pipeline.RunAsync(CancellationToken.None); + + continuePreparation.SetResult(true); + + await prepareTask; + await runTask; + + Assert.Equal(1, prepareCallCount); + Assert.True(stepExecuted); + Assert.False(pipeline.Failed); + + async IAsyncEnumerable ProduceStepsAsync() + { + Interlocked.Increment(ref prepareCallCount); + preparationStarted.SetResult(true); + await continuePreparation.Task; + yield return new TestStep(_ => + { + stepExecuted = true; + return Task.CompletedTask; + }, ServiceProvider); + } + } + + [Fact] + public async Task RunAsync_PreparationCancelled_ThrowsOperationCanceledException() + { + var cts = new CancellationTokenSource(); + var tcs = new TaskCompletionSource(); + + var s1 = new TestStep(async _ => + { + await Task.Delay(100, TestContext.Current.CancellationToken); + tcs.SetResult(true); + }, ServiceProvider); + + var s2Run = false; + var s2 = new TestStep(_ => + { + s2Run = true; + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + await Assert.ThrowsAnyAsync(() => pipeline.RunAsync(cts.Token)); + + Assert.False(s2Run); + return; + + async IAsyncEnumerable ProduceStepsAsync() + { + yield return s1; + await tcs.Task; + cts.Cancel(); + await Task.Delay(100, TestContext.Current.CancellationToken); + yield return s2; + } + } + + [Fact] + public async Task RunAsync_CancelledDuringProduction_StopsProducing() + { + var cts = new CancellationTokenSource(); + var productionGate = new TaskCompletionSource(); + var cancelGate = new TaskCompletionSource(); + var producedSteps = new List(); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + var runTask = Assert.ThrowsAnyAsync(() => pipeline.RunAsync(cts.Token)); + + await productionGate.Task; + + cts.Cancel(); + cancelGate.SetResult(true); + + await runTask; + + Assert.Equal(2, producedSteps.Count); + + async IAsyncEnumerable ProduceStepsAsync() + { + for (var i = 0; i < 10; i++) + { + if (i == 2) + { + productionGate.SetResult(true); + await cancelGate.Task; + } + + cts.Token.ThrowIfCancellationRequested(); + + producedSteps.Add(i); + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + } + } + } + + [Fact] + public async Task RunAsync_PreparationFailsImmediately_ThrowsException() + { + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + async IAsyncEnumerable ProduceStepsAsync() + { + await Task.Yield(); + throw new ApplicationException("Immediate failure"); +#pragma warning disable CS0162 // Unreachable code detected + yield break; +#pragma warning restore CS0162 + } + } + + [Theory] + [InlineData(RunnerBehavior.Concurrent)] + [InlineData(RunnerBehavior.Sequential)] + public async Task RunAsync_PreparationAndExecutionConcurrent_NoDeadlock(RunnerBehavior runnerBehavior) + { + var firstStepStarted = new ManualResetEventSlim(false); + var allowPreparationToContinue = new ManualResetEventSlim(false); + var executedSteps = new ConcurrentBag(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, TestContext.Current.CancellationToken); + + + var pipeline = new NonAwaitingTestProducerConsumerPipeline(ServiceProvider, + GetWorkerCount(runnerBehavior), buildSteps: BuildSteps); + + var runTask = pipeline.RunAsync(linkedCts.Token); + + // Wait for execution to start with timeout + Assert.True(firstStepStarted.Wait(TimeSpan.FromSeconds(5), linkedCts.Token), + "First step did not start; pipeline may be deadlocked."); + + // Allow preparation to continue producing steps + allowPreparationToContinue.Set(); + + // Should complete without deadlock + await runTask; + + Assert.False(cts.IsCancellationRequested, "Pipeline hit timeout; possible deadlock."); + Assert.Equal(11, executedSteps.Count); + Assert.Contains(0, executedSteps); + return; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + async IAsyncEnumerable BuildSteps([EnumeratorCancellation] CancellationToken token) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + // First step: starts execution, signals, then blocks + yield return new TestStep(async _ => + { + await Task.Yield(); + executedSteps.Add(0); + firstStepStarted.Set(); + + // Wait with timeout to avoid hanging + if (!allowPreparationToContinue.Wait(TimeSpan.FromSeconds(8), linkedCts.Token)) + throw new TimeoutException("First step timed out waiting for continuation signal"); + }, ServiceProvider); + + // Wait for first step to actually start executing with timeout + if (!firstStepStarted.Wait(TimeSpan.FromSeconds(5), linkedCts.Token)) + throw new TimeoutException("BuildSteps timed out waiting for first step to start"); + + // Produce more steps while first step is still running + for (var i = 1; i <= 10; i++) + { + var index = i; + yield return new TestStep(_ => + { + executedSteps.Add(index); + return Task.CompletedTask; + }, ServiceProvider); + } + } + } + + [Fact] + public async Task RunAsync_CancellationDoesNotCauseStepAddedProductionThrowsInvalidOperationException() + { + var productionStarted = new TaskCompletionSource(); + var enumerationCompleted = new TaskCompletionSource(); + + var pipeline = CreateConsumerPipeline( + CreateSteps(CancellationToken.None), + Random.Bool(), + RunnerBehavior.Sequential); + + var pipelineTask = pipeline.RunAsync(new CancellationToken(true)); + + await productionStarted.Task; + + var productionCompletedAndHandledTask = Task.Run(async () => + { + await enumerationCompleted.Task; + // Required, to ensure the ExceptionHandler of RunPreparationAsync had time to complete. + await Task.Delay(200, CancellationToken.None); + }, CancellationToken.None); + + var completed = await Task.WhenAny(productionCompletedAndHandledTask, Task.Delay(5000, CancellationToken.None)); + Assert.Equal(productionCompletedAndHandledTask, completed); + + await Assert.ThrowsAsync(async () => await pipelineTask); + + Assert.False(pipeline.Failed); + return; + + async IAsyncEnumerable CreateSteps([EnumeratorCancellation] CancellationToken _) + { + productionStarted.SetResult(true); + try + { + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + } + finally + { + enumerationCompleted.TrySetResult(true); + } + } + } + + [Fact] + public async Task RunAsync_StepAddedAfterRunnerFinishedUnexpectedly_RethrowsInvalidOperationException() + { + var productionStarted = new TaskCompletionSource(); + var proceedWithProduction = new TaskCompletionSource(); + var enumerationCompleted = new TaskCompletionSource(); + + var pipeline = new TestProducerConsumerPipelineExposed( + ServiceProvider, + CreateSteps(CancellationToken.None), + prepareAction: null, + workerCount: 1, + failFast: false); + + var pipelineTask = pipeline.RunAsync(CancellationToken.None); + + await productionStarted.Task; + + // Finish the runner without cancellation request, + // which means that something really unexpected was going on + pipeline.ExposedStepRunner.Finish(); + + proceedWithProduction.SetResult(true); + + var productionCompletedAndHandledTask = Task.Run(async () => + { + await enumerationCompleted.Task; + // Required, to ensure the ExceptionHandler of RunPreparationAsync had time to complete. + await Task.Delay(200, CancellationToken.None); + }, CancellationToken.None); + + var completed = await Task.WhenAny(productionCompletedAndHandledTask, Task.Delay(5000, CancellationToken.None)); + Assert.Equal(productionCompletedAndHandledTask, completed); + + await Assert.ThrowsAsync(async () => await pipelineTask); + + Assert.True(pipeline.Failed); + return; + + async IAsyncEnumerable CreateSteps([EnumeratorCancellation] CancellationToken _) + { + productionStarted.SetResult(true); + await proceedWithProduction.Task; + + try + { + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + } + finally + { + enumerationCompleted.TrySetResult(true); + } + } + } + + #endregion + + #region PrepareAsync + + [Fact] + public async Task PrepareAsync_ConcurrentCallsDuringStepProduction_OnlyFirstSucceeds() + { + var productionStarted = new TaskCompletionSource(); + var continueProduction = new TaskCompletionSource(); + var prepareCount = 0; + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + var firstTask = pipeline.PrepareAsync(CancellationToken.None); + + await productionStarted.Task; + + await Assert.ThrowsAsync(() => pipeline.PrepareAsync(CancellationToken.None)); + + continueProduction.SetResult(true); + await firstTask; + + Assert.Equal(1, prepareCount); + + async IAsyncEnumerable ProduceStepsAsync() + { + Interlocked.Increment(ref prepareCount); + productionStarted.SetResult(true); + await continueProduction.Task; + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + } + } + + [Fact] + public async Task PrepareAsync_CalledAfterRunAsyncStartedPreparation_ThrowsInvalidOperationException() + { + var preparationStarted = new TaskCompletionSource(); + var continuePreparation = new TaskCompletionSource(); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + var runTask = pipeline.RunAsync(CancellationToken.None); + + await preparationStarted.Task; + + await Assert.ThrowsAsync(() => pipeline.PrepareAsync(CancellationToken.None)); + + continuePreparation.SetResult(true); + await runTask; + + Assert.False(pipeline.Failed); + + async IAsyncEnumerable ProduceStepsAsync() + { + preparationStarted.SetResult(true); + await continuePreparation.Task; + yield return new TestStep(_ => Task.CompletedTask, ServiceProvider); + } + } + + [Fact] + public async Task PrepareAsync_PreparationFails_ThrowsException() + { + var s1 = new TestStep(null, ServiceProvider); + var s2 = new TestStep(null, ServiceProvider); + + var pipeline = CreateConsumerPipeline(ProduceStepsAsync()); + + await Assert.ThrowsAsync(async () => await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + async IAsyncEnumerable ProduceStepsAsync() + { + yield return s1; + yield return s2; + await Task.Yield(); + throw new ApplicationException("test"); + } + } + + [Fact] + public async Task PrepareAsync_RunnerInitializedWithCtorWorkerCount() + { + var workerCount = new Random().Next(1, 65); + var pipeline = new TestProducerConsumerPipeline(ServiceProvider, Array.Empty().ToAsyncEnumerable(), + null, workerCount, false); + + await pipeline.PrepareAsync(CancellationToken.None); + + Assert.Equal(workerCount, pipeline.StepRunner.WorkerCount); + } + + #endregion + + private class TestProducerConsumerPipeline : ProducerConsumerPipeline, ITrackingPipeline + { + private readonly IAsyncEnumerable _steps; + private readonly Func? _prepareAction; + + public TestProducerConsumerPipeline( + IServiceProvider serviceProvider, + IAsyncEnumerable steps, + Func? onPrepare, + int workerCount, + bool failFast) : base(workerCount, serviceProvider) + { + _steps = steps; + FailFast = failFast; + _prepareAction = onPrepare; + } + + protected override async IAsyncEnumerable BuildStepsAsync([EnumeratorCancellation] CancellationToken token) + { + if (_prepareAction is not null) + await _prepareAction(token); + await foreach (var step in _steps.WithCancellation(token)) + { + //token.ThrowIfCancellationRequested(); + yield return step; + } + } + } + + private class NonAwaitingTestProducerConsumerPipeline( + IServiceProvider serviceProvider, + int workerCount, + Func> buildSteps) + : ProducerConsumerPipeline(workerCount, serviceProvider) + { + private readonly Func> _buildSteps = buildSteps ?? throw new ArgumentNullException(nameof(buildSteps)); + + protected override IAsyncEnumerable BuildStepsAsync(CancellationToken token) + { + return _buildSteps(token); + } + } + + private class TestProducerConsumerPipelineExposed( + IServiceProvider serviceProvider, + IAsyncEnumerable steps, + Func? prepareAction, + int workerCount, + bool failFast) + : TestProducerConsumerPipeline(serviceProvider, steps, prepareAction, workerCount, failFast) + { + public ProducerConsumerStepRunner ExposedStepRunner => StepRunner; + } + + private class TrackingProducerConsumerPipeline : ProducerConsumerPipeline + { + private readonly IAsyncEnumerable _steps; + private readonly Func, IEnumerable>? _filterErrorStepsFunc; + private readonly TrackingPipelineHelper _helper; + + public TrackingProducerConsumerPipeline( + IServiceProvider serviceProvider, + IAsyncEnumerable steps, + int workerCount, + bool failFast, + List callOrder, + string? throwOnMethod, + Func, IEnumerable>? filterErrorStepsFunc) + : base(workerCount, serviceProvider) + { + _steps = steps; + _filterErrorStepsFunc = filterErrorStepsFunc; + _helper = new TrackingPipelineHelper(callOrder, throwOnMethod); + FailFast = failFast; + } + + protected override IAsyncEnumerable BuildStepsAsync(CancellationToken token) + { + return _steps; + } + + protected override void OnExecuteStarted() => _helper.OnExecuteStarted(); + protected override void OnRunnerExecuted() => _helper.OnRunnerExecuted(); + protected override void OnExecuteCompleted() => _helper.OnExecuteCompleted(); + protected override IEnumerable GetFailedSteps(IEnumerable steps) + { + return _filterErrorStepsFunc is null ? base.GetFailedSteps(steps) : _filterErrorStepsFunc(steps); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/RunnerBehavior.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/RunnerBehavior.cs new file mode 100644 index 00000000..805702bd --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/RunnerBehavior.cs @@ -0,0 +1,7 @@ +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +public enum RunnerBehavior +{ + Sequential = 0, + Concurrent = 1 +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/SequentialPipelineTests.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/SequentialPipelineTests.cs index 2e88024a..9a315a05 100644 --- a/src/CommonUtilities.SimplePipeline/test/Pipelines/SequentialPipelineTests.cs +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/SequentialPipelineTests.cs @@ -1,71 +1,113 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Threading; using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using AnakinRaW.CommonUtilities.Testing.Extensions; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; -public class SequentialPipelineTests : StepRunnerPipelineTest +public class SequentialPipelineTests : StepRunnerPipelineTestBase { - protected override StepRunnerPipeline CreatePipeline(IList steps, bool failFast) + protected override bool RunnerSupportsConcurrentRuns => false; + + protected override StepRunnerPipeline CreateStepRunnerPipeline(IList steps, bool failFast, RunnerBehavior runnerBehavior) { - return new TestSequentialPipeline(steps, ServiceProvider, failFast); + if (runnerBehavior is RunnerBehavior.Concurrent) + throw new NotSupportedException("Concurrent runs are not supported"); + return CreateSequentialPipeline(steps, failFast); } - protected override Pipeline CreatePipeline(IList steps) + protected override ITrackingPipeline CreateTrackingPipeline(Func prepare, Func run) { - return CreatePipeline(steps, true); + var testStep = new TestStep(run, ServiceProvider); + return new TestSequentialPipeline(ServiceProvider, [testStep], prepare, failFast: false); } - [Fact] - public void Ctor_NullArgs_Throws() + protected override StepRunnerPipeline CreateTrackingStepRunnerPipeline( + IList steps, bool failFast, RunnerBehavior runnerBehavior, List callOrder, + string? throwOnMethod = null, + Func, IEnumerable>? filterErrorStepsFunc = null) { - Assert.Throws(() => new TestSequentialPipeline([], null!)); + if (runnerBehavior is RunnerBehavior.Concurrent) + throw new NotSupportedException("Concurrent runs are not supported"); + return new TrackingSequentialPipeline(ServiceProvider, steps, failFast, callOrder, throwOnMethod, filterErrorStepsFunc); } - [Fact] - public async Task RunAsync_RunsInSequence() - { - var sb = new StringBuilder(); - - var s1 = new TestStep(_ => sb.Append('a'), ServiceProvider); - var s2 = new TestStep(_ => sb.Append('b'), ServiceProvider); + private SequentialPipeline CreateSequentialPipeline(IList steps, bool failFast) + { + return new TestSequentialPipeline(ServiceProvider, steps, null, failFast); + } - var pipeline = CreatePipeline([s1, s2], true); - - await pipeline.RunAsync(); - Assert.Equal("ab", sb.ToString()); + #region Constructor Tests - Assert.False(pipeline.PipelineFailed); + [Fact] + public void Ctor_NullServiceProvider_Throws() + { + Assert.Throws(() => new TestSequentialPipeline(null!, [], null, Random.Bool())); } - [Theory] - [InlineData(true, "")] - //[InlineData(false, "b")] - public async Task RunAsync_WithError_FailFastBehavior_Throws(bool failFast, string result) + #endregion + + private class TestSequentialPipeline : SequentialPipeline, ITrackingPipeline { - var sb = new StringBuilder(); - - var s1 = new TestStep(_ => throw new Exception("Test"), ServiceProvider); - var s2 = new TestStep(_ => sb.Append('b'), ServiceProvider); + private readonly IList _steps; + private readonly Func? _prepareAction; - var pipeline = CreatePipeline([s1, s2], failFast); + public TestSequentialPipeline( + IServiceProvider serviceProvider, + IList steps, + Func? onPrepare, + bool failFast = false) + : base(serviceProvider) + { + _steps = steps; + FailFast = failFast; + _prepareAction = onPrepare; + } - var e = await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - Assert.Equal("Step 'TestStep' failed with error: Test", e.Message); - Assert.Equal(result, sb.ToString()); - Assert.True(pipeline.PipelineFailed); + protected override async Task> CreateRunnerSteps(CancellationToken token) + { + if (_prepareAction is not null) + await _prepareAction(token); + return _steps; + } } - private class TestSequentialPipeline(IEnumerable steps, IServiceProvider serviceProvider, bool failFast = true) - : SequentialPipeline(serviceProvider, failFast) + private class TrackingSequentialPipeline : SequentialPipeline { - protected override Task> BuildSteps() + private readonly IList _steps; + private readonly Func, IEnumerable>? _filterErrorStepsFunc; + private readonly TrackingPipelineHelper _helper; + + public TrackingSequentialPipeline( + IServiceProvider serviceProvider, + IList steps, + bool failFast, + List callOrder, + string? throwOnMethod, + Func, IEnumerable>? filterErrorStepsFunc) + : base(serviceProvider) + { + _steps = steps; + _filterErrorStepsFunc = filterErrorStepsFunc; + _helper = new TrackingPipelineHelper(callOrder, throwOnMethod); + FailFast = failFast; + } + + protected override Task> CreateRunnerSteps(CancellationToken token) { - return Task.FromResult>(steps.ToList()); + return Task.FromResult(_steps); } + + protected override IEnumerable GetFailedSteps(IEnumerable steps) + { + return _filterErrorStepsFunc is null ? base.GetFailedSteps(steps) : _filterErrorStepsFunc(steps); + } + + protected override void OnExecuteStarted() => _helper.OnExecuteStarted(); + protected override void OnRunnerExecuted() => _helper.OnRunnerExecuted(); + protected override void OnExecuteCompleted() => _helper.OnExecuteCompleted(); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineBaseTestSuite.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineBaseTestSuite.cs new file mode 100644 index 00000000..5b5405be --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineBaseTestSuite.cs @@ -0,0 +1,650 @@ +using AnakinRaW.CommonUtilities.Testing.Extensions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +public abstract class StepRunnerPipelineBaseTestSuite : PipelineTestSuite where TRunner : class, IStepRunner +{ + protected virtual bool RunnerSupportsSequentialRuns => true; + + protected virtual bool RunnerSupportsConcurrentRuns => true; + + protected abstract StepRunnerPipelineBase CreateStepRunnerPipelineBase( + IList steps, + bool failFast, + RunnerBehavior runnerBehavior); + + protected abstract StepRunnerPipelineBase CreateTrackingPipeline( + IList steps, + bool failFast, + RunnerBehavior runnerBehavior, + List callOrder, + string? throwOnMethod = null, + Func, IEnumerable>? filterErrorStepsFunc = null); + + protected StepRunnerPipelineBase CreateStepRunnerPipelineBase(IList steps) + { + var sequential = GetRandomRunBehavior(); + return CreateStepRunnerPipelineBase(steps, Random.Bool(), sequential); + } + + protected StepRunnerPipelineBase CreateStepRunnerPipelineBase(IList steps, bool failFast) + { + var sequential = GetRandomRunBehavior(); + return CreateStepRunnerPipelineBase(steps, failFast, sequential); + } + + protected override Pipeline CreatePipeline(IList steps) + { + return CreateStepRunnerPipelineBase(steps, false); + } + + protected RunnerBehavior GetRandomRunBehavior() + { + if (RunnerSupportsConcurrentRuns && RunnerSupportsSequentialRuns) + return Random.Enum(); + if (RunnerSupportsConcurrentRuns) + return RunnerBehavior.Concurrent; + if (RunnerSupportsSequentialRuns) + return RunnerBehavior.Sequential; + throw new NotSupportedException(); + } + + protected virtual int GetWorkerCount(RunnerBehavior runnerBehavior) + { + return runnerBehavior is RunnerBehavior.Sequential ? 1 : 4; + } + + protected bool IsRunBehaviorSupported(RunnerBehavior runnerBehavior) + { + switch (runnerBehavior) + { + case RunnerBehavior.Sequential when !RunnerSupportsSequentialRuns: + case RunnerBehavior.Concurrent when !RunnerSupportsConcurrentRuns: + return false; + } + + return true; + } + + #region FailFast + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void FailFast_CtorSetsProperty(bool failFast) + { + var pipeline = CreateStepRunnerPipelineBase([], failFast); + Assert.Equal(failFast, pipeline.FailFast); + } + + #endregion + + #region StepRunner + + [Fact] + public void StepRunner_AccessAlwaysInitializes() + { + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var pipeline = CreateStepRunnerPipelineBase([step], Random.Bool()); + Assert.NotNull(pipeline.StepRunner); + } + + [Fact] + public async Task StepRunner_IsNotNullAfterPreparation() + { + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var pipeline = CreateStepRunnerPipelineBase([step], Random.Bool()); + + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(pipeline.StepRunner); + } + + #endregion + + #region IsStepRunnerInitialized + + [Fact] + public async Task IsStepRunnerInitialized_IsInitializedAfterPrepare() + { + var pipeline = CreateStepRunnerPipelineBase([], Random.Bool()); + Assert.False(pipeline.IsStepRunnerInitialized); + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + Assert.True(pipeline.IsStepRunnerInitialized); + } + + [Fact] + public async Task IsStepRunnerInitialized_IsInitializedAfterRun() + { + var pipeline = CreateStepRunnerPipelineBase([], Random.Bool()); + Assert.False(pipeline.IsStepRunnerInitialized); + await pipeline.RunAsync(TestContext.Current.CancellationToken); + Assert.True(pipeline.IsStepRunnerInitialized); + } + + #endregion + + #region RunAsync + + [Fact] + public async Task RunAsync_EmptyPipeline_Succeeds() + { + var pipeline = CreateStepRunnerPipelineBase([]); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.False(pipeline.Failed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAsync_AllStepsExecuted(bool prepare) + { + var runCounter = 0; + + var s1 = new TestStep(_ => + { + Interlocked.Increment(ref runCounter); + return Task.CompletedTask; + }, ServiceProvider); + var s2 = new TestStep(_ => + { + Interlocked.Increment(ref runCounter); + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2]); + + if (prepare) + await pipeline.PrepareAsync(TestContext.Current.CancellationToken); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, runCounter); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task RunAsync_StepReceivesValidCancellationToken() + { + var receivedToken = CancellationToken.None; + var tokenWasProvided = false; + + var step = new TestStep(ct => + { + receivedToken = ct; + tokenWasProvided = ct.CanBeCanceled; + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([step]); + + using var cts = new CancellationTokenSource(); + await pipeline.RunAsync(cts.Token); + + Assert.True(tokenWasProvided); + Assert.True(receivedToken.CanBeCanceled); + } + + [Theory] + [InlineData(RunnerBehavior.Sequential)] + [InlineData(RunnerBehavior.Concurrent)] + public async Task RunAsync_FailFastEnabled_StepThrows_StopsExecution(RunnerBehavior runnerBehavior) + { + if (!IsRunBehaviorSupported(runnerBehavior)) + return; + + var secondStepRan = false; + var s1 = new TestStep(_ => throw new Exception("Test"), ServiceProvider); + var s2 = new TestStep(_ => + { + secondStepRan = true; + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2], true, runnerBehavior); + + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Failed); + + if (runnerBehavior is RunnerBehavior.Sequential) + { + Assert.False(secondStepRan, "FailFast should prevent subsequent steps from running"); + Assert.True(pipeline.Cancelled); + } + } + + [Theory] + [InlineData(RunnerBehavior.Sequential)] + [InlineData(RunnerBehavior.Concurrent)] + public async Task RunAsync_FailFastDisabled_ContinuesAfterError(RunnerBehavior runnerBehavior) + { + if (!IsRunBehaviorSupported(runnerBehavior)) + return; + + var executed = new ConcurrentQueue(); + + var s1 = new TestStep(_ => { executed.Enqueue(1); return Task.CompletedTask; }, ServiceProvider); + var s2 = new TestStep(_ => { executed.Enqueue(2); throw new InvalidOperationException(); }, ServiceProvider); + var s3 = new TestStep(_ => { executed.Enqueue(3); return Task.CompletedTask; }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2, s3], false, runnerBehavior); + + await Assert.ThrowsAsync( + () => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + if (runnerBehavior is RunnerBehavior.Sequential) + Assert.Equal([1, 2, 3], executed); + else + Assert.EqualUnordered([1, 2, 3], executed.ToList()); + } + + [Fact] + public async Task RunAsync_StepThrowsException_ThrowsStepFailureException() + { + var step = new TestStep(_ => throw new InvalidOperationException("Test error"), ServiceProvider); + var pipeline = CreatePipeline([step]); + + var e = await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Failed); + Assert.False(pipeline.Cancelled); + Assert.Contains("failed with error", e.Message); + Assert.Contains("Test error", e.Message); + } + + [Fact] + public async Task RunAsync_MultipleStepsThrow_AllErrorsCaptured() + { + var s1 = new TestStep(_ => throw new Exception("Error1"), ServiceProvider); + var s2 = new TestStep(_ => throw new Exception("Error2"), ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2], false); + + var e = await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Failed); + Assert.Contains("failed with error", e.Message); + Assert.Contains("Error1", e.Message); + Assert.Contains("Error2", e.Message); + } + + [Fact] + public async Task RunAsync_WithPreCancelledToken_ThrowsAndSetsPipelineCancelled() + { + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + var pipeline = CreatePipeline([step]); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => pipeline.RunAsync(cts.Token)); + + Assert.False(executed); + Assert.True(pipeline.Cancelled); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task RunAsync_StepThrowsOperationCanceledException_TreatedAsCancellation() + { + var s1 = new TestStep(_ => throw new OperationCanceledException(), ServiceProvider); + + var pipeline = CreatePipeline([s1]); + + // OperationCanceledException from a step should propagate + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Cancelled); + Assert.False(pipeline.Failed); + } + + [Fact] + public async Task RunAsync_StepThrowsAggregateExceptionWithCancellation_TreatedAsCancellation() + { + var step = new TestStep(_ => throw new AggregateException(new OperationCanceledException()), ServiceProvider); + + var pipeline = CreatePipeline([step]); + + await Assert.ThrowsAsync(() => + pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.False(pipeline.Failed); + Assert.True(pipeline.Cancelled); + } + + [Fact] + public async Task RunAsync_Concurrent_ExecutesStepsInParallel() + { + if (!RunnerSupportsConcurrentRuns) + return; + + var concurrentCount = 0; + var maxConcurrent = 0; + var lockObj = new object(); + + var steps = new List(); + for (var i = 0; i < GetWorkerCount(RunnerBehavior.Concurrent) * 10; i++) + { + steps.Add(new TestStep(async _ => + { + lock (lockObj) + { + concurrentCount++; + maxConcurrent = Math.Max(maxConcurrent, concurrentCount); + } + + await Task.Delay(100, TestContext.Current.CancellationToken); + + lock (lockObj) + { + concurrentCount--; + } + }, ServiceProvider)); + } + + var pipeline = CreateStepRunnerPipelineBase(steps, Random.Bool(), RunnerBehavior.Concurrent); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.True(maxConcurrent > 1, $"Expected parallel execution, but max concurrent was {maxConcurrent}"); + } + + [Fact] + public async Task RunAsync_Concurrent_RespectsWorkerCount() + { + if (!RunnerSupportsConcurrentRuns) + return; + + var executedSteps = new ConcurrentBag(); + var concurrentCount = 0; + var maxConcurrent = 0; + var lockObj = new object(); + + var steps = new List(); + + var stepCount = GetWorkerCount(RunnerBehavior.Concurrent) * 10; + for (var i = 1; i <= stepCount; i++) + { + var i1 = i; + steps.Add(new TestStep(async _ => + { + lock (lockObj) + { + concurrentCount++; + maxConcurrent = Math.Max(maxConcurrent, concurrentCount); + } + + await Task.Delay(new Random().Next(50, 300), TestContext.Current.CancellationToken); + executedSteps.Add(i1); + + lock (lockObj) + { + concurrentCount--; + } + }, ServiceProvider)); + } + + var pipeline = CreateStepRunnerPipelineBase(steps, Random.Bool(), RunnerBehavior.Concurrent); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.True(maxConcurrent <= pipeline.StepRunner.WorkerCount, + $"Expected max {pipeline.StepRunner.WorkerCount} concurrent, but was {maxConcurrent}"); + + Assert.Equal(stepCount, executedSteps.Count); + Assert.Equal(stepCount * (stepCount + 1) / 2, executedSteps.Sum(x => x)); + } + + [Fact] + public async Task RunAsync_Sequential_MultipleSteps() + { + if (!RunnerSupportsSequentialRuns) + return; + + var sb = new StringBuilder(); + + var s1 = new TestStep(_ => { sb.Append('a'); return Task.CompletedTask; }, ServiceProvider); + var s2 = new TestStep(async _ => + { + await Task.Delay(100, TestContext.Current.CancellationToken); + sb.Append('b'); + }, ServiceProvider); + var s3 = new TestStep(_ => { sb.Append('c'); return Task.CompletedTask; }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2, s3], Random.Bool(), RunnerBehavior.Sequential); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal("abc", sb.ToString()); + Assert.False(pipeline.Failed); + } + + [Theory] + [InlineData(true, RunnerBehavior.Concurrent)] + [InlineData(true, RunnerBehavior.Sequential)] + [InlineData(false, RunnerBehavior.Concurrent)] + [InlineData(false, RunnerBehavior.Sequential)] + public async Task RunAsync_CancelledMidSequence_StopsOnFailFast(bool failFast, RunnerBehavior runnerBehavior) + { + if (!IsRunBehaviorSupported(runnerBehavior)) + return; + + using var cts = new CancellationTokenSource(); + var executedSteps = new ConcurrentQueue(); + + var s1 = new TestStep(_ => { executedSteps.Enqueue(1); return Task.CompletedTask; }, ServiceProvider); + var s2 = new TestStep(_ => + { + executedSteps.Enqueue(2); + cts.Cancel(); + return Task.CompletedTask; + }, ServiceProvider); + var s3 = new TestStep(_ => { executedSteps.Enqueue(3); return Task.CompletedTask; }, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([s1, s2, s3], failFast, runnerBehavior); + + await Assert.ThrowsAsync(() => pipeline.RunAsync(cts.Token)); + + if (runnerBehavior is RunnerBehavior.Sequential) + Assert.Equal([1, 2], executedSteps); + else + Assert.Contains(2, executedSteps); + } + + #endregion + + #region GetFailedSteps + + [Fact] + public async Task GetFailedSteps_OnlyReturnsStepsWithErrors() + { + var successStep = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var failedStep = new TestStep(_ => throw new InvalidOperationException("Test error"), ServiceProvider); + var anotherSuccessStep = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + var pipeline = CreateStepRunnerPipelineBase([successStep, failedStep, anotherSuccessStep], false); + + var ex = await Assert.ThrowsAsync( + () => pipeline.RunAsync(TestContext.Current.CancellationToken)); + +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Assert.Single(ex.FailedSteps); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Assert.Equal(failedStep, ex.FailedSteps.First()); + } + + [Fact] + public async Task GetFailedSteps_CanBeOverridden_CustomFilteringLogic() + { + var callOrder = new List(); + + var step1 = new TestStep(_ => throw new InvalidOperationException("Error 1"), ServiceProvider); + var step2 = new TestStep(_ => throw new ArgumentException("Error 2"), ServiceProvider); + + var pipeline = CreateTrackingPipeline([step1, step2], false, GetRandomRunBehavior(), callOrder, + filterErrorStepsFunc: _ => []); + + var ex = await Record.ExceptionAsync(async () => await pipeline.RunAsync(TestContext.Current.CancellationToken)); + Assert.Null(ex); + } + + #endregion + + #region Common Usage Tests + + [Theory] + [InlineData(RunnerBehavior.Concurrent)] + [InlineData(RunnerBehavior.Sequential)] + public async Task UsageTest_StepsWaitingForEachOther(RunnerBehavior runnerBehavior) + { + if (!IsRunBehaviorSupported(runnerBehavior)) + return; + + var executedSteps = new ConcurrentQueue(); + + var s1 = new TestStep(async _ => + { + await Task.Delay(200, TestContext.Current.CancellationToken); + executedSteps.Enqueue(1); + }, ServiceProvider); + var s2 = new TestStep(async _ => + { + await s1; + executedSteps.Enqueue(2); + }, ServiceProvider); + var s3 = new TestStep(_ => + { + executedSteps.Enqueue(3); + return Task.CompletedTask; + }, ServiceProvider); + + + var pipeline = CreateStepRunnerPipelineBase([s1, s2, s3], Random.Bool(), runnerBehavior); + + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + if (runnerBehavior == RunnerBehavior.Sequential) + Assert.Equal([1,2,3], executedSteps); + else + { + var list = executedSteps.ToList(); + Assert.True(list.IndexOf(1) < list.IndexOf(2), "1 should appear before 2"); + } + } + + [Theory] + [InlineData(RunnerBehavior.Concurrent)] + [InlineData(RunnerBehavior.Sequential)] + public async Task UsageTest_StepsWaitingForEachOther_WrongInsertionOrderHangsOnSequential_WorksForConcurrentRun(RunnerBehavior runnerBehavior) + { + if (!IsRunBehaviorSupported(runnerBehavior)) + return; + + var executedSteps = new ConcurrentQueue(); + + var s1 = new TestStep(_ => + { + executedSteps.Enqueue(1); + return Task.CompletedTask; + }, ServiceProvider); + var s2 = new TestStep(async _ => + { + await s1; + executedSteps.Enqueue(2); + }, ServiceProvider); + var s3 = new TestStep(_ => + { + executedSteps.Enqueue(3); + return Task.CompletedTask; + }, ServiceProvider); + + // Insertion order for Sequential runs is broken and will cause starvation + var pipeline = CreateStepRunnerPipelineBase([s2, s1, s3], Random.Bool(), runnerBehavior); + + if (runnerBehavior == RunnerBehavior.Sequential) + { + var runTask = pipeline.RunAsync(TestContext.Current.CancellationToken); + var finished = await Task.WhenAny( + runTask, + Task.Delay(5000, TestContext.Current.CancellationToken)); + Assert.NotEqual(runTask, finished); + } + else + { + await pipeline.RunAsync(TestContext.Current.CancellationToken); + var list = executedSteps.ToList(); + Assert.True(list.IndexOf(1) < list.IndexOf(2), "1 should appear before 2"); + } + } + + #endregion + + #region Lifecycle Hooks + + [Theory] + [InlineData(false, "OnExecuteStarted,StepExecuted,OnRunnerExecuted,OnExecuteCompleted")] + [InlineData(true, "OnExecuteStarted,OnRunnerExecuted")] + public async Task ExecuteAsync_LifecycleMethods(bool stepFails, string expectedCalls) + { + var callOrder = new List(); + var expected = expectedCalls.Split([','], StringSplitOptions.RemoveEmptyEntries); + + var step = new TestStep(_ => + { + if (stepFails) + throw new InvalidOperationException("Test error"); + callOrder.Add("StepExecuted"); + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateTrackingPipeline([step], false, GetRandomRunBehavior(), callOrder); + + if (stepFails) + await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + else + await pipeline.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(expected, callOrder); + } + + [Theory] + [InlineData("OnExecuteStarted", "OnExecuteStarted")] + [InlineData("OnRunnerExecuted", "OnExecuteStarted,StepExecuted,OnRunnerExecuted")] + [InlineData("OnExecuteCompleted", "OnExecuteStarted,StepExecuted,OnRunnerExecuted,OnExecuteCompleted")] + public async Task ExecuteAsync_LifecycleMethodThrows_ExceptionPropagates(string methodToThrow, string expectedCalls) + { + var callOrder = new List(); + var expected = expectedCalls.Split([','], StringSplitOptions.RemoveEmptyEntries); + + var step = new TestStep(_ => + { + callOrder.Add("StepExecuted"); + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = CreateTrackingPipeline([step], false, GetRandomRunBehavior(), callOrder, methodToThrow); + + var ex = await Assert.ThrowsAsync(() => pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.Equal($"{methodToThrow} threw", ex.Message); + Assert.Equal(expected, callOrder); + } + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTest.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTest.cs deleted file mode 100644 index ce7edff1..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTest.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; - -public abstract class StepRunnerPipelineTest : PipelineTest where T : IStepRunner -{ - protected abstract StepRunnerPipeline CreatePipeline(IList steps, bool failFast); - - [Fact] - public async Task RunAsync_EmptyPipeline() - { - var pipeline = CreatePipeline([], true); - - await pipeline.RunAsync(); - Assert.False(pipeline.PipelineFailed); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task RunAsync_AllStepsExecuted(bool prepare) - { - var runCounter = 0; - - var s1 = new TestStep(_ => Interlocked.Increment(ref runCounter), ServiceProvider); - var s2 = new TestStep(_ => Interlocked.Increment(ref runCounter), ServiceProvider); - - var pipeline = CreatePipeline([s1, s2], true); - - if (prepare) - { - await pipeline.PrepareAsync(); - await pipeline.PrepareAsync(); // Double prepare should have no effect - } - - await pipeline.RunAsync(); - Assert.Equal(2, runCounter); - Assert.False(pipeline.PipelineFailed); - } - - [Fact] - public async Task RunAsync_WithError_Throws() - { - var s1 = new TestStep(_ => throw new Exception("Test"), ServiceProvider); - - var pipeline = CreatePipeline([s1], true); - - var e = await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - Assert.Equal("Step 'TestStep' failed with error: Test", e.Message); - Assert.True(pipeline.PipelineFailed); - } - - [Fact] - public async Task RunAsync_WithError_FailSlow_Throws() - { - var ran = false; - var s1 = new TestStep(_ => throw new Exception("Test"), ServiceProvider); - var s2 = new TestStep(_ => ran = true, ServiceProvider); - - var pipeline = CreatePipeline([s1, s2], false); - - var e = await Assert.ThrowsAsync(async () => await pipeline.RunAsync()); - Assert.Equal("Step 'TestStep' failed with error: Test", e.Message); - Assert.True(pipeline.PipelineFailed); - Assert.True(ran); - } - - [Fact] - public async Task PrepareAsync_ReturnsNull_Throws() - { - var pipeline = new NullRunnerPipeline(ServiceProvider); - - await Assert.ThrowsAsync(pipeline.PrepareAsync); - - // Should not throw, as preparation is only done once. - await pipeline.PrepareAsync(); - - Assert.False(pipeline.PipelineFailed); - } - - private class NullRunnerPipeline(IServiceProvider serviceProvider) : StepRunnerPipeline(serviceProvider) - { - protected override T CreateRunner() - { - return default!; - } - - protected override Task> BuildSteps() - { - return Task.FromResult>([]); - } - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTestBase.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTestBase.cs new file mode 100644 index 00000000..caf86380 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/StepRunnerPipelineTestBase.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +public abstract class StepRunnerPipelineTestBase : StepRunnerPipelineBaseTestSuite +{ + protected abstract StepRunnerPipeline CreateStepRunnerPipeline(IList steps, bool failFast, RunnerBehavior runnerBehavior); + + protected override StepRunnerPipelineBase CreateStepRunnerPipelineBase(IList steps, bool failFast, RunnerBehavior runnerBehavior) + { + return CreateStepRunnerPipeline(steps, failFast, runnerBehavior); + } + + protected abstract StepRunnerPipeline CreateTrackingStepRunnerPipeline( + IList steps, bool failFast, RunnerBehavior runnerBehavior, List callOrder, string? throwOnMethod = null, + Func, IEnumerable>? filterErrorStepsFunc = null); + + protected override StepRunnerPipelineBase CreateTrackingPipeline( + IList steps, bool failFast, RunnerBehavior runnerBehavior, List callOrder, + string? throwOnMethod = null, + Func, IEnumerable>? filterErrorStepsFunc = null) + { + return CreateTrackingStepRunnerPipeline(steps, failFast, runnerBehavior, callOrder, throwOnMethod, filterErrorStepsFunc); + } + + #region CreateRunner + + [Fact] + public virtual async Task CreateRunner_ReturnsNull_DuringPrepare_ThrowsInvalidOperationException() + { + var pipeline = new NullRunnerPipeline(ServiceProvider); + + await Assert.ThrowsAsync(async ()=> await pipeline.PrepareAsync(TestContext.Current.CancellationToken)); + + Assert.False(pipeline.Failed); + } + + [Fact] + public virtual async Task CreateRunner_ReturnsNull_DuringRun_ThrowsInvalidOperationException() + { + var pipeline = new NullRunnerPipeline(ServiceProvider); + + await Assert.ThrowsAsync(async () => await pipeline.RunAsync(TestContext.Current.CancellationToken)); + + Assert.True(pipeline.Failed); + } + + #endregion + + private class NullRunnerPipeline(IServiceProvider serviceProvider) : StepRunnerPipeline(serviceProvider) + { + protected override IStepRunner CreateRunner() + { + return null!; + } + + protected override Task> CreateRunnerSteps(CancellationToken token) + { + return Task.FromResult>([]); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Pipelines/TrackingPipelineHelper.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/TrackingPipelineHelper.cs new file mode 100644 index 00000000..13747325 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/TrackingPipelineHelper.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; + +internal sealed class TrackingPipelineHelper(List callOrder, string? throwOnMethod) +{ + public void OnExecuteStarted() + { + callOrder.Add("OnExecuteStarted"); + if (throwOnMethod == "OnExecuteStarted") + throw new InvalidOperationException("OnExecuteStarted threw"); + } + + public void OnRunnerExecuted() + { + callOrder.Add("OnRunnerExecuted"); + if (throwOnMethod == "OnRunnerExecuted") + throw new InvalidOperationException("OnRunnerExecuted threw"); + } + + public void OnExecuteCompleted() + { + callOrder.Add("OnExecuteCompleted"); + if (throwOnMethod == "OnExecuteCompleted") + throw new InvalidOperationException("OnExecuteCompleted threw"); + } +} diff --git a/src/CommonUtilities.SimplePipeline/test/Progress/AggregatedProgressReporterTests.cs b/src/CommonUtilities.SimplePipeline/test/Progress/AggregatedProgressReporterTests.cs index 099142d3..33f43e20 100644 --- a/src/CommonUtilities.SimplePipeline/test/Progress/AggregatedProgressReporterTests.cs +++ b/src/CommonUtilities.SimplePipeline/test/Progress/AggregatedProgressReporterTests.cs @@ -1,52 +1,118 @@ using System; using System.Collections.Generic; using AnakinRaW.CommonUtilities.SimplePipeline.Progress; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using AnakinRaW.CommonUtilities.Testing; using Xunit; // ReSharper disable InconsistentNaming +// ReSharper disable UnusedMember.Global namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Progress; -// ReSharper disable once UnusedMember.Global +#region Test Classes for AggregatedProgressReporter + public class AggregatedProgressReporterTest_Struct : AggregatedProgressReporterTestBase { protected override TestInfoStruct CreateCustomProgressInfo(TestProgressStep step, double progress) + => new() { Progress = progress }; + + protected override ITestableAggregatedReporter CreateReporter(IEnumerable> steps) + => new AggregateTestReporter(InternalReporter, steps); + + protected override ITestableAggregatedReporter CreateReporterWithComparer(IEnumerable> steps) + => new AggregateTestReporter(InternalReporter, steps, new TestStepEqualityComparer()); + + public override void Ctor_NullArgs_Throws() { - return new TestInfoStruct - { - Progress = progress, - }; + Assert.Throws(() => new AggregateTestReporter(null!, [])); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, null!)); + Assert.Throws(() => new AggregateTestReporter(null!, [], EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, null!, EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, [], null!)); } } -// ReSharper disable once UnusedMember.Global -public class AggregatedProgressReporterTest_Class: AggregatedProgressReporterTestBase +public class AggregatedProgressReporterTest_Class : AggregatedProgressReporterTestBase { protected override TestInfoClass CreateCustomProgressInfo(TestProgressStep step, double progress) + => new() { Progress = progress }; + + protected override ITestableAggregatedReporter CreateReporter(IEnumerable> steps) + => new AggregateTestReporter(InternalReporter, steps); + + protected override ITestableAggregatedReporter CreateReporterWithComparer(IEnumerable> steps) + => new AggregateTestReporter(InternalReporter, steps, new TestStepEqualityComparer()); + + public override void Ctor_NullArgs_Throws() { - return new TestInfoClass - { - Progress = progress, - }; + Assert.Throws(() => new AggregateTestReporter(null!, [])); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, null!)); + Assert.Throws(() => new AggregateTestReporter(null!, [], EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, null!, EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporter(InternalReporter, [], null!)); } } -public abstract class AggregatedProgressReporterTestBase : CommonTestBase where T : ITestInfo, new() +#endregion + +#region Test Classes for AggregatedProgressReporter + +public class AggregatedProgressReporterSimpleTest_Struct : AggregatedProgressReporterTestBase { - private readonly TestProgressReporter _internalReporter = new(); + protected override TestInfoStruct CreateCustomProgressInfo(TestProgressStep step, double progress) + => new() { Progress = progress }; - protected abstract T CreateCustomProgressInfo(TestProgressStep step, double progress); + protected override ITestableAggregatedReporter CreateReporter(IEnumerable> steps) + => new AggregateTestReporterSimple(InternalReporter, steps); - [Fact] - public void Ctor_NullArgs_Throws() + protected override ITestableAggregatedReporter CreateReporterWithComparer(IEnumerable> steps) + => new AggregateTestReporterSimple(InternalReporter, steps, new TestProgressStepEqualityComparer()); + + public override void Ctor_NullArgs_Throws() { - Assert.Throws(() => new AggregateTestReporter(null!, [])); - Assert.Throws(() => new AggregateTestReporter(_internalReporter, null!)); + Assert.Throws(() => new AggregateTestReporterSimple(null!, [])); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, null!)); + Assert.Throws(() => new AggregateTestReporterSimple(null!, [], EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, null!, EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, [], null!)); + } +} - Assert.Throws(() => new AggregateTestReporter(null!, [], EqualityComparer.Default)); - Assert.Throws(() => new AggregateTestReporter(_internalReporter, null!, EqualityComparer.Default)); - Assert.Throws(() => new AggregateTestReporter(_internalReporter, [], null!)); +public class AggregatedProgressReporterSimpleTest_Class : AggregatedProgressReporterTestBase +{ + protected override TestInfoClass CreateCustomProgressInfo(TestProgressStep step, double progress) + => new() { Progress = progress }; + + protected override ITestableAggregatedReporter CreateReporter(IEnumerable> steps) + => new AggregateTestReporterSimple(InternalReporter, steps); + + protected override ITestableAggregatedReporter CreateReporterWithComparer(IEnumerable> steps) + => new AggregateTestReporterSimple(InternalReporter, steps, new TestProgressStepEqualityComparer()); + + public override void Ctor_NullArgs_Throws() + { + Assert.Throws(() => new AggregateTestReporterSimple(null!, [])); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, null!)); + Assert.Throws(() => new AggregateTestReporterSimple(null!, [], EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, null!, EqualityComparer>.Default)); + Assert.Throws(() => new AggregateTestReporterSimple(InternalReporter, [], null!)); } +} + +#endregion + +#region Base Test Class + +public abstract class AggregatedProgressReporterTestBase : TestBaseWithServiceProvider where T : ITestInfo, new() +{ + protected readonly TestProgressReporter InternalReporter = new(); + + protected abstract T CreateCustomProgressInfo(TestProgressStep step, double progress); + protected abstract ITestableAggregatedReporter CreateReporter(IEnumerable> steps); + protected abstract ITestableAggregatedReporter CreateReporterWithComparer(IEnumerable> steps); + + [Fact] + public abstract void Ctor_NullArgs_Throws(); [Fact] public void Ctor_SetsProperties() @@ -55,22 +121,21 @@ public void Ctor_SetsProperties() var steps = new List> { step, - step, // Add same reference twice + step, new(2, "Step 2", ServiceProvider), new(3, "Step 3", ServiceProvider) }; - using var reporter = new AggregateTestReporter(_internalReporter, steps); + using var reporter = CreateReporter(steps); Assert.Equal(6, reporter.TotalSize); Assert.Equal(3, reporter.TotalStepCount); } - [Fact] public void Ctor_SetsProperties_EmptySteps() { - using var reporter = new AggregateTestReporter(_internalReporter, []); + using var reporter = CreateReporter([]); Assert.Equal(0, reporter.TotalSize); Assert.Equal(0, reporter.TotalStepCount); @@ -84,12 +149,12 @@ public void Ctor_SetsProperties_WithEqualityComparer() var steps = new List> { step, - other, // Add a step that equals 'step' + other, new(2, "Step 2", ServiceProvider), new(3, "Step 3", ServiceProvider) }; - using var reporter = new AggregateTestReporter(_internalReporter, steps, new TestStepEqualityComparer()); + using var reporter = CreateReporterWithComparer(steps); Assert.Equal(6, reporter.TotalSize); Assert.Equal(3, reporter.TotalStepCount); @@ -99,45 +164,45 @@ public void Ctor_SetsProperties_WithEqualityComparer() public void Report_IgnoresUnregisteredStep() { var step = new TestProgressStep(1, "Step 1", ServiceProvider); - _ = new AggregateTestReporter(_internalReporter, []); - step.Report( 0.5, "step", CreateCustomProgressInfo(step, 0.5)); - Assert.Null(_internalReporter.ReportedData); + _ = CreateReporter([]); + step.Report(0.5, "step", CreateCustomProgressInfo(step, 0.5)); + Assert.Null(InternalReporter.ReportedData); } [Fact] public void Report_DefaultT() { var step = new TestProgressStep(1, "Step 1", ServiceProvider); - _ = new AggregateTestReporter(_internalReporter, [step]); - step.Report( 0.5, "Text", default); - - Assert.NotNull(_internalReporter.ReportedData); - Assert.Equal("Step 1aggregated", _internalReporter.ReportedData.Text); - Assert.Equal("test", _internalReporter.ReportedData.Type.Id); - Assert.Equal(0.5, _internalReporter.ReportedData.Progress); - if (typeof(T).IsValueType) - Assert.Equal(0, _internalReporter.ReportedData.ProgressInfo!.Progress); + var r = CreateReporter([step]); + step.Report(0.5, "Text", default); + + Assert.NotNull(InternalReporter.ReportedData); + Assert.Equal("Step 1aggregated", InternalReporter.ReportedData.Text); + Assert.Equal("test", InternalReporter.ReportedData.Type.Id); + Assert.Equal(0.5, InternalReporter.ReportedData.Progress); + if (typeof(T).IsValueType) + Assert.Equal(0, InternalReporter.ReportedData.ProgressInfo!.Progress); else - Assert.Equal(-1, _internalReporter.ReportedData.ProgressInfo!.Progress); - Assert.True(_internalReporter.ReportedData.ProgressInfo!.Aggregated); + Assert.Equal(-1, InternalReporter.ReportedData.ProgressInfo!.Progress); + Assert.True(InternalReporter.ReportedData.ProgressInfo!.Aggregated); } [Fact] public void Report_DefaultCustomT() { var step = new TestProgressStep(1, "Step 1", ServiceProvider); - _ = new AggregateTestReporter(_internalReporter, [step]); + _ = CreateReporter([step]); step.Report(0.5, "Text", CreateCustomProgressInfo(step, 0.5)); var expected = CreateCustomProgressInfo(step, 0.5); expected.Aggregated = true; - Assert.NotNull(_internalReporter.ReportedData); - Assert.Equal("Step 1aggregated", _internalReporter.ReportedData.Text); - Assert.Equal("test", _internalReporter.ReportedData.Type.Id); - Assert.Equal(0.5, _internalReporter.ReportedData.Progress); - Assert.Equal(expected, _internalReporter.ReportedData.ProgressInfo); + Assert.NotNull(InternalReporter.ReportedData); + Assert.Equal("Step 1aggregated", InternalReporter.ReportedData.Text); + Assert.Equal("test", InternalReporter.ReportedData.Type.Id); + Assert.Equal(0.5, InternalReporter.ReportedData.Progress); + Assert.Equal(expected, InternalReporter.ReportedData.ProgressInfo); } [Fact] @@ -146,72 +211,89 @@ public void Report() var step1 = new TestProgressStep(1, "Step 1", ServiceProvider); var step2 = new TestProgressStep(1, "Step 2", ServiceProvider); - _ = new AggregateTestReporter(_internalReporter, [step1, step2]); + _ = CreateReporter([step1, step2]); step1.Report(0.5, "step1", default); - Assert.NotNull(_internalReporter.ReportedData); - - Assert.Equal("Step 1aggregated", _internalReporter.ReportedData.Text); - Assert.Equal("test", _internalReporter.ReportedData.Type.Id); - Assert.Equal(0.5, _internalReporter.ReportedData.Progress); + Assert.NotNull(InternalReporter.ReportedData); + Assert.Equal("Step 1aggregated", InternalReporter.ReportedData.Text); + Assert.Equal("test", InternalReporter.ReportedData.Type.Id); + Assert.Equal(0.5, InternalReporter.ReportedData.Progress); if (typeof(T).IsValueType) - Assert.Equal(0, _internalReporter.ReportedData.ProgressInfo!.Progress); + Assert.Equal(0, InternalReporter.ReportedData.ProgressInfo!.Progress); else - Assert.Equal(-1, _internalReporter.ReportedData.ProgressInfo!.Progress); - Assert.True(_internalReporter.ReportedData.ProgressInfo!.Aggregated); + Assert.Equal(-1, InternalReporter.ReportedData.ProgressInfo!.Progress); + Assert.True(InternalReporter.ReportedData.ProgressInfo!.Aggregated); - step2.Report( 1, null, default); + step2.Report(1, null, default); - Assert.Equal("Step 2aggregated", _internalReporter.ReportedData.Text); - Assert.Equal("test", _internalReporter.ReportedData.Type.Id); - Assert.Equal(1, _internalReporter.ReportedData.Progress); + Assert.Equal("Step 2aggregated", InternalReporter.ReportedData.Text); + Assert.Equal("test", InternalReporter.ReportedData.Type.Id); + Assert.Equal(1, InternalReporter.ReportedData.Progress); if (typeof(T).IsValueType) - Assert.Equal(0, _internalReporter.ReportedData.ProgressInfo!.Progress); + Assert.Equal(0, InternalReporter.ReportedData.ProgressInfo!.Progress); else - Assert.Equal(-1, _internalReporter.ReportedData.ProgressInfo!.Progress); - Assert.True(_internalReporter.ReportedData.ProgressInfo!.Aggregated); + Assert.Equal(-1, InternalReporter.ReportedData.ProgressInfo!.Progress); + Assert.True(InternalReporter.ReportedData.ProgressInfo!.Aggregated); } [Fact] public void Report_NoReportIfDisposed() { var step = new TestProgressStep(1, "Step 1", ServiceProvider); - var aggregator = new AggregateTestReporter(_internalReporter, [step]); + var aggregator = CreateReporter([step]); aggregator.Dispose(); - step.Report( 0.5, "step", CreateCustomProgressInfo(step, 0.5)); - Assert.Null(_internalReporter.ReportedData); + step.Report(0.5, "step", CreateCustomProgressInfo(step, 0.5)); + Assert.Null(InternalReporter.ReportedData); } } -internal class TestStepEqualityComparer : EqualityComparer> +#endregion + +#region Test Infrastructure + +public interface ITestableAggregatedReporter : IDisposable +{ + long TotalSize { get; } + int TotalStepCount { get; } +} + +public class TestStepEqualityComparer : EqualityComparer> { public override bool Equals(TestProgressStep? x, TestProgressStep? y) { - if (ReferenceEquals(x, y)) - return true; - if (x is null || y is null) - return false; + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; return x.Text.Equals(y.Text); } - public override int GetHashCode(TestProgressStep obj) + public override int GetHashCode(TestProgressStep obj) => obj.Text.GetHashCode(); +} + +public class TestProgressStepEqualityComparer : EqualityComparer> +{ + public override bool Equals(IProgressStep? x, IProgressStep? y) { - return obj.Text.GetHashCode(); + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + return x is TestProgressStep tx && y is TestProgressStep ty && tx.Text.Equals(ty.Text); } + + public override int GetHashCode(IProgressStep obj) => + obj is TestProgressStep t ? t.Text.GetHashCode() : obj.GetHashCode(); } -internal class AggregateTestReporter : AggregatedProgressReporter, T> where T : ITestInfo, new() +public class AggregateTestReporter : AggregatedProgressReporter, T>, ITestableAggregatedReporter + where T : ITestInfo, new() { + long ITestableAggregatedReporter.TotalSize => TotalSize; + int ITestableAggregatedReporter.TotalStepCount => TotalStepCount; + public AggregateTestReporter(IProgressReporter progressReporter, IEnumerable> steps) - : base(progressReporter, steps) - { - } + : base(progressReporter, steps) { } public AggregateTestReporter(IProgressReporter progressReporter, IEnumerable> steps, IEqualityComparer> equalityComparer) - : base(progressReporter, steps, equalityComparer) - { - } + : base(progressReporter, steps, equalityComparer) { } protected override string GetProgressText(TestProgressStep step, string? progressText) { @@ -221,16 +303,37 @@ protected override string GetProgressText(TestProgressStep step, string? prog protected override ProgressEventArgs CalculateAggregatedProgress(TestProgressStep task, ProgressEventArgs progress) { - var newT = new T - { - Aggregated = true, - Progress = progress.ProgressInfo?.Progress ?? -1 - }; + var newT = new T { Aggregated = true, Progress = progress.ProgressInfo?.Progress ?? -1 }; + return new ProgressEventArgs(progress.Progress, "aggregated", newT); + } +} + +public class AggregateTestReporterSimple : AggregatedProgressReporter, ITestableAggregatedReporter + where T : ITestInfo, new() +{ + long ITestableAggregatedReporter.TotalSize => TotalSize; + int ITestableAggregatedReporter.TotalStepCount => TotalStepCount; + + public AggregateTestReporterSimple(IProgressReporter progressReporter, IEnumerable> steps) + : base(progressReporter, steps) { } + + public AggregateTestReporterSimple(IProgressReporter progressReporter, IEnumerable> steps, IEqualityComparer> equalityComparer) + : base(progressReporter, steps, equalityComparer) { } + + protected override string GetProgressText(IProgressStep step, string? progressText) + { + Assert.Equal("aggregated", progressText); + return ((TestProgressStep)step).Text + progressText; + } + + protected override ProgressEventArgs CalculateAggregatedProgress(IProgressStep task, ProgressEventArgs progress) + { + var newT = new T { Aggregated = true, Progress = progress.ProgressInfo?.Progress ?? -1 }; return new ProgressEventArgs(progress.Progress, "aggregated", newT); } } -internal class TestProgressReporter : IProgressReporter +public class TestProgressReporter : IProgressReporter { public ReportedData? ReportedData { get; private set; } @@ -246,10 +349,12 @@ public void Report(double progress, string? progressText, ProgressType type, T? } } -internal class ReportedData +public class ReportedData { public string? Text { get; init; } public double Progress { get; init; } public ProgressType Type { get; init; } public T? ProgressInfo { get; init; } -} \ No newline at end of file +} + +#endregion \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Progress/ProgressEventArgsTest.cs b/src/CommonUtilities.SimplePipeline/test/Progress/ProgressEventArgsTest.cs index 2540a222..ed3cf7d4 100644 --- a/src/CommonUtilities.SimplePipeline/test/Progress/ProgressEventArgsTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Progress/ProgressEventArgsTest.cs @@ -1,5 +1,6 @@ using System; using AnakinRaW.CommonUtilities.SimplePipeline.Progress; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Progress; diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/AsyncStepRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/AsyncStepRunnerTest.cs new file mode 100644 index 00000000..ba5a6dbe --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Runners/AsyncStepRunnerTest.cs @@ -0,0 +1,21 @@ +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; + +public class AsyncStepRunnerTest : StepRunnerTestSuite +{ + public override bool HasSequentialStepExecutionOrder => false; + + public override bool SupportsSequentialExecutionOrder => true; + + protected override AsyncStepRunner CreateStepRunner(bool? sequential = null) + { + var workers = sequential is true ? 1 : 4; + return CreateStepRunner(workers); + } + + protected override AsyncStepRunner CreateStepRunner(int workerCount) + { + return new AsyncStepRunner(workerCount, ServiceProvider); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerStepRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerStepRunnerTest.cs deleted file mode 100644 index 4fc03bae..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerStepRunnerTest.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; - -public class ParallelProducerConsumerStepRunnerTest : ParallelStepRunnerTestBase -{ - protected override ParallelProducerConsumerStepRunner CreateParallelRunner(int workerCount = 2) - { - return CreateConsumerStepRunner(workerCount); - } - - private ParallelProducerConsumerStepRunner CreateConsumerStepRunner(int workerCount = 2) - { - return new ParallelProducerConsumerStepRunner(workerCount, ServiceProvider); - } - - protected override void FinishAdding(ParallelProducerConsumerStepRunner runner) - { - runner.Finish(); - } - - [Fact] - public async Task Run_WaitNotFinished() - { - var runner = CreateStepRunner(); - - var tsc1 = new TaskCompletionSource(); - var tsc2 = new TaskCompletionSource(); - - var s1 = new TestStep(_ => tsc1.SetResult(1), ServiceProvider); - var s2 = new TestStep(_ => tsc2.SetResult(1), ServiceProvider); - - runner.AddStep(s1); - runner.AddStep(s2); - - _ = runner.RunAsync(CancellationToken.None); - - await tsc1.Task; - await tsc1.Task; - - Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(2))); - } - - - [Fact] - public async Task Run_WaitNotFinished_CancellationShouldFinish() - { - var runner = CreateStepRunner(); - - var cts = new CancellationTokenSource(); - var task = runner.RunAsync(cts.Token); - - // Give it some time - await Task.Delay(500, CancellationToken.None); - - cts.Cancel(); - - await task; - - Assert.True(runner.IsCancelled); - Assert.NotNull(runner.Exception); - Assert.IsType(runner.Exception.InnerExceptions.First(), true); - } - - [Fact] - public void Run_AddDelayed() - { - var runner = CreateStepRunner(); - - var ran1 = false; - var ran2 = false; - var ran3 = false; - var s1 = new TestStep(_ => ran1 = true, ServiceProvider); - var s2 = new TestStep(_ => ran2 = true, ServiceProvider); - var s3 = new TestStep(_ => ran3 = true, ServiceProvider); - - runner.AddStep(s1); - runner.AddStep(s2); - - _ = runner.RunAsync(CancellationToken.None); - - Task.Run(() => - { - runner.AddStep(s3); - Task.Delay(1000); - runner.Finish(); - }); - - runner.Wait(); - - Assert.True(ran1); - Assert.True(ran2); - Assert.True(ran3); - } - - [Fact] - public async Task Run_AddDelayed_Await() - { - var runner = CreateStepRunner(); - - var ran1 = false; - var ran2 = false; - var ran3 = false; - var s1 = new TestStep(_ => ran1 = true, ServiceProvider); - var s2 = new TestStep(_ => ran2 = true, ServiceProvider); - var s3 = new TestStep(_ => ran3 = true, ServiceProvider); - - runner.AddStep(s1); - runner.AddStep(s2); - - var runTask = runner.RunAsync(CancellationToken.None); - - Task.Run(() => - { - runner.AddStep(s3); - runner.Finish(); - - }).Forget(); - - await runTask; - // Should not block - runner.Wait(); - - Assert.True(ran1); - Assert.True(ran2); - Assert.True(ran3); - } - - [Fact] - public async Task Run_AddDelayed_Cancelled() - { - var runner = CreateStepRunner(); - - var tcs = new TaskCompletionSource(); - - var ran1 = false; - var s1 = new TestStep(_ => - { - ran1 = true; - tcs.SetResult(0); - }, ServiceProvider); - var ran2 = false; - var s2 = new TestStep(_ => ran2 = true, ServiceProvider); - - runner.AddStep(s1); - - var cts = new CancellationTokenSource(); - - var runTask = runner.RunAsync(cts.Token); - - Task.Run(async () => - { - await tcs.Task.ConfigureAwait(false); - await Task.Delay(1000, CancellationToken.None); // Give it some time, so ensure the runner is internally blocking and waiting for the next step. - cts.Cancel(); - runner.AddStep(s2); - }, CancellationToken.None).Forget(); - - - await runTask; - - Assert.True(ran1); - Assert.False(ran2); - Assert.Equal([s1], runner.ExecutedSteps); - - Assert.True(runner.IsCancelled); - Assert.NotNull(runner.Exception); - - Assert.IsType(runner.Exception.InnerExceptions.First(), true); - } - - [Fact] - public void AddStep_AddAfterFinish() - { - var runner = CreateStepRunner(); - var s1 = new TestStep(_ => { }, ServiceProvider); - runner.AddStep(s1); - - runner.Finish(); - - Assert.Throws(() => runner.AddStep(s1)); - } - - [Fact] - public async Task RunAsync_Cancelled() - { - // Have deterministic result - var runner = CreateParallelRunner(1); - - var cts = new CancellationTokenSource(); - - var b = new ManualResetEvent(false); - - var step1 = new TestStep(_ => - { - cts.Cancel(); - b.Set(); - }, ServiceProvider); - - var step2 = new TestStep(_ => {}, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - await runner.RunAsync(cts.Token); - - // This is all we can test for, because we cannot know whether the cancellation was requested while fetching step queue data - // or the step was already fetched - Assert.True(runner.IsCancelled); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTest.cs deleted file mode 100644 index 68d51725..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTest.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Threading; -using AnakinRaW.CommonUtilities.SimplePipeline.Runners; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; - -public class ParallelStepRunnerTest : ParallelStepRunnerTestBase -{ - protected override ParallelStepRunner CreateParallelRunner(int workerCount = 2) - { - return CreateParallelStepRunner(workerCount); - } - - private ParallelStepRunner CreateParallelStepRunner(int workerCount = 2) - { - return new ParallelStepRunner(workerCount, ServiceProvider); - } - - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws(() => new ParallelStepRunner(1, null!)); - Assert.Throws(() => new ParallelStepRunner(new Random().Next(int.MinValue, 0), ServiceProvider)); - Assert.Throws(() => new ParallelStepRunner(0, ServiceProvider)); - } - - [Fact] - public async Task RunAsync_Cancelled() - { - // Have deterministic result - var runner = CreateParallelRunner(1); - - var cts = new CancellationTokenSource(); - - var b = new ManualResetEvent(false); - - StepRunnerErrorEventArgs? error = null!; - runner.Error += (_, e) => - { - error = e; - }; - - var ran1 = false; - var step1 = new TestStep(_ => - { - ran1 = true; - cts.Cancel(); - b.Set(); - }, ServiceProvider); - - var ran2 = false; - var step2 = new TestStep(_ => ran2 = true, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - await runner.RunAsync(cts.Token); - - Assert.True(runner.IsCancelled); - Assert.NotNull(error); - Assert.True(error.Cancel); - Assert.True(ran1); - Assert.False(ran2); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTestBase.cs b/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTestBase.cs deleted file mode 100644 index e446301a..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelStepRunnerTestBase.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; - -public abstract class ParallelStepRunnerTestBase : StepRunnerTestBase where T : class, IParallelStepRunner -{ - public override bool PreservesStepExecutionOrder => false; - - protected abstract T CreateParallelRunner(int workerCount = 2); - - protected sealed override T CreateStepRunner(bool deterministic = false) - { - return CreateParallelRunner(deterministic ? 1 : new Random().Next(2, 6)); - } - - [Fact] - public void Ctor_WorkerCount() - { - Assert.Throws(() => CreateParallelRunner(new Random().Next(65, int.MaxValue))); - Assert.Throws(() => CreateParallelRunner(new Random().Next(int.MinValue, 0))); - Assert.Throws(() => CreateParallelRunner(0)); - - var numRunners = new Random().Next(1, 64); - var runner = CreateParallelRunner(numRunners); - - Assert.Equal(numRunners, runner.WorkerCount); - } - - [Fact] - public void Wait() - { - var runner = CreateParallelRunner(); - - var ran1 = false; - var ran2 = false; - - var step1 = new TestStep(_ => ran1 = true, ServiceProvider); - var step2 = new TestStep(_ => ran2 = true, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - runner.RunAsync(CancellationToken.None).Forget(); - runner.Wait(); - - // We cannot assert on the returned task, - // as the impl. creates different tasks for await and Wait(). - // This may result in a race where the Wait() reports completion before the awaitable task - - Assert.True(ran1); - Assert.True(ran2); - } - - [Fact] - public void Wait_FailedRunner_Throws() - { - var runner = CreateParallelRunner(); - - var ran1 = false; - var ran2 = false; - - var step1 = new TestStep(_ => - { - ran1 = true; - throw new Exception("Test"); - }, ServiceProvider); - var step2 = new TestStep(_ => ran2 = true, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - var runnerTask = runner.RunAsync(CancellationToken.None); - - var e = Assert.Throws(() => runner.Wait()); - Assert.Equal("Test", e.InnerExceptions.First().Message); - - Assert.True(runnerTask.IsCompleted); - Assert.False(runnerTask.IsFaulted); - - Assert.NotNull(runner.Exception); - - Assert.True(ran1); - Assert.True(ran2); - } - - [Fact] - public async Task RunAsync_Await() - { - var runner = CreateParallelRunner(); - - var b = new ManualResetEvent(false); - - var ran1 = false; - var ran2 = false; - - var step1 = new TestStep(_ => - { - b.WaitOne(); - ran1 = true; - }, ServiceProvider); - var step2 = new TestStep(_ => - { - b.WaitOne(); - ran2 = true; - }, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - var runTask = runner.RunAsync(CancellationToken.None); - - Assert.False(ran1); - Assert.False(ran2); - - b.Set(); - - await runTask; - - Assert.True(ran1); - Assert.True(ran2); - - Assert.Equivalent(new HashSet([step1, step2]), runner.ExecutedSteps, true); - } - - [Fact] - public async Task Wait_Timeout_ThrowsTimeoutException() - { - var runner = CreateParallelRunner(); - - var b = new ManualResetEvent(false); - - var step1 = new TestStep(_ => - { - b.WaitOne(); - }, ServiceProvider); - - runner.AddStep(step1); - - FinishAdding(runner); - - var runnerTask = runner.RunAsync(CancellationToken.None); - - Assert.Throws(() => runner.Wait(TimeSpan.FromMilliseconds(100))); - - // Even if Wait throws, we still continue executing - b.Set(); - await runnerTask; - - Assert.True(runnerTask.IsCompleted); - } - - [Fact] - public async Task RunAsync_ErrorSetsCancellation() - { - // Have deterministic result - var runner = CreateParallelRunner(1); - - var step1 = new TestStep(_ => throw new StopRunnerException(), ServiceProvider); - var ran2 = false; - var step2 = new TestStep(_ => { ran2 = true; }, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - // Do not signal runner to finish. It must do that on itself! - - var runnerTask = runner.RunAsync(CancellationToken.None); - - await runnerTask; - - Assert.False(ran2); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/ProducerConsumerStepRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/ProducerConsumerStepRunnerTest.cs new file mode 100644 index 00000000..99b64125 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Runners/ProducerConsumerStepRunnerTest.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; + +public class ProducerConsumerStepRunnerTest : StepRunnerTestSuite +{ + public override bool HasSequentialStepExecutionOrder => false; + + public override bool SupportsSequentialExecutionOrder => true; + + protected override bool SupportsAddingStepsAfterCancellation => false; + + protected override ProducerConsumerStepRunner CreateStepRunner(bool? sequential = null) + { + var workers = sequential is true ? 1 : 4; + return CreateStepRunner(workers); + } + + protected override ProducerConsumerStepRunner CreateStepRunner(int workerCount) + { + return new ProducerConsumerStepRunner(workerCount, ServiceProvider); + } + + protected override void FinishAdding(ProducerConsumerStepRunner runner) + { + base.FinishAdding(runner); + runner.Finish(); + } + + #region Finish + + [Fact] + public void Finish_CalledMultipleTimes() + { + var runner = CreateStepRunner(); + runner.Finish(); + runner.Finish(); + runner.Finish(); + } + + [Fact] + public async Task Finish_CalledBeforeRun_AllStepsExecute() + { + var runner = CreateStepRunner(); + var executedSteps = new ConcurrentBag(); + + for (var i = 0; i < 10; i++) + { + var index = i; + runner.AddStep(new TestStep(_ => + { + executedSteps.Add(index); + return Task.CompletedTask; + }, ServiceProvider)); + } + + runner.Finish(); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(10, executedSteps.Count); + } + + [Fact] + public void Finish_AddStepAfterFinish_ThrowsException() + { + var runner = CreateStepRunner(); + runner.Finish(); + + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + Assert.Throws(() => runner.AddStep(step)); + } + + [Fact] + public async Task Finish_WhileRunning_AllQueuedStepsComplete() + { + var runner = CreateStepRunner(workerCount: 2); + var executedSteps = new ConcurrentBag(); + var step1Started = new ManualResetEventSlim(false); + var allStepsAdded = new ManualResetEventSlim(false); + + runner.AddStep(new TestStep(async _ => + { + await Task.Yield(); + step1Started.Set(); + executedSteps.Add(1); + allStepsAdded.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider)); + + var runTask = runner.RunAsync(CancellationToken.None); + + step1Started.Wait(TestContext.Current.CancellationToken); + + for (var i = 2; i <= 5; i++) + { + var index = i; + runner.AddStep(new TestStep(_ => + { + executedSteps.Add(index); + return Task.CompletedTask; + }, ServiceProvider)); + } + allStepsAdded.Set(); + + runner.Finish(); + await runTask; + + Assert.Equal(5, executedSteps.Count); + } + + #endregion + + #region As Sequential + + [Fact] + public async Task Sequential_TakeNextStep_BlocksUntilStepAvailable() + { + var runner = CreateStepRunner(workerCount: 1); + var step1Started = new ManualResetEventSlim(false); + var step2Added = new ManualResetEventSlim(false); + var executedSteps = new ConcurrentBag(); + + runner.AddStep(new TestStep(async _ => + { + await Task.Yield(); + step1Started.Set(); + executedSteps.Add(1); + step2Added.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider)); + + var runTask = runner.RunAsync(CancellationToken.None); + + step1Started.Wait(TestContext.Current.CancellationToken); + + runner.AddStep(new TestStep(_ => + { + executedSteps.Add(2); + return Task.CompletedTask; + }, ServiceProvider)); + + step2Added.Set(); + runner.Finish(); + await runTask; + + Assert.Equal(2, executedSteps.Count); + Assert.Contains(1, executedSteps); + Assert.Contains(2, executedSteps); + } + + [Fact] + public async Task Error_SetCancelToTrue_Sequential_StepsInQueue_AreNotExecuted() + { + var runner = CreateStepRunner(workerCount: 1); + var executedSteps = new ConcurrentBag(); + var errorOccurred = new ManualResetEventSlim(false); + var barrier = new ManualResetEventSlim(false); + + runner.Error += (_, args) => + { + args.Cancel = true; + errorOccurred.Set(); + }; + + runner.AddStep(new TestStep(async _ => + { + await Task.Yield(); + executedSteps.Add(1); + barrier.Wait(TestContext.Current.CancellationToken); + throw new InvalidOperationException("Test error"); + }, ServiceProvider)); + + for (var i = 2; i <= 5; i++) + { + var index = i; + runner.AddStep(new TestStep(_ => + { + executedSteps.Add(index); + return Task.CompletedTask; + }, ServiceProvider)); + } + + runner.Finish(); + var runTask = runner.RunAsync(CancellationToken.None); + + await Task.Delay(100, TestContext.Current.CancellationToken); + barrier.Set(); + + errorOccurred.Wait(TestContext.Current.CancellationToken); + await runTask; + + Assert.Contains(1, executedSteps); + Assert.DoesNotContain(2, executedSteps); + Assert.DoesNotContain(3, executedSteps); + Assert.DoesNotContain(4, executedSteps); + Assert.DoesNotContain(5, executedSteps); + } + + #endregion + + #region RunAsync Extended Behavior + + [Fact] + public async Task RunAsync_MultipleWorkers_ExecutesStepsConcurrently() + { + const int workerCount = 4; + const int totalSteps = 20; + var runner = CreateStepRunner(workerCount); + var executedSteps = new ConcurrentBag(); + + var concurrentCount = 0; + var maxConcurrentCount = 0; + var lockObj = new object(); + + for (var i = 0; i < totalSteps; i++) + { + var index = i; + runner.AddStep(new TestStep(async _ => + { + var current = Interlocked.Increment(ref concurrentCount); + lock (lockObj) + { + if (current > maxConcurrentCount) + maxConcurrentCount = current; + } + + await Task.Delay(new Random().Next(50, 300), TestContext.Current.CancellationToken); + executedSteps.Add(index); + + Interlocked.Decrement(ref concurrentCount); + }, ServiceProvider)); + } + + runner.Finish(); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(totalSteps, executedSteps.Count); + Assert.True(maxConcurrentCount >= 2, $"Expected concurrent execution, but max concurrent was {maxConcurrentCount}"); + } + + [Fact] + public async Task RunAsync_NotFinished_NeverEnds() + { + var runner = CreateStepRunner(); + + var tsc1 = new TaskCompletionSource(); + var tsc2 = new TaskCompletionSource(); + + var s1 = new TestStep(_ => { tsc1.SetResult(1); return Task.CompletedTask; }, ServiceProvider); + var s2 = new TestStep(_ => { tsc2.SetResult(1); return Task.CompletedTask; }, ServiceProvider); + + runner.AddStep(s1); + runner.AddStep(s2); + + _ = runner.RunAsync(CancellationToken.None); + + await tsc1.Task; + await tsc2.Task; + + Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(2))); + } + + [Fact] + public async Task RunAsync_AddStep_AfterCancellation() + { + var runner = CreateStepRunner(); + + StepRunnerErrorEventArgs? raisedArgs = null; + runner.Error += (_, args) => + { + raisedArgs = args; + Assert.Null(args.Step); + Assert.True(args.Cancel); + }; + + var tcs = new TaskCompletionSource(); + + var ran1 = false; + var s1 = new TestStep(async _ => + { + await Task.Yield(); + ran1 = true; + tcs.SetResult(0); + }, ServiceProvider); + + runner.AddStep(s1); + + var cts = new CancellationTokenSource(); + + var runTask = runner.RunAsync(cts.Token); + + Task.Run(async () => + { + await tcs.Task.ConfigureAwait(false); + + // Give it some time, so ensure the runner is internally blocking and waiting for the next step. + await Task.Delay(1000, CancellationToken.None); + cts.Cancel(); + Assert.Throws(() => runner.AddStep(new TestStep(_ => Task.CompletedTask, ServiceProvider))); + }, CancellationToken.None).Forget(); + + + await runTask; + + Assert.True(ran1); + Assert.Equal([s1], runner.ExecutedSteps); + + Assert.True(runner.IsCancelled); + Assert.Null(runner.Exception); + Assert.NotNull(raisedArgs); + } + + #endregion + + #region Automatic Finish + + [Fact] + public async Task Finished_OnStopRunnerException_RunnerFinishes() + { + var runner = CreateStepRunner(workerCount: 4); + + StepRunnerErrorEventArgs? raisedArgs = null; + runner.Error += (_, args) => + { + Assert.True(args.Cancel); + raisedArgs = args; + }; + + runner.AddStep(new TestStep(_ => throw new StopRunnerException(), ServiceProvider)); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(raisedArgs); + + var step2 = new TestStep(_ => Task.CompletedTask, ServiceProvider); + Assert.Throws(() => runner.AddStep(step2)); + } + + [Fact] + public async Task Finished_CancellationShouldFinish() + { + var runner = CreateStepRunner(); + + StepRunnerErrorEventArgs? raisedArgs = null; + runner.Error += (_, args) => + { + raisedArgs = args; + Assert.Null(args.Step); + Assert.True(args.Cancel); + }; + + var cts = new CancellationTokenSource(); + var task = runner.RunAsync(cts.Token); + + // Give it some time + await Task.Delay(500, CancellationToken.None); + + cts.Cancel(); + + await task; + + Assert.Throws(() => runner.AddStep(new TestStep(_ => Task.CompletedTask, ServiceProvider))); + + Assert.True(runner.IsCancelled); + Assert.Null(runner.Exception); + Assert.NotNull(raisedArgs); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/SequentialStepRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/SequentialStepRunnerTest.cs index 708b73a5..5c8934c7 100644 --- a/src/CommonUtilities.SimplePipeline/test/Runners/SequentialStepRunnerTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Runners/SequentialStepRunnerTest.cs @@ -1,20 +1,27 @@ using AnakinRaW.CommonUtilities.SimplePipeline.Runners; using System; -using System.Threading.Tasks; -using System.Threading; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; -public class SequentialStepRunnerTest : StepRunnerTestBase +public class SequentialStepRunnerTest : StepRunnerTestSuite { - public override bool PreservesStepExecutionOrder => true; + public override bool HasSequentialStepExecutionOrder => true; - protected override SequentialStepRunner CreateStepRunner(bool deterministic = false) + public override bool SupportsSequentialExecutionOrder => true; + + protected override SequentialStepRunner CreateStepRunner(bool? sequential = null) { + if (sequential is false) + throw new InvalidOperationException(); return new SequentialStepRunner(ServiceProvider); } + protected override SequentialStepRunner CreateStepRunner(int workerCount) + { + throw new NotSupportedException(); + } + [Fact] public void Ctor_InvalidArgs_Throws() { @@ -22,30 +29,9 @@ public void Ctor_InvalidArgs_Throws() } [Fact] - public async Task RunAsync_ErrorSetsCancellation() + public void Ctor_WorkerCountIsOne() { - var runner = CreateStepRunner(); - - var errorCounter = 0; - runner.Error += (_, e) => - { - errorCounter++; - if (e.Step?.Error?.Message == "Test") - e.Cancel = true; - }; - - var step1 = new TestStep(_ => throw new Exception("Test"), ServiceProvider); - var ran2 = false; - var step2 = new TestStep(_ => { ran2 = true; }, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - var runnerTask = runner.RunAsync(CancellationToken.None); - - await runnerTask; - - Assert.Equal(2, errorCounter); - Assert.False(ran2); + var runner = new SequentialStepRunner(ServiceProvider); + Assert.Equal(1, runner.WorkerCount); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestBase.cs b/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestBase.cs deleted file mode 100644 index cf12592f..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestBase.cs +++ /dev/null @@ -1,214 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Threading; -using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.Testing; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; - -public abstract class StepRunnerTestBase : CommonTestBase where T : class, IStepRunner -{ - public abstract bool PreservesStepExecutionOrder { get; } - - protected abstract T CreateStepRunner(bool deterministic = false); - - protected virtual void FinishAdding(T runner) - { - } - - [Fact] - public void AddStep_Null_Throws() - { - var runner = CreateStepRunner(); - Assert.Throws(() => runner.AddStep(null!)); - } - - [Fact] - public async Task RunAsync_StepsEmpty() - { - var runner = CreateStepRunner(); - FinishAdding(runner); - await runner.RunAsync(CancellationToken.None); - Assert.Empty(runner.ExecutedSteps); - } - - [Fact] - public async Task RunAsync_CancelledWhenStarted() - { - var runner = CreateStepRunner(); - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - var ran = false; - var step = new TestStep(_ => ran = true, ServiceProvider); - - runner.AddStep(step); - - FinishAdding(runner); - - await runner.RunAsync(cts.Token); - - Assert.False(ran); - Assert.Empty(runner.ExecutedSteps); - } - - [Fact] - public async Task RunAsync_WithError() - { - var runner = CreateStepRunner(); - - StepRunnerErrorEventArgs? error = null; - runner.Error += (s, e) => - { - Assert.Same(runner, s); - error = e; - }; - - var ran1 = false; - var ran2 = false; - var step1 = new TestStep(_ => - { - ran1 = true; - throw new Exception("Test"); - }, ServiceProvider); - var step2 = new TestStep(_ => - { - ran2 = true; - }, ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - await runner.RunAsync(CancellationToken.None); - - Assert.NotNull(error); - Assert.False(error.Cancel); - Assert.Same(step1, error.Step); - - Assert.True(ran1); - Assert.True(ran2); - Assert.Equal("Test", step1.Error!.Message); - } - - [Fact] - public async Task RunAsync() - { - var runner = CreateStepRunner(); - - var hasError = false; - runner.Error += (_, _) => - { - hasError = true; - }; - - var ranList = new List(); - var tsc = new ManualResetEventSlim(false); - var step1 = new TestStep(_ => - { - ranList.Add("Step1"); - tsc.Wait(); - }, ServiceProvider); - var step2 = new TestStep(_ => ranList.Add("Step2"), ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - var runnerTask = runner.RunAsync(CancellationToken.None); - - // Step that was added later, also gets executed - var step3 = new TestStep(_ => ranList.Add("Step3"), ServiceProvider); - runner.AddStep(step3); - tsc.Set(); - - FinishAdding(runner); - - await runnerTask; - - Assert.False(hasError); - - if (PreservesStepExecutionOrder) - Assert.Equal(["Step1", "Step2", "Step3"], ranList); - else - Assert.Equivalent(new HashSet(["Step1", "Step2", "Step3"]), ranList, true); - - Assert.Equivalent(new ReadOnlyCollection([step1, step2, step3]), runner.ExecutedSteps, true); - } - - [Fact] - public async Task RunAsync_Cancellation() - { - var runner = CreateStepRunner(true); - - var ranList = new List(); - var cts = new CancellationTokenSource(); - var step1 = new TestStep(_ => - { - Task.Delay(1000).Wait(); - ranList.Add("Step1"); - cts.Cancel(); - - }, ServiceProvider); - var step2 = new TestStep(_ => ranList.Add("Step2"), ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - var runnerTask = runner.RunAsync(cts.Token); - - await runnerTask; - - Assert.Equal(["Step1"], ranList); - Assert.Contains(step1, runner.ExecutedSteps); - Assert.DoesNotContain(step2, runner.ExecutedSteps); - } - - [Fact] - public async Task RunAsync_StopRunner_ShouldStopExecution() - { - var runner = CreateStepRunner(true); - - StepRunnerErrorEventArgs? args = null!; - runner.Error += (_, e) => - { - args = e; - }; - - var ranList = new List(); - var cts = new CancellationTokenSource(); - var step1 = new TestStep(_ => - { - Task.Delay(1000).Wait(); - ranList.Add("Step1"); - cts.Cancel(); - - }, ServiceProvider); - var step2 = new TestStep(_ => ranList.Add("Step2"), ServiceProvider); - - runner.AddStep(step1); - runner.AddStep(step2); - - FinishAdding(runner); - - var runnerTask = runner.RunAsync(cts.Token); - - await runnerTask; - - if (PreservesStepExecutionOrder) - { - Assert.NotNull(args); - Assert.True(args.Cancel); - - Assert.Equal(["Step1"], ranList); - - Assert.Contains(step1, runner.ExecutedSteps); - Assert.DoesNotContain(step2, runner.ExecutedSteps); - } - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestSuite.cs b/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestSuite.cs new file mode 100644 index 00000000..f23086f5 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Runners/StepRunnerTestSuite.cs @@ -0,0 +1,2241 @@ +using AnakinRaW.CommonUtilities.Testing; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; + +public abstract class StepRunnerTestSuite : TestBaseWithServiceProvider where T : AsyncStepRunner +{ + /// + /// Indicates whether the runner guarantees sequential step execution order. + /// + public virtual bool HasSequentialStepExecutionOrder => false; + + /// + /// Indicates whether the runner supports sequential execution mode. + /// + public virtual bool SupportsSequentialExecutionOrder => true; + + protected virtual bool SupportsAddingStepsAfterCancellation => true; + + protected abstract T CreateStepRunner(bool? sequential = null); + + protected abstract T CreateStepRunner(int workerCount); + + protected virtual void FinishAdding(T runner) + { + } + + #region Initial State Tests + + [Fact] + public void NewRunner_InitialState_ConcurrentRunner() + { + if (HasSequentialStepExecutionOrder) + return; + Assert.Throws("workerCount", () => CreateStepRunner(0)); + Assert.Throws("workerCount", () => CreateStepRunner(new Random().Next(int.MinValue, 0))); + Assert.Throws("workerCount", () => CreateStepRunner(new Random().Next(65, int.MaxValue))); + + var workerCount = new Random().Next(2, 65); + var runner = CreateStepRunner(workerCount); + Assert.Equal(workerCount, runner.WorkerCount); + Assert.False(runner.IsSequential); + } + + [Fact] + public void NewRunner_InitialState_Sequential() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(true); + Assert.Equal(1, runner.WorkerCount); + Assert.True(runner.IsSequential); + } + + [Fact] + public void NewRunner_InitialState_IsCorrect() + { + var runner = CreateStepRunner(); + + Assert.Empty(runner.ExecutedSteps); + Assert.Null(runner.Exception); + Assert.True(runner.WorkerCount >= 1); + } + + [Fact] + public void NewRunner_Sequential_WorkerCountIsOne() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + Assert.Equal(1, runner.WorkerCount); + } + + #endregion + + #region IsRunning Property Tests + + [Fact] + public void IsRunning_BeforeRun_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + runner.AddStep(step); + + Assert.False(runner.IsRunning); + } + + [Fact] + public async Task IsRunning_DuringExecution_ReturnsTrue() + { + var runner = CreateStepRunner(); + var isRunningDuringExecution = false; + var stepStarted = new ManualResetEventSlim(false); + var canComplete = new ManualResetEventSlim(false); + + var step = new TestStep(async _ => + { + await Task.Yield(); + stepStarted.Set(); + isRunningDuringExecution = runner.IsRunning; + canComplete.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + stepStarted.Wait(TestContext.Current.CancellationToken); + Assert.True(runner.IsRunning, "IsRunning should be true during execution"); + + canComplete.Set(); + await runTask; + + Assert.True(isRunningDuringExecution, "IsRunning should have been true inside step"); + } + + [Fact] + public async Task IsRunning_AfterSuccessfulCompletion_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.False(runner.IsRunning); + } + + [Fact] + public async Task IsRunning_AfterExecutionWithErrors_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => throw new InvalidOperationException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.False(runner.IsRunning); + } + + [Fact] + public async Task IsRunning_AfterCancellation_ReturnsFalse() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + var stepStarted = new ManualResetEventSlim(false); + var canComplete = new ManualResetEventSlim(false); + + var step = new TestStep(async _ => + { + await Task.Yield(); + stepStarted.Set(); + canComplete.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(cts.Token); + + stepStarted.Wait(TestContext.Current.CancellationToken); + cts.Cancel(); + canComplete.Set(); + + await runTask; + + Assert.False(runner.IsRunning); + } + + #endregion + + #region IsCancelled Property Tests + + [Fact] + public void IsCancelled_BeforeRun_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + runner.AddStep(step); + + Assert.False(runner.IsCancelled); + } + + [Fact] + public async Task IsCancelled_DuringNormalExecution_ReturnsFalse() + { + var runner = CreateStepRunner(); + var isCancelledDuringExecution = false; + var stepStarted = new ManualResetEventSlim(false); + var canComplete = new ManualResetEventSlim(false); + + var step = new TestStep(async _ => + { + await Task.Yield(); + stepStarted.Set(); + isCancelledDuringExecution = runner.IsCancelled; + canComplete.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + stepStarted.Wait(TestContext.Current.CancellationToken); + Assert.False(runner.IsCancelled, "IsCancelled should be false during normal execution"); + + canComplete.Set(); + await runTask; + + Assert.False(isCancelledDuringExecution, "IsCancelled should have been false inside step"); + } + + [Fact] + public async Task IsCancelled_AfterNormalCompletion_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.False(runner.IsCancelled); + } + + [Fact] + public async Task IsCancelled_AfterCancellation_ReturnsTrue() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + var stepStarted = new ManualResetEventSlim(false); + var canComplete = new ManualResetEventSlim(false); + + var step = new TestStep(async ct => + { + await Task.Yield(); + stepStarted.Set(); + canComplete.Wait(TestContext.Current.CancellationToken); + ct.ThrowIfCancellationRequested(); + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(cts.Token); + + stepStarted.Wait(TestContext.Current.CancellationToken); + cts.Cancel(); + canComplete.Set(); + + await runTask; + + Assert.True(runner.IsCancelled); + } + + [Fact] + public async Task IsCancelled_AfterExecutionWithErrors_ReturnsFalse() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => throw new InvalidOperationException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.False(runner.IsCancelled, "IsCancelled should be false when errors occur without cancellation"); + Assert.NotNull(runner.Exception); + } + + [Fact] + public async Task IsCancelled_AlreadyCancelledToken_ReturnsTrue() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(cts.Token); + + Assert.True(runner.IsCancelled); + } + + [Fact] + public async Task IsCancelled_StopRunnerExceptionWithCancel_ReturnsTrue() + { + var runner = CreateStepRunner(); + + var step = new TestStep(_ => throw new StopRunnerException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(runner.IsCancelled, "IsCancelled should be true when StopRunnerException causes cancellation"); + } + + [Fact] + public async Task IsCancelled_OnErrorSetsCancellation_ReturnsTrue() + { + var runner = CreateStepRunner(); + + runner.Error += (_, args) => + { + args.Cancel = true; + }; + + var step = new TestStep(_ => throw new Exception(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(runner.IsCancelled, "IsCancelled should be true when StopRunnerException causes cancellation"); + } + + #endregion + + #region AddStep Tests + + [Fact] + public void AddStep_Null_ThrowsArgumentNullException() + { + var runner = CreateStepRunner(); + Assert.Throws(() => runner.AddStep(null!)); + } + + [Fact] + public void AddStep_ValidStep_DoesNotThrow() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + var exception = Record.Exception(() => runner.AddStep(step)); + + Assert.Null(exception); + } + + [Fact] + public void AddStep_MultipleSteps_AllAccepted() + { + var runner = CreateStepRunner(); + + for (var i = 0; i < 10; i++) + { + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + runner.AddStep(step); + } + + // Should not throw + } + + [Fact] + public async Task AddStep_DuringExecution_StepGetsExecuted() + { + var runner = CreateStepRunner(); + var executedSteps = new ConcurrentBag(); + var gate = new ManualResetEventSlim(false); + + var step1 = new TestStep(async _ => + { + executedSteps.Add("Step1"); + await Task.Yield(); + gate.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider); + + runner.AddStep(step1); + + var runTask = runner.RunAsync(CancellationToken.None); + + // Add step while runner is executing + var step2 = new TestStep(_ => + { + executedSteps.Add("Step2"); + return Task.CompletedTask; + }, ServiceProvider); + runner.AddStep(step2); + + gate.Set(); + FinishAdding(runner); + + await step2; + await runTask; + + Assert.Contains("Step1", executedSteps); + Assert.Contains("Step2", executedSteps); + } + + #endregion + + #region RunAsync - Basic Execution Tests + + [Fact] + public async Task RunAsync_NoSteps_CompletesSuccessfully() + { + var runner = CreateStepRunner(); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Empty(runner.ExecutedSteps); + Assert.Null(runner.Exception); + } + + [Fact] + public async Task RunAsync_SingleStep_ExecutesStep() + { + var runner = CreateStepRunner(); + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(executed); + Assert.Contains(step, runner.ExecutedSteps); + await Assert.Single(runner.ExecutedSteps); + } + + [Fact] + public async Task RunAsync_MultipleSteps_ExecutesAllSteps() + { + var runner = CreateStepRunner(); + var executedSteps = new ConcurrentBag(); + var executionOrder = new List(); + var lockObj = new object(); + + var step1 = new TestStep(_ => + { + lock (lockObj) { executionOrder.Add("Step1"); } + executedSteps.Add("Step1"); + return Task.CompletedTask; + }, ServiceProvider); + + var step2 = new TestStep(_ => + { + lock (lockObj) { executionOrder.Add("Step2"); } + executedSteps.Add("Step2"); + return Task.CompletedTask; + }, ServiceProvider); + + var step3 = new TestStep(_ => + { + lock (lockObj) { executionOrder.Add("Step3"); } + executedSteps.Add("Step3"); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + runner.AddStep(step3); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(3, executedSteps.Count); + Assert.Contains("Step1", executedSteps); + Assert.Contains("Step2", executedSteps); + Assert.Contains("Step3", executedSteps); + Assert.Equal(3, runner.ExecutedSteps.Count); + + if (HasSequentialStepExecutionOrder) + { + Assert.Equal(new[] { "Step1", "Step2", "Step3" }, executionOrder); + } + } + + [Fact] + public async Task RunAsync_AsyncStep_WaitsForCompletion() + { + var runner = CreateStepRunner(); + var completed = false; + + var step = new TestStep(async _ => + { + await Task.Delay(100, TestContext.Current.CancellationToken); + completed = true; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(completed); + } + + [Fact] + public async Task RunAsync_MixedSyncAndAsyncSteps_ExecutesAll() + { + var runner = CreateStepRunner(); + var executed = new ConcurrentBag(); + + runner.AddStep(new TestStep(_ => { executed.Add(1); return Task.CompletedTask; }, ServiceProvider)); + runner.AddStep(new TestStep(async _ => { await Task.Delay(10, TestContext.Current.CancellationToken); executed.Add(2); }, ServiceProvider)); + runner.AddStep(new TestStep(_ => { executed.Add(3); return Task.CompletedTask; }, ServiceProvider)); + runner.AddStep(new TestStep(async _ => { await Task.Yield(); executed.Add(4); }, ServiceProvider)); + + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(4, executed.Count); + } + + [Fact] + public async Task RunAsync_SequentialRunner_ExecutesInAddOrder() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + var executionOrder = new List(); + + for (var i = 0; i < 5; i++) + { + var index = i; + var step = new TestStep(async _ => + { + await Task.Yield(); + executionOrder.Add(index); + }, ServiceProvider); + runner.AddStep(step); + } + + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, executionOrder); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RunAsync_StepsAreExecutedOnThreadPool_DoesNotDeadlock(bool sequential) + { + if (sequential && !SupportsSequentialExecutionOrder) + return; + if (!sequential && HasSequentialStepExecutionOrder) + return; + + var waitSource = new TaskCompletionSource(); + + var runner = CreateStepRunner(sequential); + var canComplete = new ManualResetEventSlim(false); + + var step1 = new TestStep(_ => + { + canComplete.Wait(); + waitSource.SetResult(true); + return Task.CompletedTask; + }, ServiceProvider); + var step2 = new TestStep(async _ => + { + await waitSource.Task; + }, ServiceProvider); + + + runner.AddStep(step1); + runner.AddStep(step2); + + FinishAdding(runner); + + var task = runner.RunAsync(CancellationToken.None); + + canComplete.Set(); + await waitSource.Task; + + var completedTask = + await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Same(task, completedTask); + + Assert.True(task is {IsCompleted: true, Status: TaskStatus.RanToCompletion}); + } + + #endregion + + #region RunAsync - Cancellation Tests + + [Fact] + public async Task RunAsync_AlreadyCancelledToken_DoesNotExecuteAnyStep() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var executed = false; + var step = new TestStep(_ => + { + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(cts.Token); + + Assert.False(executed); + Assert.Empty(runner.ExecutedSteps); + } + + [Fact] + public async Task RunAsync_CancellationToken_IsCancellableAndLinkedToRunner() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + var tokenWasCancellable = false; + var tokenWasCancelledAfterRequest = false; + + var step = new TestStep(ct => + { + tokenWasCancellable = ct.CanBeCanceled; + cts.Cancel(); + tokenWasCancelledAfterRequest = ct.IsCancellationRequested; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(cts.Token); + + Assert.True(tokenWasCancellable, "Token passed to step should be cancellable"); + Assert.True(tokenWasCancelledAfterRequest, "Token should reflect cancellation request"); + } + + [Fact] + public async Task RunAsync_StepAddedAfterCancellation_IsNotExecuted() + { + if (!SupportsAddingStepsAfterCancellation) + return; + + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + var step1Executed = false; + var step2Executed = false; + var barrier = new ManualResetEventSlim(false); + var step1Started = new ManualResetEventSlim(false); + + var step1 = new TestStep(async _ => + { + await Task.Yield(); + step1Started.Set(); + step1Executed = true; + barrier.Wait(TestContext.Current.CancellationToken); + }, ServiceProvider); + + runner.AddStep(step1); + + var runTask = runner.RunAsync(cts.Token); + + step1Started.Wait(TestContext.Current.CancellationToken); + + cts.Cancel(); + + var step2 = new TestStep(_ => + { + step2Executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step2); + + barrier.Set(); + FinishAdding(runner); + + await runTask; + + Assert.True(step1Executed); + Assert.False(step2Executed, "Step added after cancellation should not execute"); + Assert.Contains(step1, runner.ExecutedSteps); + Assert.DoesNotContain(step2, runner.ExecutedSteps); + } + + [Fact] + public async Task RunAsync_SequentialRunner_CancellationStopsQueuedSteps() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + var cts = new CancellationTokenSource(); + var stepsExecuted = new List(); + + var step1 = new TestStep(_ => + { + stepsExecuted.Add("Step1"); + cts.Cancel(); + return Task.CompletedTask; + }, ServiceProvider); + + var step2 = new TestStep(_ => + { + stepsExecuted.Add("Step2"); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(cts.Token); + + Assert.Single(stepsExecuted); + Assert.Equal("Step1", stepsExecuted[0]); + } + + [Fact] + public async Task RunAsync_StepThrowsOperationCanceledException_TreatedAsError() + { + var runner = CreateStepRunner(); + var errorRaised = false; + runner.Error += (_, _) => errorRaised = true; + + var step = new TestStep(_ => throw new OperationCanceledException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(errorRaised); + Assert.NotNull(runner.Exception); + } + + [Fact] + public async Task RunAsync_StepThrowsTaskCanceledException_TreatedAsError() + { + var runner = CreateStepRunner(); + var errorRaised = false; + runner.Error += (_, _) => errorRaised = true; + + var step = new TestStep(_ => throw new TaskCanceledException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(errorRaised); + Assert.NotNull(runner.Exception); + } + + #endregion + + #region RunAsync - Error Handling Tests + + [Fact] + public async Task RunAsync_StepThrows_OtherStepsStillExecute() + { + var runner = CreateStepRunner(); + var executedSteps = new ConcurrentBag(); + var failingStepStarted = new ManualResetEventSlim(false); + var failingStepCanThrow = new ManualResetEventSlim(false); + + var failingStep = new TestStep(async _ => + { + await Task.Yield(); + executedSteps.Add("FailingStep"); + failingStepStarted.Set(); + failingStepCanThrow.Wait(TestContext.Current.CancellationToken); + throw new InvalidOperationException("Test error"); + }, ServiceProvider); + + var successStep = new TestStep(_ => + { + executedSteps.Add("SuccessStep"); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(failingStep); + runner.AddStep(successStep); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + failingStepStarted.Wait(TestContext.Current.CancellationToken); + failingStepCanThrow.Set(); + + await runTask; + + Assert.Contains("FailingStep", executedSteps); + Assert.Contains("SuccessStep", executedSteps); + Assert.Equal(2, runner.ExecutedSteps.Count); + Assert.NotNull(runner.Exception); + } + + [Fact] + public async Task RunAsync_StepThrows_SetsStepErrorProperty() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Test error"); + + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Same(expectedException, step.Error); + } + + [Fact] + public async Task RunAsync_MultipleStepsThrow_AllErrorsRecorded() + { + var runner = CreateStepRunner(); + var exception1 = new InvalidOperationException("Error 1"); + var exception2 = new ArgumentException("Error 2"); + + var step1 = new TestStep(_ => throw exception1, ServiceProvider); + var step2 = new TestStep(_ => throw exception2, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(runner.Exception); + Assert.Equal(2, runner.Exception.InnerExceptions.Count); + Assert.Contains(exception1, runner.Exception.InnerExceptions); + Assert.Contains(exception2, runner.Exception.InnerExceptions); + } + + [Fact] + public async Task RunAsync_ReentryNotAllowed_ThrowsInvalidOperationException() + { + var runner = CreateStepRunner(); + var mre = new ManualResetEventSlim(false); + var isRunning = new TaskCompletionSource(); + + var executed = false; + var step = new TestStep(_ => + { + isRunning.SetResult(true); + mre.Wait(); + executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + + Assert.False(runner.IsRunning); + + var runTask = runner.RunAsync(CancellationToken.None); + + await isRunning.Task; + Assert.True(runner.IsRunning); + + await Assert.ThrowsAsync(async () => await runner.RunAsync(CancellationToken.None)); + mre.Set(); + + await runTask; + + Assert.True(executed); + Assert.Contains(step, runner.ExecutedSteps); + await Assert.Single(runner.ExecutedSteps); + } + + #endregion + + #region StopRunnerException Tests + + [Fact] + public async Task RunAsync_StopRunnerException_StepAddedAfterException_IsNotExecuted() + { + var runner = CreateStepRunner(); + var step1Executed = false; + var step2Executed = false; + var errorOccurred = new ManualResetEventSlim(false); + + runner.Error += (_, args) => + { + args.Cancel = true; + + if (args.Exception is StopRunnerException) + { + var step2 = new TestStep(_ => + { + step2Executed = true; + return Task.CompletedTask; + }, ServiceProvider); + runner.AddStep(step2); + + errorOccurred.Set(); + } + }; + + var step1 = new TestStep(async _ => + { + await Task.Yield(); + step1Executed = true; + throw new StopRunnerException(); + }, ServiceProvider); + + runner.AddStep(step1); + + var runTask = runner.RunAsync(CancellationToken.None); + + errorOccurred.Wait(TestContext.Current.CancellationToken); + + FinishAdding(runner); + + await runTask; + + Assert.True(step1Executed); + Assert.False(step2Executed, "Step added before StopRunnerException should not execute"); + Assert.NotNull(runner.Exception); + Assert.Contains(runner.Exception.InnerExceptions, e => e is StopRunnerException); + } + + [Fact] + public async Task RunAsync_StopRunnerException_SequentialRunner_StopsQueuedSteps() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + var stepsExecuted = new List(); + + var step1 = new TestStep(_ => + { + stepsExecuted.Add("Step1"); + throw new StopRunnerException(); + }, ServiceProvider); + + var step2 = new TestStep(_ => + { + stepsExecuted.Add("Step2"); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Single(stepsExecuted); + Assert.Equal("Step1", stepsExecuted[0]); + } + + [Fact] + public async Task RunAsync_StepThrowsStopRunnerException_SetsCancelToTrue() + { + var runner = CreateStepRunner(); + StepRunnerErrorEventArgs? errorArgs = null; + + runner.Error += (_, args) => + { + errorArgs = args; + }; + + var step = new TestStep(_ => throw new StopRunnerException(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(errorArgs); + Assert.IsType(errorArgs.Exception); + Assert.True(errorArgs.Cancel, "Cancel should be automatically set to true for StopRunnerException"); + } + + [Fact] + public async Task RunAsync_StepThrowsStopRunnerException_ExceptionIsRecorded() + { + var runner = CreateStepRunner(); + var expectedException = new StopRunnerException(); + + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(runner.Exception); + Assert.Contains(expectedException, runner.Exception.InnerExceptions); + } + + #endregion + + #region Exception Property Tests + + [Fact] + public void Exception_BeforeRun_ReturnsNull() + { + var runner = CreateStepRunner(); + runner.AddStep(new TestStep(_ => throw new Exception(), ServiceProvider)); + + Assert.Null(runner.Exception); + } + + [Fact] + public async Task Exception_NoErrors_ReturnsNull() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Null(runner.Exception); + } + + [Fact] + public async Task Exception_SingleError_ReturnsAggregateExceptionWithSingleInner() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Test error"); + + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(runner.Exception); + Assert.Single(runner.Exception.InnerExceptions); + Assert.Same(expectedException, runner.Exception.InnerExceptions[0]); + } + + [Fact] + public async Task Exception_MultipleErrors_ReturnsAggregateExceptionWithAllErrors() + { + var runner = CreateStepRunner(); + var exception1 = new InvalidOperationException("Error 1"); + var exception2 = new ArgumentException("Error 2"); + var exception3 = new FormatException("Error 3"); + + runner.AddStep(new TestStep(_ => throw exception1, ServiceProvider)); + runner.AddStep(new TestStep(_ => throw exception2, ServiceProvider)); + runner.AddStep(new TestStep(_ => throw exception3, ServiceProvider)); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(runner.Exception); + Assert.Equal(3, runner.Exception.InnerExceptions.Count); + Assert.Contains(exception1, runner.Exception.InnerExceptions); + Assert.Contains(exception2, runner.Exception.InnerExceptions); + Assert.Contains(exception3, runner.Exception.InnerExceptions); + } + + #endregion + + #region Error Event Tests + + [Fact] + public async Task Error_StepThrows_EventIsRaisedWithCorrectArgs() + { + var runner = CreateStepRunner(); + StepRunnerErrorEventArgs? errorArgs = null; + object? sender = null; + + runner.Error += (s, args) => + { + sender = s; + errorArgs = args; + }; + + var expectedException = new InvalidOperationException("Test error"); + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(errorArgs); + Assert.Same(runner, sender); + Assert.Same(step, errorArgs.Step); + Assert.Same(expectedException, errorArgs.Exception); + Assert.False(errorArgs.Cancel); + } + + [Fact] + public async Task Error_MultipleStepsThrow_EventIsRaisedForEachError() + { + var runner = CreateStepRunner(); + var errorCount = 0; + var raisedSteps = new ConcurrentBag(); + + runner.Error += (_, args) => + { + Interlocked.Increment(ref errorCount); + raisedSteps.Add(args.Step!); + }; + + var step1 = new TestStep(_ => throw new Exception("Error 1"), ServiceProvider); + var step2 = new TestStep(_ => throw new Exception("Error 2"), ServiceProvider); + var step3 = new TestStep(_ => throw new Exception("Error 3"), ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + runner.AddStep(step3); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(3, errorCount); + Assert.Contains(step1, raisedSteps); + Assert.Contains(step2, raisedSteps); + Assert.Contains(step3, raisedSteps); + } + + [Fact] + public async Task Error_SetCancelToTrue_StepAddedAfterError_IsNotExecuted() + { + var runner = CreateStepRunner(); + var step1Executed = false; + var step2Executed = false; + var errorOccurred = new ManualResetEventSlim(false); + + runner.Error += (_, args) => + { + args.Cancel = true; + errorOccurred.Set(); + }; + + var step1 = new TestStep(async _ => + { + await Task.Yield(); + step1Executed = true; + throw new InvalidOperationException("Test error"); + }, ServiceProvider); + + runner.AddStep(step1); + + var runTask = runner.RunAsync(CancellationToken.None); + + errorOccurred.Wait(TestContext.Current.CancellationToken); + + // Give the runner time to process the error and cancel + await Task.Delay(200, TestContext.Current.CancellationToken); + + var step2 = new TestStep(_ => + { + step2Executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + if (SupportsAddingStepsAfterCancellation) + runner.AddStep(step2); + else + Assert.Throws(() => runner.AddStep(step2)); + + FinishAdding(runner); + + await runTask; + + if (SupportsAddingStepsAfterCancellation) + { + Assert.True(step1Executed); + Assert.False(step2Executed, "Step added AFTER error with Cancel=true should not execute"); + } + Assert.NotNull(runner.Exception); + } + + [Fact] + public async Task Error_SetCancelToTrue_SequentialRunner_StopsQueuedSteps() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + var stepsExecuted = new List(); + + runner.Error += (_, args) => + { + args.Cancel = true; + }; + + var step1 = new TestStep(_ => + { + stepsExecuted.Add("Step1"); + throw new InvalidOperationException("Test error"); + }, ServiceProvider); + + var step2 = new TestStep(_ => + { + stepsExecuted.Add("Step2"); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Single(stepsExecuted); + Assert.Equal("Step1", stepsExecuted[0]); + Assert.NotNull(runner.Exception); + } + + [Fact] + public async Task Error_SuccessfulStep_EventIsNotRaised() + { + var runner = CreateStepRunner(); + var errorRaised = false; + runner.Error += (_, _) => errorRaised = true; + + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.False(errorRaised); + } + + [Fact] + public async Task Error_NoSubscribers_DoesNotThrow() + { + var runner = CreateStepRunner(); + + var step = new TestStep(_ => throw new Exception("Test"), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var exception = await Record.ExceptionAsync(() => runner.RunAsync(CancellationToken.None)); + + Assert.Null(exception); + } + + #endregion + + #region Wait() Tests + + [Fact] + public async Task Wait_AfterSuccessfulRun_CompletesWithoutException() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + var exception = Record.Exception(() => runner.Wait()); + + Assert.Null(exception); + } + + [Fact] + public async Task Wait_AfterFailedRun_ThrowsAggregateException() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Test error"); + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + var exception = Assert.Throws(() => runner.Wait()); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public async Task Wait_DuringActiveRun_BlocksUntilCompletion() + { + var runner = CreateStepRunner(); + var stepCompleted = false; + var tcs = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + await tcs.Task; + stepCompleted = true; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + _ = runner.RunAsync(CancellationToken.None); + + var waitTask = Task.Run(() => runner.Wait(), TestContext.Current.CancellationToken); + + // Give Wait time to start blocking + Thread.Sleep(100); + Assert.False(waitTask.IsCompleted); + + // Complete the step + tcs.SetResult(null); + + // Wait should complete now + var completed = await WaitForTaskWithTimeout(waitTask, TimeSpan.FromSeconds(5)); + Assert.True(completed); + Assert.True(stepCompleted); + } + + [Fact] + public async Task Wait_MultipleCallsAfterRun_AllSucceed() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + runner.Wait(); + runner.Wait(); + runner.Wait(); + } + + #endregion + + #region Wait(TimeSpan) Tests + + [Fact] + public async Task WaitWithTimeout_CompletesInTime_DoesNotThrow() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + var exception = Record.Exception(() => runner.Wait(TimeSpan.FromSeconds(5))); + + Assert.Null(exception); + } + + [Fact] + public void WaitWithTimeout_TimeoutExpires_ThrowsTimeoutException() + { + var runner = CreateStepRunner(); + var tcs = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + await tcs.Task; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + _ = runner.RunAsync(CancellationToken.None); + + Assert.Throws(() => runner.Wait(TimeSpan.FromMilliseconds(100))); + + tcs.SetResult(null); + } + + [Fact] + public async Task WaitWithTimeout_StepFails_ThrowsAggregateException() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Test error"); + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + var exception = Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(5))); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public void WaitWithTimeout_ZeroTimeout_ThrowsTimeoutExceptionIfNotComplete() + { + var runner = CreateStepRunner(); + var tcs = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + await tcs.Task; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + _ = runner.RunAsync(CancellationToken.None); + + Assert.Throws(() => runner.Wait(TimeSpan.Zero)); + + tcs.SetResult(null); + } + + [Fact] + public void WaitWithTimeout_NegativeTimeout_ThrowsArgumentOutOfRangeException() + { + var runner = CreateStepRunner(); + FinishAdding(runner); + + _ = runner.RunAsync(CancellationToken.None); + + Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void WaitWithTimeout_RunnerNeverRuns_ThrowsTimeoutException() + { + var runner = CreateStepRunner(); + FinishAdding(runner); + Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(1))); + } + + #endregion + + #region GetAwaiter / ConfigureAwait + + [Fact] + public async Task GetAwaiter_BeforeRun_WaitsUntilRunnerStartedAndCompleted() + { + var runner = CreateStepRunner(); + var stepExecuted = false; + + var step = new TestStep(_ => + { + stepExecuted = true; + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var awaitTask = Task.Run(async () => await runner, TestContext.Current.CancellationToken); + var awaitTaskConfigureAwaitT = Task.Run(async () => await runner.ConfigureAwait(true), TestContext.Current.CancellationToken); + var awaitTaskConfigureAwaitF = Task.Run(async () => await runner.ConfigureAwait(false), TestContext.Current.CancellationToken); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaitTask.IsCompleted, "Awaiter should block until runner starts and completes"); + + await runner.RunAsync(CancellationToken.None); + + await awaitTask; + await awaitTaskConfigureAwaitT; + await awaitTaskConfigureAwaitF; + + Assert.True(awaitTask.IsCompleted); + Assert.True(awaitTaskConfigureAwaitT.IsCompleted); + Assert.True(awaitTaskConfigureAwaitF.IsCompleted); + Assert.True(stepExecuted); + } + + [Fact] + public async Task GetAwaiter_DuringRun_WaitsForCompletion() + { + var runner = CreateStepRunner(); + var stepCompleted = false; + var tcs = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + await tcs.Task; + stepCompleted = true; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + var awaitTask = Task.Run(async () => await runner, TestContext.Current.CancellationToken); + var awaitTaskConfigureAwaitT = Task.Run(async () => await runner.ConfigureAwait(true), TestContext.Current.CancellationToken); + var awaitTaskConfigureAwaitF = Task.Run(async () => await runner.ConfigureAwait(false), TestContext.Current.CancellationToken); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaitTask.IsCompleted, "Awaiter should block while runner is executing"); + Assert.False(awaitTaskConfigureAwaitT.IsCompleted, "Awaiter should block while runner is executing"); + Assert.False(awaitTaskConfigureAwaitF.IsCompleted, "Awaiter should block while runner is executing"); + Assert.False(stepCompleted); + + tcs.SetResult(true); + + await awaitTask; + await awaitTaskConfigureAwaitT; + await awaitTaskConfigureAwaitF; + + Assert.True(awaitTask.IsCompleted); + Assert.True(awaitTaskConfigureAwaitT.IsCompleted); + Assert.True(awaitTaskConfigureAwaitF.IsCompleted); + + Assert.True(stepCompleted); + + await runTask; + } + + [Fact] + public async Task GetAwaiter_AfterRun_CompletesImmediately() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + var awaiter = runner.GetAwaiter(); + var awaitConfigureAwaitT = runner.ConfigureAwait(true); + var awaitConfigureAwaitF = runner.ConfigureAwait(false); + + Assert.True(awaiter.IsCompleted); + Assert.True(awaitConfigureAwaitT.GetAwaiter().IsCompleted); + Assert.True(awaitConfigureAwaitF.GetAwaiter().IsCompleted); + + await runner; + await runner.ConfigureAwait(false); + await runner.ConfigureAwait(true); + } + + [Fact] + public async Task GetAwaiter_MultipleAwaitersBeforeRun_AllCompleteWhenRunFinishes() + { + var runner = CreateStepRunner(); + var step = new TestStep(async _ => await Task.Delay(50, TestContext.Current.CancellationToken), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var awaitTask1 = Task.Run(async () => await runner, TestContext.Current.CancellationToken); + var awaitTask2 = Task.Run(async () => await runner.ConfigureAwait(true), TestContext.Current.CancellationToken); + var awaitTask3 = Task.Run(async () => await runner.ConfigureAwait(false), TestContext.Current.CancellationToken); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaitTask1.IsCompleted); + Assert.False(awaitTask2.IsCompleted); + Assert.False(awaitTask3.IsCompleted); + + await runner.RunAsync(CancellationToken.None); + + await awaitTask1; + await awaitTask2; + await awaitTask3; + + Assert.True(awaitTask1.IsCompleted); + Assert.True(awaitTask2.IsCompleted); + Assert.True(awaitTask3.IsCompleted); + } + + [Fact] + public async Task GetAwaiter_WithStepErrors_CompletesWithoutThrowing() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => throw new InvalidOperationException("Test error"), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + var eList = new List + { + await Record.ExceptionAsync(async () => await runner), + await Record.ExceptionAsync(async () => await runner.ConfigureAwait(false)), + await Record.ExceptionAsync(async () => await runner.ConfigureAwait(true)) + }; + + Assert.All(eList, Assert.Null); + Assert.NotNull(runner.Exception); + + await runTask; + } + + [Fact] + public async Task GetAwaiter_CalledMultipleTimesBeforeAndAfterRun_AllSucceed() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + Assert.False(runner.GetAwaiter().IsCompleted); + Assert.False(runner.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.False(runner.ConfigureAwait(true).GetAwaiter().IsCompleted); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(runner.GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(true).GetAwaiter().IsCompleted); +#pragma warning disable xUnit1031 + runner.GetAwaiter().GetResult(); + runner.ConfigureAwait(true).GetAwaiter().GetResult(); + runner.ConfigureAwait(false).GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + } + + [Fact] + public async Task GetAwaiter_MultipleAwaiters_AllCompleteWhenCancelled() + { + var runner = CreateStepRunner(); + var cts = new CancellationTokenSource(); + var stepStarted = new ManualResetEventSlim(false); + var canCancel = new ManualResetEventSlim(false); + + var step1 = new TestStep(async ct => + { + await Task.Yield(); + stepStarted.Set(); + canCancel.Wait(TestContext.Current.CancellationToken); + ct.ThrowIfCancellationRequested(); + }, ServiceProvider); + + runner.AddStep(step1); + FinishAdding(runner); + + // Start multiple awaiters before cancellation + var awaiterTasks = new[] + { + Task.Run(async () => await runner, TestContext.Current.CancellationToken), + Task.Run(async () => await runner, TestContext.Current.CancellationToken), + Task.Run(async () => await runner, TestContext.Current.CancellationToken), + }; + + var runTask = runner.RunAsync(cts.Token); + + stepStarted.Wait(TestContext.Current.CancellationToken); + + // Cancel + cts.Cancel(); + canCancel.Set(); + + var allAwaitersTask = Task.WhenAll(awaiterTasks); + var exception = await Record.ExceptionAsync(() => allAwaitersTask); + + Assert.Null(exception); + } + + [Fact] + public void AwaiterAndAwaitableEquality() + { + ConfigureAwaitTestExtensions.AwaiterAndAwaitableEquality( + () => + { + var stepRunner = CreateStepRunner(); + return stepRunner; + }, + step => step.GetAwaiter(), + (step, ca) => step.ConfigureAwait(ca)); + + + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + [InlineData(null)] + public void OnCompleted_CompletesInAnotherSynchronizationContext(bool? continueOnCapturedContext) + { + var executeCount = 0; + var step = new TestStep((_ => + { + Interlocked.Increment(ref executeCount); + return Task.CompletedTask; + }), ServiceProvider); + + ConfigureAwaitTestExtensions.TestOnCompletedCompletesInAnotherSynchronizationContext( + continueOnCapturedContext, + () => { + var stepRunner = CreateStepRunner(); + stepRunner.AddStep(step); + stepRunner.AddStep(step); + stepRunner.AddStep(step); + FinishAdding(stepRunner); + return stepRunner; + }, + runner => runner.GetAwaiter(), + (runner, ca) => runner.ConfigureAwait(ca), + runner => runner.RunAsync(CancellationToken.None)); + + Assert.Equal(3, executeCount); + } + + [Fact] + public async Task RunAsync_GetAwaiter_ConfigureAwait_ShareSameTask() + { + var runner = CreateStepRunner(); + var stepStarted = new TaskCompletionSource(); + var canComplete = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + stepStarted.SetResult(1); + await canComplete.Task; + }, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + // Get task from RunAsync + var runTask = runner.RunAsync(CancellationToken.None); + + await stepStarted.Task; + + // All should report same IsCompleted state while running + Assert.False(runTask.IsCompleted); + Assert.False(runner.GetAwaiter().IsCompleted); + Assert.False(runner.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.False(runner.ConfigureAwait(true).GetAwaiter().IsCompleted); + + canComplete.SetResult(1); + await runTask; + + // All should now be completed + Assert.True(runTask.IsCompleted); + Assert.True(runner.GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(true).GetAwaiter().IsCompleted); + } + + [Fact] + public async Task RunAsync_GetAwaiter_ConfigureAwait_PropagateExceptionConsistently() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Test"); + + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + + // All should complete without throwing (exceptions are collected in runner.Exception) + await runTask; + await runner; + await runner.ConfigureAwait(false); + await runner.ConfigureAwait(true); + + // All report same completed state + Assert.True(runTask.IsCompleted); + Assert.True(runner.GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.True(runner.ConfigureAwait(true).GetAwaiter().IsCompleted); + + // Exception is accessible via runner.Exception + Assert.NotNull(runner.Exception); + Assert.Contains(expectedException, runner.Exception.InnerExceptions); + } + + #endregion + + #region ExecutedSteps Tests + + [Fact] + public void ExecutedSteps_BeforeRun_IsEmpty() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + + Assert.Empty(runner.ExecutedSteps); + } + + [Fact] + public async Task ExecutedSteps_AfterSuccessfulRun_ContainsAllSteps() + { + var runner = CreateStepRunner(); + var step1 = new TestStep(_ => Task.CompletedTask, ServiceProvider); + var step2 = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(2, runner.ExecutedSteps.Count); + Assert.Contains(step1, runner.ExecutedSteps); + Assert.Contains(step2, runner.ExecutedSteps); + } + + [Fact] + public async Task ExecutedSteps_AfterFailedStep_ContainsFailedStep() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => throw new Exception(), ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Assert.Single(runner.ExecutedSteps); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Assert.Contains(step, runner.ExecutedSteps); + } + + [Fact] + public async Task ExecutedSteps_CancelledRun_ContainsOnlyExecutedSteps() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + var cts = new CancellationTokenSource(); + + var step1 = new TestStep(_ => + { + cts.Cancel(); + return Task.CompletedTask; + }, ServiceProvider); + var step2 = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(cts.Token); + + Assert.Contains(step1, runner.ExecutedSteps); + Assert.DoesNotContain(step2, runner.ExecutedSteps); + } + + [Fact] + public async Task ExecutedSteps_IsReadOnlyCollection() + { + var runner = CreateStepRunner(); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + runner.AddStep(step); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.IsAssignableFrom>(runner.ExecutedSteps); + } + + #endregion + + #region WorkerCount Tests + + [Fact] + public void WorkerCount_IsAtLeastOne() + { + var runner = CreateStepRunner(); + Assert.True(runner.WorkerCount >= 1); + } + + [Fact] + public void WorkerCount_SequentialRunner_IsOne() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + Assert.Equal(1, runner.WorkerCount); + } + + [Fact] + public void WorkerCount_ParallelRunner_IsGreaterThanOne() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + Assert.True(runner.WorkerCount > 1); + } + + #endregion + + #region Parallel Execution Tests + + [Fact] + public async Task RunAsync_ParallelRunner_AllowsConcurrentExecution() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + var workerCount = runner.WorkerCount; + + var barrier = new CountdownEvent(workerCount); + var allReachedBarrier = false; + var stepsCompleted = 0; + + for (var i = 0; i < workerCount; i++) + { + var step = new TestStep(async _ => + { + await Task.Yield(); + barrier.Signal(); + allReachedBarrier = barrier.Wait(TimeSpan.FromSeconds(5)); + Interlocked.Increment(ref stepsCompleted); + }, ServiceProvider); + runner.AddStep(step); + } + + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.True(allReachedBarrier, + $"All {workerCount} steps should run concurrently, but barrier was not reached"); + Assert.Equal(workerCount, stepsCompleted); + } + + [Fact] + public async Task RunAsync_ParallelRunner_RespectsWorkerCount() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + var workerCount = runner.WorkerCount; + var concurrentCount = 0; + var maxConcurrent = 0; + var lockObj = new object(); + + for (var i = 0; i < workerCount * 3; i++) + { + var step = new TestStep(async _ => + { + lock (lockObj) + { + concurrentCount++; + maxConcurrent = Math.Max(maxConcurrent, concurrentCount); + } + + await Task.Delay(200, TestContext.Current.CancellationToken); + + lock (lockObj) + { + concurrentCount--; + } + }, ServiceProvider); + runner.AddStep(step); + } + + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.True(maxConcurrent <= workerCount, + $"Max concurrent ({maxConcurrent}) exceeded worker count ({workerCount})"); + } + + #endregion + + #region Thread Safety Tests + + [Fact] + public async Task AddStep_ConcurrentAdds_AllStepsAdded() + { + var runner = CreateStepRunner(); + var executedCount = 0; + var tasks = new List(); + + for (var i = 0; i < 100; i++) + { + tasks.Add(Task.Run(() => + { + var step = new TestStep(_ => + { + Interlocked.Increment(ref executedCount); + return Task.CompletedTask; + }, ServiceProvider); + runner.AddStep(step); + }, TestContext.Current.CancellationToken)); + } + + await Task.WhenAll(tasks); + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(100, executedCount); + } + + [Fact] + public async Task Error_ConcurrentErrors_AllErrorsRecorded() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + var errorCount = 0; + + runner.Error += (_, _) => + { + Interlocked.Increment(ref errorCount); + }; + + for (var i = 0; i < 50; i++) + { + var i1 = i; + runner.AddStep(new TestStep(_ => throw new Exception($"Error {i1}"), ServiceProvider)); + } + + FinishAdding(runner); + await runner.RunAsync(CancellationToken.None); + + Assert.Equal(50, errorCount); + Assert.NotNull(runner.Exception); + Assert.Equal(50, runner.Exception.InnerExceptions.Count); + } + + #endregion + + #region Step Dependency / Awaiting Tests + + [Fact] + public async Task RunAsync_StepAwaitsCompletedStep_CompletesImmediately() + { + var runner = CreateStepRunner(); + var step1Completed = false; + var step2AwaitedSuccessfully = false; + var step1CompletedEvent = new ManualResetEventSlim(false); + + var step1 = new TestStep(_ => + { + step1Completed = true; + step1CompletedEvent.Set(); + return Task.CompletedTask; + }, ServiceProvider); + + var step2 = new TestStep(async _ => + { + step1CompletedEvent.Wait(TestContext.Current.CancellationToken); + await step1; + step2AwaitedSuccessfully = step1Completed; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.True(step1Completed, "Step1 should have completed"); + Assert.True(step2AwaitedSuccessfully, "Step2 should see Step1 as completed"); + } + + [Fact] + public async Task RunAsync_ParallelRunner_StepAwaitsOtherStep_NoDeadlock() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + + // Need at least 2 workers + if (runner.WorkerCount < 2) + return; + + var step1Started = new ManualResetEventSlim(false); + var step1CanComplete = new ManualResetEventSlim(false); + var step1Completed = false; + var step2CompletedAfterStep1 = false; + + var step1 = new TestStep(_ => + { + step1Started.Set(); + step1CanComplete.Wait(TestContext.Current.CancellationToken); + step1Completed = true; + return Task.CompletedTask; + }, ServiceProvider); + + var step2 = new TestStep(async _ => + { + step1Started.Wait(TestContext.Current.CancellationToken); + step1CanComplete.Set(); + await step1; + step2CompletedAfterStep1 = step1Completed; + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + var completed = await WaitForTaskWithTimeout(runTask, TimeSpan.FromSeconds(10)); + + Assert.True(completed, "Runner should complete without deadlock"); + Assert.True(step1Completed, "Step1 should have completed"); + Assert.True(step2CompletedAfterStep1, "Step2 should complete after Step1"); + } + + [Fact] + public async Task RunAsync_ParallelRunner_MultipleStepsAwaitSameStep_AllComplete() + { + if (HasSequentialStepExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: false); + + var awaiterCount = runner.WorkerCount - 1; + if (awaiterCount < 2) + return; + + var step1CanComplete = new ManualResetEventSlim(false); + var awaitersStarted = new CountdownEvent(awaiterCount); + var completedAwaiters = 0; + + var step1 = new TestStep( _ => + { + awaitersStarted.Wait(TestContext.Current.CancellationToken); + step1CanComplete.Wait(TestContext.Current.CancellationToken); + return Task.CompletedTask; + }, ServiceProvider); + + runner.AddStep(step1); + + for (var i = 0; i < awaiterCount; i++) + { + var step = new TestStep(async _ => + { + awaitersStarted.Signal(); + + if (awaitersStarted.CurrentCount == 0) + step1CanComplete.Set(); + + await step1; + Interlocked.Increment(ref completedAwaiters); + }, ServiceProvider); + runner.AddStep(step); + } + + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); + var completed = await WaitForTaskWithTimeout(runTask, TimeSpan.FromSeconds(10)); + + Assert.True(completed, "Runner should complete without deadlock"); + Assert.Equal(awaiterCount, completedAwaiters); + } + + [Fact] + public async Task RunAsync_StepAwaitsFailedStep_ReceivesException() + { + var runner = CreateStepRunner(); + var expectedException = new InvalidOperationException("Step1 failed"); + Exception? caughtException = null; + + var step1 = new TestStep(_ => throw expectedException, ServiceProvider); + + var step2 = new TestStep(async _ => + { + await Task.Delay(50, TestContext.Current.CancellationToken); + + try + { + await step1; + } + catch (Exception ex) + { + caughtException = ex; + } + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + await runner.RunAsync(CancellationToken.None); + + Assert.NotNull(caughtException); + Assert.Same(expectedException, caughtException); + } + + [Fact] + public void RunAsync_SequentialRunner_StepAwaitsLaterStep_WouldDeadlock() + { + if (!SupportsSequentialExecutionOrder) + return; + + var runner = CreateStepRunner(sequential: true); + + var step2 = new TestStep(_ => Task.CompletedTask, ServiceProvider); + + var step1 = new TestStep(async _ => + { + var awaitTask = Task.Run(async () => await step2, TestContext.Current.CancellationToken); + var completed = await WaitForTaskWithTimeout(awaitTask, TimeSpan.FromMilliseconds(500)); + + Assert.False(completed, + "Awaiting a later step in sequential mode should not complete (would deadlock)"); + }, ServiceProvider); + + runner.AddStep(step1); + runner.AddStep(step2); + FinishAdding(runner); + + var runTask = runner.RunAsync(CancellationToken.None); +#pragma warning disable xUnit1031 + var testCompleted = WaitForTaskWithTimeout(runTask, TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + + Assert.True(testCompleted, "Test should complete (step1 detects the would-be deadlock internally)"); + } + + #endregion + + // TODO: Remove + private static async Task WaitForTaskWithTimeout(Task task, TimeSpan timeout) + { + var delayTask = Task.Delay(timeout); + var completedTask = await Task.WhenAny(task, delayTask); + return completedTask == task; + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/StepErrorEventArgsTest.cs b/src/CommonUtilities.SimplePipeline/test/StepErrorEventArgsTest.cs index 1a37b4d9..9835a744 100644 --- a/src/CommonUtilities.SimplePipeline/test/StepErrorEventArgsTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/StepErrorEventArgsTest.cs @@ -1,16 +1,18 @@ using System; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using AnakinRaW.CommonUtilities.Testing; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; -public class StepErrorEventArgsTest : CommonTestBase +public class StepErrorEventArgsTest : TestBaseWithServiceProvider { [Fact] public void Cancel() { var e = new Exception("Tet"); - var step = new TestStep(_ => { }, ServiceProvider); + var step = new TestStep(_ => Task.CompletedTask, ServiceProvider); var args = new StepRunnerErrorEventArgs(e, step); Assert.Same(step, args.Step); diff --git a/src/CommonUtilities.SimplePipeline/test/StepFailureExceptionTests.cs b/src/CommonUtilities.SimplePipeline/test/StepFailureExceptionTests.cs index a765b5af..14afbeb4 100644 --- a/src/CommonUtilities.SimplePipeline/test/StepFailureExceptionTests.cs +++ b/src/CommonUtilities.SimplePipeline/test/StepFailureExceptionTests.cs @@ -1,11 +1,13 @@ using System; -using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using AnakinRaW.CommonUtilities.Testing; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; -public class StepFailureExceptionTests : CommonTestBase +public class StepFailureExceptionTests : TestBaseWithServiceProvider { [Fact] public void Ctor_WithNullFailedSteps_ThrowsArgumentNullException() @@ -13,57 +15,68 @@ public void Ctor_WithNullFailedSteps_ThrowsArgumentNullException() Assert.Throws(() => new StepFailureException(null!)); } + [Fact] + public async Task FailedSteps_IsCopyOfFailedSteps() + { + var step1 = await TestStep.CreateFailed(new Exception("TestError 1"), ServiceProvider); + var step2 = await TestStep.CreateFailed(new Exception("TestError 2"), ServiceProvider); + + List steps = [step1]; + + var exception = new StepFailureException(steps); + + // Adding a step after exception is created + steps.Add(step2); + + var actualStep = Assert.Single(exception.FailedSteps); + Assert.Same(step1, actualStep); + } + [Fact] public void Message_WithNoFailedSteps_ReturnsEmptyString() { var ex = new StepFailureException([]); - Assert.Equal(string.Empty, ex.Message); + Assert.Equal("0 Failed Step(s)", ex.Message); } [Fact] - public void Message_WithOneFailedStep_ReturnsErrorMessage() + public async Task Message_WithOneFailedStep_ReturnsErrorMessage() { - var step = new TestStep(_ => throw new Exception("TestError"), ServiceProvider); - - try - { - step.Run(CancellationToken.None); - } - catch - { - // Ignore - } - + var step = await TestStep.CreateFailed(new Exception("TestError"), ServiceProvider); var ex = new StepFailureException([step]); - - Assert.Equal("Step 'TestStep' failed with error: TestError", ex.Message); + Assert.Equal( + "1 Failed Step(s): " + + "Step 'TestStep' failed with error: TestError", + ex.Message); } [Fact] - public void Message_WithMultipleFailedSteps_ReturnsErrorMessage() + public async Task Message_WithMultipleFailedAndNonFailedSteps_ReturnsErrorMessage() { - var step1 = new TestStep(_ => throw new Exception("TestError1"), ServiceProvider); - var step2 = new TestStep(_ => throw new Exception("TestError2"), ServiceProvider); + var step1 = await TestStep.CreateFailed(new Exception("TestError1"), ServiceProvider); + var step2 = await TestStep.CreateFailed(new Exception("TestError2"), ServiceProvider); + var step3 = await TestStep.CreateFailed(null, ServiceProvider); + + var ex = new StepFailureException([step1, step2, step3]); - try - { - step1.Run(CancellationToken.None); - } - catch - { - // Ignore - } - try - { - step2.Run(CancellationToken.None); - } - catch - { - // Ignore - } + Assert.Equal( + "3 Failed Step(s): " + + "Step 'TestStep' failed with error: TestError1;" + + "Step 'TestStep' failed with error: TestError2;" + + "Step 'TestStep' failed with error: n/a", + ex.Message); + } + + [Fact] + public async Task Message_CalledMultipleTimes() + { + var step1 = await TestStep.CreateFailed(new Exception("TestError1"), ServiceProvider); - var ex = new StepFailureException([step1, step2]); + var ex = new StepFailureException([step1]); - Assert.Equal("Step 'TestStep' failed with error: TestError1;Step 'TestStep' failed with error: TestError2", ex.Message); + var message1 = ex.Message; + var message2 = ex.Message; + + Assert.Equal(message1, message2); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTest.cs b/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTest.cs index 5f4f7575..a1be5496 100644 --- a/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTest.cs @@ -1,106 +1,108 @@ -using System; +using AnakinRaW.CommonUtilities.SimplePipeline.Steps; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using System; +using System.Collections.Generic; using System.Threading; -using AnakinRaW.CommonUtilities.Testing; +using System.Threading.Tasks; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Steps; -public class PipelineStepTest : CommonTestBase +public class PipelineStepTest : PipelineStepTestSuite { - [Fact] - public void Ctor_NullArgs_Throws() + protected override bool StepRespectsCancellationToken => true; + protected override bool StepAddsExceptionsToErrorProperty => true; + + protected override Type GetExpectedExceptionType(Exception thrownException) { - Assert.Throws(() => new TestStep(_ => { }, null!)); + return thrownException.GetType(); } - [Fact] - public void Disposed() + protected override PipelineStep CreateStep() { - var step = new TestStep(_ => { }, ServiceProvider); + return new TestStep(null, ServiceProvider); + } - step.Dispose(); - Assert.True(step.IsDisposed); + protected override PipelineStep CreateStepWithAction(Func action) + { + return new TestStep(action, ServiceProvider); } + #region Ctor + [Fact] - public void Run() + public void Ctor_NullArgs_Throws() { - var ran = false; - var step = new TestStep(_ => { ran = true; }, ServiceProvider); + Assert.Throws(() => new TestStep(null, null!)); + } - step.Run(CancellationToken.None); + #endregion - Assert.True(ran); - } + #region ToString [Fact] - public void Run_ThrowsException() + public void ToString_IsTypeName() { - var expectedError = new Exception(); + var step = new TestStep(null, ServiceProvider); + Assert.Equal(step.GetType().Name, step.ToString()); + } - var step = new TestStep(_ => throw expectedError, ServiceProvider); + #endregion - Assert.Throws(() => step.Run(CancellationToken.None)); - Assert.Same(expectedError, step.Error); - } + #region Error - [Fact] - public void Run_WithCancellation_ThrowsOperationCanceledException() + [Theory] + [MemberData(nameof(StepsThatThrowError_TestData))] + public async Task Error_Cancel_PropertyIsCorrectlySet(PipelineStep step, bool shouldContainError, bool isCancel) { - var step = new TestStep(ct => + try { - ct.ThrowIfCancellationRequested(); - }, ServiceProvider); - - var cts = new CancellationTokenSource(); - cts.Cancel(); + await step.RunAsync(TestContext.Current.CancellationToken); + } + catch + { + // Ignore + } - Assert.Throws(() => step.Run(cts.Token)); - Assert.Null(step.Error); - } - - [Fact] - public void Run_StopRunnerException_IsNotAddedToErrors() - { - var step = new TestStep(_ => throw new StopRunnerException(), ServiceProvider); - - Assert.Throws(() => step.Run(CancellationToken.None)); - Assert.Null(step.Error); + Assert.Equal(shouldContainError, step.Error is not null); + + Assert.Equal(isCancel, step.IsCancelled); } - [Fact] - public void Run_AggregateException() + #endregion + + public static IEnumerable StepsThatThrowError_TestData() { - var expected = new AggregateException(new Exception("Test")); - var step = new TestStep(_ => throw expected, ServiceProvider); - - Assert.Throws(() => step.Run(CancellationToken.None)); - Assert.Same(expected, step.Error); + foreach (var step in StepsWhichEvaluateToNullErrorProperty()) + yield return [step.step, false, step.cancel]; + foreach (var step in CancelledStepsWithErrorProperty()) + yield return [step, true, true]; + foreach (var step in FailedSteps()) + yield return [step, true, false]; } - [Fact] - public void Run_AggregateException_OriginatedFromOperationCancelled() + private static IEnumerable<(ErrorStep step, bool cancel)> StepsWhichEvaluateToNullErrorProperty() { - var expected = new Exception("Test"); - var step = new TestStep(_ => throw new AggregateException(new OperationCanceledException(null, expected)), ServiceProvider); - - Assert.Throws(() => step.Run(CancellationToken.None)); - Assert.Same(expected, step.Error); + yield return (new ErrorStep(null), false); + yield return (new ErrorStep(new StopRunnerException()), false); + yield return (new ErrorStep(new OperationCanceledException()), true); + yield return (new ErrorStep(new TaskCanceledException()), true); + yield return (new ErrorStep(new AggregateException(new OperationCanceledException())), true); + yield return (new ErrorStep(new AggregateException(new TaskCanceledException())), true); + yield return (new ErrorStep(new AggregateException(new AggregateException(new OperationCanceledException()))), true); + yield return (new ErrorStep(new AggregateException(new Exception(), new OperationCanceledException())), true); } - [Fact] - public void Run_AggregateException_OriginatedFromOperationCancelled_NoInnerException() + private static IEnumerable CancelledStepsWithErrorProperty() { - var step = new TestStep(_ => throw new AggregateException(new OperationCanceledException()), ServiceProvider); - - Assert.Throws(() => step.Run(CancellationToken.None)); - Assert.Null(step.Error); + yield return new ErrorStep(new OperationCanceledException("Cancel", new Exception("Test"))); + yield return new ErrorStep(new AggregateException(new OperationCanceledException("Cancel", new Exception("Test")))); + yield return new ErrorStep(new AggregateException(new AggregateException(new OperationCanceledException("Cancel", new Exception("Test"))))); } - [Fact] - public void ToString_IsTypeName() + private static IEnumerable FailedSteps() { - var step = new TestStep(_ => { }, ServiceProvider); - Assert.Equal(step.GetType().Name, step.ToString()); + yield return new ErrorStep(new Exception()); + yield return new ErrorStep(new AggregateException(new ArgumentException())); } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTestSuite.cs b/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTestSuite.cs new file mode 100644 index 00000000..7ab85475 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/Steps/PipelineStepTestSuite.cs @@ -0,0 +1,507 @@ +using AnakinRaW.CommonUtilities.SimplePipeline.Steps; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; +using AnakinRaW.CommonUtilities.Testing; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Steps; + +/// +/// Abstract base class for testing PipelineStep implementations. +/// Subclasses MUST define expected behavior via abstract properties. +/// +public abstract class PipelineStepTestSuite : TestBaseWithServiceProvider +{ + /// + /// Factory method to create a step for basic testing. + /// + protected abstract PipelineStep CreateStep(); + + /// + /// Factory method to create a step that performs an async action. + /// + protected abstract PipelineStep CreateStepWithAction(Func action); + + /// + /// Defines whether this step respects cancellation tokens. + /// + protected abstract bool StepRespectsCancellationToken { get; } + + /// + /// Defines whether exceptions thrown in RunCoreAsync are added to the Error property. + /// + protected abstract bool StepAddsExceptionsToErrorProperty { get; } + + /// + /// Defines whether the Step throws exceptions at all + /// + protected virtual bool StepThrowsExceptions => true; + + /// + /// Defines the exception type that is propagated when the step throws an exception. + /// Return null if the step does not throw exceptions + /// + protected abstract Type? GetExpectedExceptionType(Exception thrownException); + + #region Dispose + + [Fact] + public void Disposed() + { + var step = CreateStep(); + + step.Dispose(); + Assert.True(step.IsDisposed); + } + + #endregion + + #region RunAsync + + [Fact] + public async Task RunAsync_TaskAction() + { + var ran = false; + var step = CreateStepWithAction(_ => + { + ran = true; + return Task.CompletedTask; + }); + + await step.RunAsync(CancellationToken.None); + + Assert.True(ran); + } + + [Fact] + public async Task RunAsync_AwaitedAction() + { + var ran = false; + var step = CreateStepWithAction(async _ => + { + await Task.Yield(); + ran = true; + }); + + await step.RunAsync(CancellationToken.None); + + Assert.True(ran); + } + + [Fact] + public async Task RunAsync_ThrowsException() + { + var expectedError = new InvalidOperationException("Test"); + var step = CreateStepWithAction(_ => throw expectedError); + + var expectedType = GetExpectedExceptionType(expectedError); + + if (expectedType != null) + { + await Assert.ThrowsAsync(expectedType, () => step.RunAsync(CancellationToken.None)); + + if (StepAddsExceptionsToErrorProperty) + { + Assert.NotNull(step.Error); + // For transformed exceptions, check if original is preserved somehow + if (expectedType == expectedError.GetType()) + Assert.Same(expectedError, step.Error); + } + else + { + Assert.Null(step.Error); + } + } + else + { + await step.RunAsync(CancellationToken.None); + } + } + + [Fact] + public async Task RunAsync_WithCancellation_ThrowsOperationCanceledException() + { + if (!StepRespectsCancellationToken) + { + // Skip test or verify step ignores cancellation + var tcs = new TaskCompletionSource(); + var step = CreateStepWithAction(async _ => await tcs.Task); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var runTask = step.RunAsync(cts.Token); + await Task.Delay(50, TestContext.Current.CancellationToken); + + Assert.False(runTask.IsCompleted); // Step ignores cancellation + Assert.False(step.IsCancelled); + tcs.SetResult(true); + await runTask; + } + else + { + var step = CreateStepWithAction(ct => + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }); + + var cts2 = new CancellationTokenSource(); + cts2.Cancel(); + + await Assert.ThrowsAsync(() => step.RunAsync(cts2.Token)); + Assert.Null(step.Error); + Assert.True(step.IsCancelled); + } + } + + [Fact] + public async Task RunAsync_StopRunnerException_IsNotAddedToErrors() + { + if (!StepThrowsExceptions) + return; + + var step = CreateStepWithAction(_ => throw new StopRunnerException()); + + var expectedType = GetExpectedExceptionType(new StopRunnerException())!; + await Assert.ThrowsAsync(expectedType, () => step.RunAsync(CancellationToken.None)); + + Assert.Null(step.Error); + } + + [Fact] + public async Task RunAsync_AggregateException() + { + if (!StepThrowsExceptions) + return; + + var innerException = new Exception("Test"); + var expected = new AggregateException(innerException); + var step = CreateStepWithAction(_ => throw expected); + + var expectedType = GetExpectedExceptionType(expected)!; + await Assert.ThrowsAsync(expectedType, () => step.RunAsync(CancellationToken.None)); + + if (StepAddsExceptionsToErrorProperty) + { + Assert.NotNull(step.Error); + if (expectedType == typeof(AggregateException)) + Assert.Same(expected, step.Error); + } + else + { + Assert.Null(step.Error); + } + } + + [Fact] + public async Task RunAsync_AggregateException_OriginatedFromOperationCancelled() + { + if (!StepThrowsExceptions) + return; + + var innerException = new Exception("Test"); + var aggregateException = new AggregateException(new OperationCanceledException(null, innerException)); + var step = CreateStepWithAction(_ => throw aggregateException); + + var expectedType = GetExpectedExceptionType(aggregateException)!; + var actualException = await Assert.ThrowsAsync(expectedType, () => step.RunAsync(CancellationToken.None)); + + if (StepAddsExceptionsToErrorProperty) + { + Assert.NotNull(step.Error); + + if (actualException == aggregateException) + { + // Only if we did not modify the exception in the step unwrap to inner exception + Assert.Same(innerException, step.Error); + } + } + else + { + Assert.Null(step.Error); + } + } + + [Fact] + public async Task RunAsync_AggregateException_OriginatedFromOperationCancelled_NoInnerException() + { + if (!StepThrowsExceptions) + return; + + var expected = new AggregateException(new OperationCanceledException()); + var step = CreateStepWithAction(_ => throw expected); + + var expectedType = GetExpectedExceptionType(expected)!; + await Assert.ThrowsAsync(expectedType, async () => await step.RunAsync(CancellationToken.None)); + Assert.Null(step.Error); + } + + #endregion + + #region GetAwaiter / ConfigureAwait + + [Fact] + public async Task GetAwaiter_AfterCompletion_ReturnsImmediately() + { + var executed = false; + var step = CreateStepWithAction(_ => + { + executed = true; + return Task.CompletedTask; + }); + + await step.RunAsync(CancellationToken.None); + + await step; + await step.ConfigureAwait(false); + await step.ConfigureAwait(true); + + Assert.True(executed); + } + + [Fact] + public async Task GetAwaiter_BeforeStart_WaitsForCompletion() + { + var tcs = new TaskCompletionSource(); + var step = CreateStepWithAction(async _ => + { + await tcs.Task; + }); + + var awaiterTask = Task.Run(async () => await step, TestContext.Current.CancellationToken); + var configuredAwaitedT = Task.Run(async () => await step.ConfigureAwait(true), TestContext.Current.CancellationToken); + var configuredAwaitedF = Task.Run(async () => await step.ConfigureAwait(false), TestContext.Current.CancellationToken); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaiterTask.IsCompleted); + + var runTask = step.RunAsync(CancellationToken.None); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaiterTask.IsCompleted); + Assert.False(configuredAwaitedT.IsCompleted); + Assert.False(configuredAwaitedF.IsCompleted); + + tcs.SetResult(true); + await runTask; + + await awaiterTask; + await configuredAwaitedT; + await configuredAwaitedF; + + Assert.True(awaiterTask.IsCompleted); + Assert.True(configuredAwaitedT.IsCompleted); + Assert.True(configuredAwaitedF.IsCompleted); + } + + [Fact] + public async Task GetAwaiter_DuringExecution_WaitsForCompletion() + { + var tcs = new TaskCompletionSource(); + var step = CreateStepWithAction(async _ => { await tcs.Task; }); + + var runTask = step.RunAsync(CancellationToken.None); + + var awaiterTask = Task.Run(async () => await step, TestContext.Current.CancellationToken); + var configuredAwaitedT = Task.Run(async () => await step.ConfigureAwait(true), TestContext.Current.CancellationToken); + var configuredAwaitedF = Task.Run(async () => await step.ConfigureAwait(false), TestContext.Current.CancellationToken); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaiterTask.IsCompleted); + + tcs.SetResult(true); + await runTask; + + await awaiterTask; + await configuredAwaitedT; + await configuredAwaitedF; + + Assert.True(awaiterTask.IsCompleted); + Assert.True(configuredAwaitedT.IsCompleted); + Assert.True(configuredAwaitedF.IsCompleted); + } + + [Fact] + public async Task GetAwaiter_MultipleAwaiters_AllComplete() + { + var tcs = new TaskCompletionSource(); + var step = CreateStepWithAction(async _ => + { + await tcs.Task; + }); + + var awaiter1 = Task.Run(async () => await step, TestContext.Current.CancellationToken); + var awaiter2 = Task.Run(async () => await step, TestContext.Current.CancellationToken); + var awaiter3 = Task.Run(async () => await step, TestContext.Current.CancellationToken); + + var runTask = step.RunAsync(CancellationToken.None); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(awaiter1.IsCompleted); + Assert.False(awaiter2.IsCompleted); + Assert.False(awaiter3.IsCompleted); + + tcs.SetResult(true); + await runTask; + + await Task.WhenAll(awaiter1, awaiter2, awaiter3); + Assert.True(awaiter1.IsCompleted); + Assert.True(awaiter2.IsCompleted); + Assert.True(awaiter3.IsCompleted); + } + + [Fact] + public async Task GetAwaiter_AfterSuccessfulRun_CanBeAwaitedMultipleTimes() + { + var executionCount = 0; + var step = CreateStepWithAction(_ => + { + Interlocked.Increment(ref executionCount); + return Task.CompletedTask; + }); + + await step.RunAsync(CancellationToken.None); + Assert.Equal(1, executionCount); + + await step; + await step; + await step; + await step.ConfigureAwait(false); + await step.ConfigureAwait(false); + await step.ConfigureAwait(false); + await step.ConfigureAwait(true); + await step.ConfigureAwait(true); + await step.ConfigureAwait(true); + + Assert.Equal(1, executionCount); + } + + [Fact] + public async Task GetAwaiter_PropagatesException() + { + if (!StepThrowsExceptions) + return; + + var expected = new InvalidOperationException("Test error"); + var step = CreateStepWithAction(_ => throw expected); + + var expectedType = GetExpectedExceptionType(expected)!; + + await Assert.ThrowsAsync(expectedType, () => step.RunAsync(CancellationToken.None)); + await Assert.ThrowsAsync(expectedType, async () => await step); + await Assert.ThrowsAsync(expectedType, async () => await step.ConfigureAwait(false)); + await Assert.ThrowsAsync(expectedType, async () => await step.ConfigureAwait(true)); + } + + [Fact] + public async Task GetAwaiter_WithCancellation_PropagatesCancellation() + { + if (!StepRespectsCancellationToken || !StepThrowsExceptions) + return; + + var step = CreateStepWithAction(ct => + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync(() => step.RunAsync(cts.Token)); + await Assert.ThrowsAsync(async () => await step); + await Assert.ThrowsAsync(async () => await step.ConfigureAwait(false)); + await Assert.ThrowsAsync(async () => await step.ConfigureAwait(true)); + } + + [Fact] + public void AwaiterAndAwaitableEquality() + { + ConfigureAwaitTestExtensions.AwaiterAndAwaitableEquality( + () => CreateStepWithAction(_ => Task.CompletedTask), + step => step.GetAwaiter(), + (step, ca) => step.ConfigureAwait(ca)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + [InlineData(null)] + public void OnCompleted_CompletesInAnotherSynchronizationContext(bool? continueOnCapturedContext) + { + ConfigureAwaitTestExtensions.TestOnCompletedCompletesInAnotherSynchronizationContext( + continueOnCapturedContext, + () => CreateStepWithAction(_ => Task.CompletedTask), + step => step.GetAwaiter(), + (step, ca) => step.ConfigureAwait(ca), + step => step.RunAsync(CancellationToken.None)); + } + + [Fact] + public async Task RunAsync_GetAwaiter_ConfigureAwait_ShareSameTask() + { + var stepStarted = new TaskCompletionSource(); + var canComplete = new TaskCompletionSource(); + + var step = new TestStep(async _ => + { + stepStarted.SetResult(1); + await canComplete.Task; + }, ServiceProvider); + + // Get task from RunAsync + var runTask = step.RunAsync(CancellationToken.None); + + await stepStarted.Task; + + // All should report same IsCompleted state while running + Assert.False(runTask.IsCompleted); + Assert.False(step.GetAwaiter().IsCompleted); + Assert.False(step.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.False(step.ConfigureAwait(true).GetAwaiter().IsCompleted); + + canComplete.SetResult(1); + await runTask; + + // All should now be completed + Assert.True(runTask.IsCompleted); + Assert.True(step.GetAwaiter().IsCompleted); + Assert.True(step.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.True(step.ConfigureAwait(true).GetAwaiter().IsCompleted); + } + + [Fact] + public async Task RunAsync_GetAwaiter_ConfigureAwait_PropagateExceptionConsistently() + { + var expectedException = new InvalidOperationException("Test"); + + var step = new TestStep(_ => throw expectedException, ServiceProvider); + + var runTask = step.RunAsync(CancellationToken.None); + + // All should throw the same exception + var ex1 = await Assert.ThrowsAsync(async () => await runTask); + var ex2 = await Assert.ThrowsAsync(async () => await step); + var ex3 = await Assert.ThrowsAsync(async () => await step.ConfigureAwait(false)); + var ex4 = await Assert.ThrowsAsync(async () => await step.ConfigureAwait(true)); + + Assert.Same(expectedException, ex1); + Assert.Same(expectedException, ex2); + Assert.Same(expectedException, ex3); + Assert.Same(expectedException, ex4); + + // All report same completed state + Assert.True(runTask.IsCompleted); + Assert.True(step.GetAwaiter().IsCompleted); + Assert.True(step.ConfigureAwait(false).GetAwaiter().IsCompleted); + Assert.True(step.ConfigureAwait(true).GetAwaiter().IsCompleted); + + // Step.Error is set + Assert.Same(expectedException, step.Error); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Steps/RunPipelineStepTest.cs b/src/CommonUtilities.SimplePipeline/test/Steps/RunPipelineStepTest.cs index 958ee1ff..349c1b0d 100644 --- a/src/CommonUtilities.SimplePipeline/test/Steps/RunPipelineStepTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Steps/RunPipelineStepTest.cs @@ -1,80 +1,276 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using AnakinRaW.CommonUtilities.SimplePipeline.Runners; using AnakinRaW.CommonUtilities.SimplePipeline.Steps; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Steps; -public class RunPipelineStepTest : CommonTestBase +public class RunPipelineStepTest : PipelineStepTestSuite { - private class DelegatePipeline(Func action, IServiceProvider serviceProvider) : Pipeline(serviceProvider) + protected override bool StepRespectsCancellationToken => true; + + protected override bool StepAddsExceptionsToErrorProperty => true; + + protected override PipelineStep CreateStep() { - protected override Task PrepareCoreAsync() - { - return Task.FromResult(true); - } + var steps = new List(); + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, steps); + return new RunPipelineStep(pipeline, ServiceProvider); + } + + protected override PipelineStep CreateStepWithAction(Func action) + { + var testStep = new TestStep(action, ServiceProvider); + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, [testStep]); + return new RunPipelineStep(pipeline, ServiceProvider); + } - protected override Task RunCoreAsync(CancellationToken token) + protected override Type GetExpectedExceptionType(Exception thrownException) + { + if (thrownException is AggregateException aggregateException && aggregateException.IsExceptionType()) { - return action(token); + return aggregateException.InnerExceptions.FirstOrDefault(p => p.IsExceptionType()) + ?.InnerException is not null ? typeof(StepFailureException) : typeof(OperationCanceledException); } + + if (thrownException.IsExceptionType() || thrownException is StopRunnerException) + return typeof(OperationCanceledException); + return typeof(StepFailureException); } + #region Constructor + [Fact] - public void Run() + public void Constructor_NullPipeline_ThrowsArgumentNullException() { - var ran = false; + Assert.Throws(() => new RunPipelineStep(null!, ServiceProvider)); + } - var pipeline = new DelegatePipeline(async ct => - { - await Task.Delay(3000, ct).ConfigureAwait(false); - ran = true; + [Fact] + public void Constructor_NullServiceProvider_ThrowsArgumentNullException() + { + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, []); + Assert.Throws(() => new RunPipelineStep(pipeline, null!)); + } - },ServiceProvider); + [Fact] + public void Constructor_ValidArguments_CreatesInstance() + { + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, []); var step = new RunPipelineStep(pipeline, ServiceProvider); - step.Run(CancellationToken.None); - Assert.True(ran); + Assert.NotNull(step); + Assert.False(step.IsDisposed); } + #endregion + + #region RunAsync + [Fact] - public void Run_PipelineFails() + public async Task RunAsync_ExecutesPipelineSteps() { - var pipeline = new DelegatePipeline(_ => throw new ApplicationException("test"), ServiceProvider); + var step1Executed = false; + var step2Executed = false; - var step = new RunPipelineStep(pipeline, ServiceProvider); + var testStep1 = new TestStep(_ => + { + step1Executed = true; + return Task.CompletedTask; + }, ServiceProvider); - Assert.Throws(() => step.Run(CancellationToken.None)); + var testStep2 = new TestStep(_ => + { + step2Executed = true; + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, [testStep1, testStep2]); + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + await runPipelineStep.RunAsync(CancellationToken.None); + + Assert.True(step1Executed); + Assert.True(step2Executed); + } + + [Fact] + public async Task RunAsync_ExecutesStepsInOrder() + { + var executionOrder = new List(); + + var testStep1 = new TestStep(_ => + { + executionOrder.Add(1); + return Task.CompletedTask; + }, ServiceProvider); + + var testStep2 = new TestStep(_ => + { + executionOrder.Add(2); + return Task.CompletedTask; + }, ServiceProvider); + + var testStep3 = new TestStep(_ => + { + executionOrder.Add(3); + return Task.CompletedTask; + }, ServiceProvider); + + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, [testStep1, testStep2, testStep3]); + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + await runPipelineStep.RunAsync(CancellationToken.None); + + Assert.Equal([1,2,3], executionOrder); } [Fact] - public void Run_Cancel() + public async Task RunAsync_CancellationToken_IsPropagatedToSteps() { - var cts = new CancellationTokenSource(); + var tcs = new TaskCompletionSource(); + var stepRunning = new TaskCompletionSource(); + var cancellationObserved = false; - var pipeline = new DelegatePipeline(async ct => + var testStep = new TestStep(async ct => { - await Task.Run(() => cts.Cancel(), CancellationToken.None); - ct.ThrowIfCancellationRequested(); + try + { + stepRunning.SetResult(true); + await tcs.Task.WaitAsync(ct); + } + catch (OperationCanceledException) + { + cancellationObserved = true; + throw; + } }, ServiceProvider); + var steps = new List { testStep }; + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, steps); + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + using var cts = new CancellationTokenSource(); + var runTask = runPipelineStep.RunAsync(cts.Token); + + await stepRunning.Task; + cts.Cancel(); + + await Assert.ThrowsAsync(() => runTask); + Assert.True(cancellationObserved); + } + + [Fact] + public async Task RunAsync_EmptyPipeline_CompletesSuccessfully() + { + var steps = new List(); + var pipeline = new TestPipeline(ServiceProvider, RunnerBehavior.Sequential, steps); + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + await runPipelineStep.RunAsync(CancellationToken.None); + + Assert.Null(runPipelineStep.Error); + } + + [Fact] + public async Task RunAsync_OnPrepareThrows_PropagatesException() + { + var expectedException = new InvalidOperationException("Prepare failed"); + var steps = new List(); + var pipeline = new TestPipeline( + ServiceProvider, + RunnerBehavior.Sequential, + steps, + _ => throw expectedException); + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + var exception = await Assert.ThrowsAsync( + () => runPipelineStep.RunAsync(CancellationToken.None)); + + Assert.Same(expectedException, exception); + } + + [Fact] + public async Task RunAsync_PipelineThrowsStepFailedException() + { + var expectedException = new InvalidOperationException("Pipeline step failed"); + var step = new TestStep(_ => throw expectedException, ServiceProvider); + var pipeline = new TestPipeline( + ServiceProvider, + RunnerBehavior.Sequential, + [step]); + + var runPipelineStep = new RunPipelineStep(pipeline, ServiceProvider); + + var exception = await Assert.ThrowsAsync( + () => runPipelineStep.RunAsync(CancellationToken.None)); + + Assert.Contains("Pipeline step failed", exception.Message); + } + + #endregion + + #region Dispose + + [Fact] + public void Dispose_DisposesPipeline() + { + var pipeline = new TestPipeline( + ServiceProvider, + RunnerBehavior.Sequential, + new List()); var step = new RunPipelineStep(pipeline, ServiceProvider); - Assert.ThrowsAny(() => step.Run(cts.Token)); + step.Dispose(); + step.Dispose(); + step.Dispose(); + + Assert.True(pipeline.IsDisposed); + Assert.True(step.IsDisposed); } [Fact] - public void Dispose() + public async Task Dispose_AfterRun_DisposesPipeline() { - var pipeline = new DelegatePipeline(_ => Task.CompletedTask, ServiceProvider); + var pipeline = new TestPipeline( + ServiceProvider, + RunnerBehavior.Sequential, + new List()); var step = new RunPipelineStep(pipeline, ServiceProvider); - + + await step.RunAsync(CancellationToken.None); step.Dispose(); Assert.True(step.IsDisposed); - Assert.True(pipeline.IsDisposed); + } + + #endregion + + private class TestPipeline( + IServiceProvider serviceProvider, + RunnerBehavior runnerBehavior, + IList steps, + Func? onPrepare = null) : StepRunnerPipeline(serviceProvider) + { + protected override IStepRunner CreateRunner() + { + if (runnerBehavior is RunnerBehavior.Sequential) + return new SequentialStepRunner(ServiceProvider); + return new AsyncStepRunner(4, ServiceProvider); + } + + protected override async Task> CreateRunnerSteps(CancellationToken token) + { + if (onPrepare is not null) + await onPrepare(token); + return steps; + } } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Steps/SynchronizedStepTest.cs b/src/CommonUtilities.SimplePipeline/test/Steps/SynchronizedStepTest.cs deleted file mode 100644 index bb2c1445..00000000 --- a/src/CommonUtilities.SimplePipeline/test/Steps/SynchronizedStepTest.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.Testing; -using Xunit; - -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Steps; - -public class SynchronizedStepTest : CommonTestBase -{ - [Fact] - public void Wait_ThrowsTimeoutException() - { - var step = new TestSyncStep(_ => { }, ServiceProvider); - // Do not run the step - Assert.Throws(() => step.Wait(TimeSpan.Zero)); - } - - [Fact] - public void Run_ThrowsWait() - { - var expectedException = new Exception("Test"); - var step = new TestSyncStep(_ => throw expectedException, ServiceProvider); - - Assert.Throws(() => step.Run(CancellationToken.None)); - - // Should not block - step.Wait(); - } - - [Fact] - public void Run_Cancelled_ThrowsOperationCanceledException() - { - var step = new TestSyncStep(ct => { ct.ThrowIfCancellationRequested(); }, ServiceProvider); - - var flag = false; - step.Canceled += delegate - { - flag = true; - }; - - var cts = new CancellationTokenSource(); - cts.Cancel(); - - Assert.Throws(() => step.Run(cts.Token)); - - // Should not block - step.Wait(); - - Assert.True(flag); - } - - [Fact] - public void Wait() - { - var flag = false; - var step = new TestSyncStep(_ => - { - Task.Delay(1000, CancellationToken.None).Wait(CancellationToken.None); - flag = true; - }, ServiceProvider); - - Task.Run(() => step.Run(CancellationToken.None)).Forget(); - - step.Wait(); - - Assert.True(flag); - } - - [Fact] - public void Wait_WithTimeout() - { - var step = new TestSyncStep(_ => - { - Task.Delay(1000, CancellationToken.None).Wait(CancellationToken.None); - }, ServiceProvider); - - Task.Factory.StartNew(() => step.Run(CancellationToken.None), CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default); - Assert.Throws(() => step.Wait(TimeSpan.FromMilliseconds(100))); - } - - [Fact] - public void Dispose() - { - var step = new TestSyncStep(_ => - { - Task.Delay(1000, CancellationToken.None).Wait(CancellationToken.None); - }, ServiceProvider); - - step.Dispose(); - - Assert.Throws(step.Wait); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Steps/WaitStepTest.cs b/src/CommonUtilities.SimplePipeline/test/Steps/WaitStepTest.cs index 019f5b6d..1c739fe2 100644 --- a/src/CommonUtilities.SimplePipeline/test/Steps/WaitStepTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Steps/WaitStepTest.cs @@ -1,42 +1,143 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; using AnakinRaW.CommonUtilities.SimplePipeline.Runners; using AnakinRaW.CommonUtilities.SimplePipeline.Steps; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; using Xunit; namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Steps; -public class WaitStepTest : CommonTestBase -{ +public class WaitStepTest : PipelineStepTestSuite +{ + protected override bool StepRespectsCancellationToken => false; // WaitStep ignores cancellation! + protected override bool StepAddsExceptionsToErrorProperty => false; // Transforms to StopRunnerException + + protected override Type GetExpectedExceptionType(Exception thrownException) + { + // WaitStep transforms runner exceptions into StopRunnerException + return typeof(StopRunnerException); + } + + protected override PipelineStep CreateStep() + { + var runner = new AsyncStepRunner(1, ServiceProvider); + return new WaitStep(runner, ServiceProvider); + } + + protected override PipelineStep CreateStepWithAction(Func action) + { + var runner = new AsyncStepRunner(1, ServiceProvider); + runner.AddStep(new TestStep(action, ServiceProvider)); + _ = runner.RunAsync(CancellationToken.None); + return new WaitStep(runner, ServiceProvider); + } + + [Fact] + public void Ctor_NullArgs_Throws() + { + Assert.Throws(() => new WaitStep(null!, ServiceProvider)); + Assert.Throws(() => new WaitStep( + new AsyncStepRunner(1, ServiceProvider), + null!)); + } + [Fact] - public void Wait() + public void ToString_HasExpectedValue() { - var runner = new ParallelStepRunner(2, ServiceProvider); + var runner = new AsyncStepRunner(1, ServiceProvider); + var step = new WaitStep(runner, ServiceProvider); + Assert.Equal("Waiting for other step runner", step.ToString()); + } + + [Fact] + public async Task RunAsync_EmptyRunner_CompletesSuccessfully() + { + var runner = new AsyncStepRunner(1, ServiceProvider); + // No steps added + + var step = new WaitStep(runner, ServiceProvider); + + await runner.RunAsync(CancellationToken.None); + await step.RunAsync(CancellationToken.None); + + Assert.Null(step.Error); + } + + [Fact] + public async Task RunAsync_CompletesAfterRunnerFinishes() + { + var runner = new AsyncStepRunner(2, ServiceProvider); var completed1 = false; var completed2 = false; runner.AddStep(new TestStep(_ => { - Task.Delay(1000, CancellationToken.None).Wait(CancellationToken.None); completed1 = true; + return Task.CompletedTask; }, ServiceProvider)); runner.AddStep(new TestStep(_ => { - Task.Delay(1000, CancellationToken.None).Wait(CancellationToken.None); completed2 = true; + return Task.CompletedTask; }, ServiceProvider)); var step = new WaitStep(runner, ServiceProvider); _ = runner.RunAsync(CancellationToken.None); - step.Run(CancellationToken.None); - // We cannot assert on the runnerTask task, - // as the impl. creates different tasks for await and Wait(). - // This may result in a race where the Wait() reports completion before the awaitable task + await step.RunAsync(CancellationToken.None); + Assert.False(runner.IsRunning); Assert.True(completed1); Assert.True(completed2); } + + [Fact] + public async Task Wait_RunnerWithException_ThrowsStopRunnerException() + { + var runner = new AsyncStepRunner(1, ServiceProvider); + runner.AddStep(new TestStep(_ => throw new InvalidOperationException("Test"), ServiceProvider)); + + var step = new WaitStep(runner, ServiceProvider); + + _ = runner.RunAsync(CancellationToken.None); + + await Assert.ThrowsAsync(() => step.RunAsync(CancellationToken.None)); + Assert.Null(step.Error); + } + + [Fact] + public async Task Wait_RunnerCancelled_ThrowsStopRunnerException() + { + var runner = new AsyncStepRunner(1, ServiceProvider); + var cts = new CancellationTokenSource(); + + runner.AddStep(new TestStep(ct => + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }, ServiceProvider)); + + var step = new WaitStep(runner, ServiceProvider); + + cts.Cancel(); + _ = runner.RunAsync(cts.Token); + + await Assert.ThrowsAsync(() => step.RunAsync(CancellationToken.None)); + Assert.Null(step.Error); + } + + [Fact] + public async Task Wait_RunnerWithStopRunnerException_PropagatesStopRunnerException() + { + var runner = new AsyncStepRunner(1, ServiceProvider); + runner.AddStep(new TestStep(_ => throw new StopRunnerException(), ServiceProvider)); + + var step = new WaitStep(runner, ServiceProvider); + _ = runner.RunAsync(CancellationToken.None); + + await Assert.ThrowsAsync(() => step.RunAsync(CancellationToken.None)); + Assert.Null(step.Error); + } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/StopRunnerExceptionTests.cs b/src/CommonUtilities.SimplePipeline/test/StopRunnerExceptionTests.cs new file mode 100644 index 00000000..3afe63b7 --- /dev/null +++ b/src/CommonUtilities.SimplePipeline/test/StopRunnerExceptionTests.cs @@ -0,0 +1,22 @@ +using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Extensions; +using Xunit; + +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; + +public class StopRunnerExceptionTests : TestBaseWithServiceProvider +{ + [Fact] + public void Ctor_Default_DoesNotThrow() + { + var ex = new StopRunnerException(); + Assert.Exception(ex, message: "Stopping step runner."); + } + + [Fact] + public void Ctor_WithMessage_SetsMessage() + { + var ex = new StopRunnerException("Test Message"); + Assert.Exception(ex, message: "Test Message"); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/TestStep.cs b/src/CommonUtilities.SimplePipeline/test/TestData/TestStep.cs similarity index 62% rename from src/CommonUtilities.SimplePipeline/test/TestStep.cs rename to src/CommonUtilities.SimplePipeline/test/TestData/TestStep.cs index bfef8bb7..ffd7a0d3 100644 --- a/src/CommonUtilities.SimplePipeline/test/TestStep.cs +++ b/src/CommonUtilities.SimplePipeline/test/TestData/TestStep.cs @@ -1,26 +1,52 @@ using AnakinRaW.CommonUtilities.SimplePipeline.Progress; using AnakinRaW.CommonUtilities.SimplePipeline.Steps; +using Microsoft.Extensions.DependencyInjection; using System; using System.Threading; +using System.Threading.Tasks; -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.TestData; public class TestStep : PipelineStep { - private readonly Action? _action; + private readonly Func? _action; protected TestStep(IServiceProvider sp) : base(sp) { } - public TestStep(Action action, IServiceProvider serviceProvider) : base(serviceProvider) + public TestStep(Func? action, IServiceProvider serviceProvider) : base(serviceProvider) { _action = action; } - protected override void RunCore(CancellationToken token) + protected override Task RunCoreAsync(CancellationToken token) { - _action?.Invoke(token); + return _action?.Invoke(token) ?? Task.CompletedTask; + } + + public static async Task CreateFailed(Exception? error, IServiceProvider serviceProvider) + { + var step = new TestStep(_ => error is not null ? throw error : Task.CompletedTask, serviceProvider); + try + { + await step.RunAsync(CancellationToken.None); + } + catch (Exception) + { + // Ignore + } + return step; + } +} + +public class ErrorStep(Exception? exceptionToThrow) : PipelineStep(new ServiceCollection().BuildServiceProvider()) +{ + protected override Task RunCoreAsync(CancellationToken token) + { + if (exceptionToThrow is not null) + throw exceptionToThrow; + return Task.CompletedTask; } } @@ -81,20 +107,6 @@ private bool Equals(TestInfoClass other) public override int GetHashCode() { - unchecked - { - return (Progress.GetHashCode() * 397) ^ Aggregated.GetHashCode(); - } - } -} - -public class TestSyncStep(Action? action, IServiceProvider serviceProvider) - : SynchronizedStep(serviceProvider) -{ - public ProgressType Type => new() { Id = "test", DisplayName = "Test" }; - - protected override void RunSynchronized(CancellationToken token) - { - action?.Invoke(token); + return HashCode.Combine(Progress, Aggregated); } } \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Attributes/PlatformSpecificFactAttribute.cs b/src/CommonUtilities.Testing/Attributes/PlatformSpecificFactAttribute.cs new file mode 100644 index 00000000..44d8e248 --- /dev/null +++ b/src/CommonUtilities.Testing/Attributes/PlatformSpecificFactAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Testing.Attributes; + +/// +/// Test attribute that specifies the test should only run on specific platforms. +/// +/// +/// This attribute allows you to define platform-specific tests by specifying the target platforms +/// using . If the current platform does not match any of the specified +/// platforms, the test will be skipped with an appropriate message. +/// +public sealed class PlatformSpecificFactAttribute : FactAttribute +{ + /// + /// Initializes a new instance of the class with the specified target platforms. + /// + /// + /// An array of values that specify the platforms on which the test should run. + /// + /// + /// If the current platform does not match any of the specified , the test will be skipped + /// with a message indicating that the test execution is not supported on the current platform. + /// + public PlatformSpecificFactAttribute(params TestPlatformIdentifier[] platformIds) + { + var platforms = platformIds.Select(targetPlatform => OSPlatform.Create(Enum.GetName(typeof(TestPlatformIdentifier), targetPlatform)!.ToUpper())); + var platformMatches = platforms.Any(RuntimeInformation.IsOSPlatform); + + if (!platformMatches) + Skip = "Test execution is not supported on the current platform"; + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Attributes/PlatformSpecificTheoryAttribute.cs b/src/CommonUtilities.Testing/Attributes/PlatformSpecificTheoryAttribute.cs new file mode 100644 index 00000000..8d9ed4a3 --- /dev/null +++ b/src/CommonUtilities.Testing/Attributes/PlatformSpecificTheoryAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Testing.Attributes; + +/// +/// A theory attribute that specifies the test should only run on specific platforms. +/// +/// +/// This attribute allows you to define platform-specific tests by specifying the target platforms +/// using . If the current platform does not match any of the specified +/// platforms, the test will be skipped with an appropriate message. +/// +public sealed class PlatformSpecificTheoryAttribute : TheoryAttribute +{ + /// + /// Initializes a new instance of the class with the specified target platforms. + /// + /// + /// An array of values that specify the platforms on which the test should run. + /// + /// + /// If the current platform does not match any of the specified , the test will be skipped + /// with a message indicating that the test execution is not supported on the current platform. + /// + public PlatformSpecificTheoryAttribute(params TestPlatformIdentifier[] platformIds) + { + var platforms = platformIds.Select(targetPlatform => OSPlatform.Create(Enum.GetName(typeof(TestPlatformIdentifier), targetPlatform)!.ToUpper())); + var platformMatches = platforms.Any(RuntimeInformation.IsOSPlatform); + + if (!platformMatches) + Skip = "Test execution is not supported on the current platform"; + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Attributes/TestPlatformIdentifier.cs b/src/CommonUtilities.Testing/Attributes/TestPlatformIdentifier.cs new file mode 100644 index 00000000..69e23b4a --- /dev/null +++ b/src/CommonUtilities.Testing/Attributes/TestPlatformIdentifier.cs @@ -0,0 +1,23 @@ +using System; + +namespace AnakinRaW.CommonUtilities.Testing.Attributes; + +/// +/// Represents identifiers for test platforms used to specify platform-specific test execution. +/// +/// +/// This enumeration is used in conjunction with attributes like +/// and to define tests that should only run on specific platforms. +/// +[Flags] +public enum TestPlatformIdentifier +{ + /// + /// Represents the Windows platform. + /// + Windows = 1, + /// + /// Represents a Linux platform. + /// + Linux = 2, +} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/CollectionsTestSuite.cs b/src/CommonUtilities.Testing/Collections/CollectionsTestSuite.cs similarity index 73% rename from src/CommonUtilities.TestingUtilities/Collections/CollectionsTestSuite.cs rename to src/CommonUtilities.Testing/Collections/CollectionsTestSuite.cs index 1fc1d512..c226d783 100644 --- a/src/CommonUtilities.TestingUtilities/Collections/CollectionsTestSuite.cs +++ b/src/CommonUtilities.Testing/Collections/CollectionsTestSuite.cs @@ -1,4 +1,7 @@ +using System; +using System.Collections.Generic; using System.Diagnostics; +using Xunit; namespace AnakinRaW.CommonUtilities.Testing.Collections; @@ -9,17 +12,43 @@ namespace AnakinRaW.CommonUtilities.Testing.Collections; /// public abstract class CollectionsTestSuite { + /// + /// Represents a set of operations that can modify a collection during testing. + /// [Flags] public enum ModifyOperation { + /// + /// Represents the absence of any modification operation on a collection. + /// None = 0, + /// + /// Represents an operation that adds an element from a collection. + /// Add = 1, + /// + /// Represents an operation that inserts an element from a collection. + /// Insert = 2, + /// + /// Represents an operation that overwrites an existing element in a collection. + /// Overwrite = 4, + /// + /// Represents an operation that removes an element from a collection. + /// Remove = 8, + /// + /// Represents an operation that clears all elements from a collection. + /// Clear = 16 } + /// + /// Provides a collection of valid sizes for testing collections. + /// + /// This data is intended for use with xUnit's Theory tests to verify behavior across different + /// collection sizes, including empty, single-item, and larger collections. public static IEnumerable ValidCollectionSizes() { yield return [0]; @@ -27,11 +56,25 @@ public static IEnumerable ValidCollectionSizes() yield return [75]; } + /// + /// Provides test data for enumerable-related test cases. + /// + /// + /// This method generates a variety of enumerable configurations to test different scenarios, + /// including empty enumerables, enumerables of varying sizes, and enumerables with duplicate or matching elements. + /// + /// + /// A collection of test data, where each element is an array of objects containing the following: + /// - The size of the original collection. + /// - The size of the enumerable to be tested. + /// - The number of matching elements between the original collection and the enumerable. + /// - The number of duplicate elements in the enumerable. + /// public static IEnumerable GetEnumerableTestData() { - foreach (var collectionSizeArray in ValidCollectionSizes()) + foreach (var sizeArray in ValidCollectionSizes()) { - var count = (int)collectionSizeArray[0]; + var count = (int)sizeArray[0]; yield return [count, 0, 0, 0]; // Empty Enumerable yield return [count, count + 1, 0, 0]; // Enumerable that is 1 larger @@ -111,7 +154,7 @@ protected IEnumerable CreateList(IEnumerable? enumerableToMatchTo, int cou // Add Matching elements if (enumerableToMatchTo != null) { - match = enumerableToMatchTo.ToList(); + match = [.. enumerableToMatchTo]; for (var i = 0; i < numberOfMatchingElements; i++) { list.Add(match[i]); @@ -131,7 +174,7 @@ protected IEnumerable CreateList(IEnumerable? enumerableToMatchTo, int cou list.Add(toAdd); } - // Validate that the Enumerable fits the guidelines as expected + // ValidateAsync that the Enumerable fits the guidelines as expected Debug.Assert(list.Count == count); if (match != null) { diff --git a/src/CommonUtilities.TestingUtilities/Collections/ICollectionTestSuite.cs b/src/CommonUtilities.Testing/Collections/ICollectionTestSuite.cs similarity index 84% rename from src/CommonUtilities.TestingUtilities/Collections/ICollectionTestSuite.cs rename to src/CommonUtilities.Testing/Collections/ICollectionTestSuite.cs index cf077a4a..a990058c 100644 --- a/src/CommonUtilities.TestingUtilities/Collections/ICollectionTestSuite.cs +++ b/src/CommonUtilities.Testing/Collections/ICollectionTestSuite.cs @@ -1,5 +1,11 @@ +using AnakinRaW.CommonUtilities.Testing.Extensions; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; +using Xunit; namespace AnakinRaW.CommonUtilities.Testing.Collections; @@ -12,20 +18,80 @@ namespace AnakinRaW.CommonUtilities.Testing.Collections; [SuppressMessage("ReSharper", "InconsistentNaming")] public abstract class ICollectionTestSuite : IEnumerableTestSuite { + /// + /// Gets the of the exception that is expected to be thrown + /// when attempting to copy elements of the collection to an array with a starting index + /// larger than the array's length. + /// + /// + /// By default, this property returns . + /// Override this property in derived classes to specify a different exception type + /// if the behavior differs. + /// protected virtual Type ICollection_Generic_CopyTo_IndexLargerThanArrayCount_ThrowType => typeof(ArgumentException); + /// + /// Gets a collection of values that are considered invalid for the test suite. + /// + /// + /// These values are used in various test cases to validate the behavior of the collection + /// when handling invalid inputs. The specific definition of "invalid" depends on the context + /// of the test suite and the type parameter . + /// protected virtual IEnumerable InvalidValues => []; + /// + /// Gets a value indicating whether the default value of the type is allowed + /// to be added to the collection. + /// + /// + /// This property determines whether operations involving the default value of + /// (e.g., adding or checking for the default value) are valid within the collection. + /// + /// + /// if the default value of is allowed in the collection; otherwise, . + /// protected virtual bool DefaultValueAllowed => true; + /// + /// Gets a value indicating whether the Add, Remove, and Clear operations + /// are expected to throw a in the collection. + /// + /// + /// if the Add, Remove, and Clear operations + /// are not supported and should throw a ; otherwise, . + /// protected virtual bool AddRemoveClear_ThrowsNotSupported => false; + /// + /// Indicates whether the collection supports storing duplicate values. + /// + /// + /// if the collection allows multiple instances of the same value; otherwise, . + /// protected virtual bool DuplicateValuesAllowed => true; + /// + /// Gets a value indicating whether the collection is read-only. + /// + /// + /// A read-only collection does not allow the addition, removal, or modification of elements + /// after the collection is created. This property can be overridden to specify whether the + /// collection is read-only in derived classes. + /// + /// + /// if the collection is read-only; otherwise, . + /// protected virtual bool IsReadOnly => false; - protected virtual bool IsReadOnly_ValidityValue => IsReadOnly; - + /// + /// Gets a value indicating whether an exception is thrown when attempting to use the default value + /// in a collection where default values are not allowed. + /// + /// + /// if an exception is thrown when the default value is used in such a collection; + /// otherwise, . + /// protected virtual bool DefaultValueWhenNotAllowed_Throws => true; /// @@ -76,6 +142,7 @@ protected override IEnumerable GetModifyEnumerables(ModifyOper } } + /// protected override IEnumerable GenericIEnumerableFactory(int count) { return GenericICollectionFactory(count); @@ -93,6 +160,11 @@ protected virtual ICollection GenericICollectionFactory(int count) return collection; } + /// + /// Adds a specified number of items to the given collection. + /// + /// The collection to which items will be added. + /// The number of items to add to the collection. protected virtual void AddToCollection(ICollection collection, int numberOfItemsToAdd) { var seed = 9600; @@ -106,17 +178,7 @@ protected virtual void AddToCollection(ICollection collection, int numberOfIt } } - #region IsReadOnly - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void ICollection_Generic_IsReadOnly_Validity(int count) - { - var collection = GenericICollectionFactory(count); - Assert.Equal(IsReadOnly_ValidityValue, collection.IsReadOnly); - } - - #endregion +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member #region Count @@ -241,7 +303,7 @@ public void ICollection_Generic_Add_AfterRemovingAnyValue(int count) collection.Add(toAdd); items.Add(toAdd); - CollectionAsserts.EqualUnordered(items, collection); + Assert.EqualUnordered(items, collection); } } @@ -261,7 +323,7 @@ public void ICollection_Generic_Add_AfterRemovingAllItems(int count) [Theory] [MemberData(nameof(ValidCollectionSizes))] - public void ICollection_Generic_Add_ToReadOnlyFrugalList(int count) + public void ICollection_Generic_Add_ToReadOnlyCollection(int count) { if (IsReadOnly || AddRemoveClear_ThrowsNotSupported) { @@ -604,7 +666,7 @@ public void ICollection_Generic_Remove_NonDefaultValueContainedInCollection(int { var seed = count * 251; var collection = GenericICollectionFactory(count); - var value = CreateT(seed++); + var value = CreateT(++seed); if (!collection.Contains(value)) { collection.Add(value); @@ -623,7 +685,7 @@ public void ICollection_Generic_Remove_ValueThatExistsTwiceInCollection(int coun { var seed = count * 90; var collection = GenericICollectionFactory(count); - var value = CreateT(seed++); + var value = CreateT(++seed); collection.Add(value); collection.Add(value); count += 2; @@ -671,4 +733,6 @@ public void ICollection_Generic_Remove_DefaultValueWhenNotAllowed(int count) } #endregion + +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Collections/IEnumerableTestSuite.cs b/src/CommonUtilities.Testing/Collections/IEnumerableTestSuite.cs new file mode 100644 index 00000000..63b9ceef --- /dev/null +++ b/src/CommonUtilities.Testing/Collections/IEnumerableTestSuite.cs @@ -0,0 +1,1188 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Testing.Collections; + +// This test suite is taken from the .NET runtime repository (https://github.com/dotnet/runtime) and adapted to the VSTesting Framework. +// The .NET Foundation licenses this under the MIT license. +/// +/// Contains tests that ensure the correctness of any class that implements the generic +/// IEnumerable interface. +/// +[SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] +[SuppressMessage("ReSharper", "AccessToDisposedClosure")] +[SuppressMessage("ReSharper", "InconsistentNaming")] +public abstract class IEnumerableTestSuite : CollectionsTestSuite +{ + /// + /// An enum to allow specification of the order of the Enumerable. Used in validation for enumerables. + /// + protected enum EnumerableOrder + { + /// + /// Specifies that the enumerable returns in an unspecified order. + /// + Unspecified, + /// + /// Specifies that the enumerable returns sequential. + /// + Sequential + } + + /// + /// Modifies the given IEnumerable such that any enumerators for that IEnumerable will be + /// invalidated. + /// + /// An IEnumerable to modify + /// true if the enumerable was successfully modified. Else false. + public delegate bool ModifyEnumerable(IEnumerable enumerable); + + /// + /// The Reset method is provided for COM interoperability. It does not necessarily need to be + /// implemented; instead, the implementer can simply throw a NotSupportedException. + /// + /// If Reset is not implemented, this property must return False. The default value is true. + /// + protected virtual bool ResetImplemented => true; + + /// Whether the enumerator returned from GetEnumerator is a singleton instance when the collection is empty. + protected virtual bool Enumerator_Empty_UsesSingletonInstance => false; + + /// + /// When calling Current of the enumerator before the first MoveNext, after the end of the collection, + /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included + /// to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Returning an undefined value. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// false. + /// + protected virtual bool Enumerator_Current_UndefinedOperation_Throws => false; + + /// + /// When calling Current of the enumerator before the first MoveNext, after the end of the collection, + /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included + /// to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Returning an undefined value. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// false. + /// + protected virtual bool NonGenericEnumerator_Current_UndefinedOperation_Throws => false; + + /// + /// When calling Current of the empty enumerator before the first MoveNext, after the end of the collection, + /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included + /// to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Returning an undefined value. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// . + /// + protected virtual bool Enumerator_Empty_Current_UndefinedOperation_Throws => Enumerator_Current_UndefinedOperation_Throws; + + /// + /// When calling Current of the empty enumerator before the first MoveNext, after the end of the collection, + /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included + /// to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Returning an undefined value. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// false. + /// + protected virtual bool NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw => Enumerator_Current_UndefinedOperation_Throws; + + /// + /// Specifies whether this IEnumerable follows some sort of ordering pattern. + /// + protected virtual EnumerableOrder Order => EnumerableOrder.Sequential; + + /// + /// When calling MoveNext or Reset after modification of the enumeration, the resulting behavior is + /// undefined. Tests are included to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Execute MoveNext or Reset. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// true. + /// + protected virtual bool Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException => true; + + /// + /// When calling MoveNext or Reset after modification of an empty enumeration, the resulting behavior is + /// undefined. Tests are included to cover two behavioral scenarios: + /// - Throwing an InvalidOperationException + /// - Execute MoveNext or Reset. + /// + /// If this property is set to true, the tests ensure that the exception is thrown. The default value is + /// . + /// + protected virtual bool Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException => Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException; + + /// + /// Gets the set of values that represent modifications to a collection + /// which are expected to throw an when performed during enumeration. + /// + /// + /// This property defines the operations that are not allowed to be performed on a collection + /// while it is being enumerated. By default, it includes , + /// , , + /// , and . + /// + protected virtual ModifyOperation ModifyEnumeratorThrows => ModifyOperation.Add | ModifyOperation.Insert | ModifyOperation.Overwrite | ModifyOperation.Remove | ModifyOperation.Clear; + + /// + /// Gets a value indicating the types of modification operations that are allowed on an enumerator + /// during enumeration without causing exceptions. + /// + /// + /// This property specifies the set of flags that represent + /// the operations permitted on the enumerator while it is being enumerated. By default, no + /// modifications are allowed, as indicated by . + /// + protected virtual ModifyOperation ModifyEnumeratorAllowed => ModifyOperation.None; + + /// + /// Creates an instance of an IEnumerable{T} that can be used for testing. + /// + /// The number of unique items that the returned IEnumerable{T} contains. + /// An instance of an IEnumerable{T} that can be used for testing. + protected abstract IEnumerable GenericIEnumerableFactory(int count); + + /// + /// To be implemented in the concrete collections test classes. Returns a set of ModifyEnumerable delegates + /// that modify the enumerable passed to them. + /// + protected abstract IEnumerable GetModifyEnumerables(ModifyOperation operations); + + /// + /// Creates an instance of an IEnumerable that can be used for testing. + /// + /// The number of unique items that the returned IEnumerable contains. + /// An instance of an IEnumerable that can be used for testing. + protected virtual IEnumerable NonGenericIEnumerableFactory(int count) + { + return GenericIEnumerableFactory(count); + } + + private void RepeatTest(Action, T[]> testCode, int iters = 3) + { + RepeatTest((e, i, _) => testCode(e, i), iters); + } + + private void RepeatTest(Action, T[], int> testCode, int iters = 3) + { + var enumerable = GenericIEnumerableFactory(32); + var items = enumerable.ToArray(); + var enumerator = enumerable.GetEnumerator(); + for (var i = 0; i < iters; i++) + { + testCode(enumerator, items, i); + if (!ResetImplemented) + enumerator = enumerable.GetEnumerator(); + else + enumerator.Reset(); + } + enumerator.Dispose(); + } + + private void VerifyEnumerator(IEnumerator enumerator, T[] expectedItems) + { + VerifyEnumerator(enumerator, expectedItems, 0, expectedItems.Length, true, true); + } + + private void VerifyEnumerator(IEnumerator enumerator, T[] expectedItems, int startIndex, int count, bool validateStart, bool validateEnd) + { + var needToMatchAllExpectedItems = count - startIndex == expectedItems.Length; + if (validateStart) + { + for (var i = 0; i < 3; i++) + { + if (Enumerator_Current_UndefinedOperation_Throws) + { + Assert.Throws(() => enumerator.Current); + } + else + { + _ = enumerator.Current; + } + } + } + + int iterations; + if (Order == EnumerableOrder.Unspecified) + { + var itemsVisited = new BitArray(needToMatchAllExpectedItems ? count : expectedItems.Length, false); + for (iterations = 0; iterations < count && enumerator.MoveNext(); iterations++) + { + object? currentItem = enumerator.Current; + + var itemFound = false; + for (var i = 0; i < itemsVisited.Length; ++i) + { + if (!itemsVisited[i] && Equals(currentItem, expectedItems[i + (needToMatchAllExpectedItems ? startIndex : 0)])) + { + itemsVisited[i] = true; + itemFound = true; + break; + } + } + + Assert.True(itemFound, "itemFound"); + + for (var i = 0; i < 3; i++) + { + object? tempItem = enumerator.Current; + Assert.Equal(currentItem, tempItem); + } + } + + if (needToMatchAllExpectedItems) + { + for (var i = 0; i < itemsVisited.Length; i++) + { + Assert.True(itemsVisited[i]); + } + } + else + { + var visitedItemCount = 0; + for (var i = 0; i < itemsVisited.Length; i++) + { + if (itemsVisited[i]) + { + ++visitedItemCount; + } + } + + Assert.Equal(count, visitedItemCount); + } + } + else if (Order == EnumerableOrder.Sequential) + { + for (iterations = 0; iterations < count && enumerator.MoveNext(); iterations++) + { + object? currentItem = enumerator.Current; + Assert.Equal(expectedItems[iterations], currentItem); + for (var i = 0; i < 3; i++) + { + object? tempItem = enumerator.Current; + Assert.Equal(currentItem, tempItem); + } + } + } + else + { + throw new ArgumentException( + "EnumerableOrder is invalid."); + } + + Assert.Equal(count, iterations); + + if (validateEnd) + { + for (var i = 0; i < 3; i++) + { + Assert.False(enumerator.MoveNext(), "enumerator.MoveNext() returned true past the expected end."); + + if (Enumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + } + } + } + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + + #region GetEnumerator() + + [Fact] + public void IEnumerable_NonGeneric_GetEnumerator_EmptyCollection_UsesSingleton() + { + var enumerable = NonGenericIEnumerableFactory(0); + + var enumerator1 = enumerable.GetEnumerator(); + try + { + var enumerator2 = enumerable.GetEnumerator(); + try + { + Assert.Equal(Enumerator_Empty_UsesSingletonInstance, ReferenceEquals(enumerator1, enumerator2)); + } + finally + { + if (enumerator2 is IDisposable d2) d2.Dispose(); + } + } + finally + { + if (enumerator1 is IDisposable d1) d1.Dispose(); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_GetEnumerator_NoExceptionsWhileGetting(int count) + { + var enumerable = NonGenericIEnumerableFactory(count); + // ReSharper disable once GenericEnumeratorNotDisposed + Assert.NotNull(enumerable.GetEnumerator()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_GetEnumerator_ReturnsUniqueEnumerator(int count) + { + //Tests that the enumerators returned by GetEnumerator operate independently of one another + var enumerable = NonGenericIEnumerableFactory(count); + var iterations = 0; + foreach (var _ in enumerable) + foreach (var __ in enumerable) + foreach (var ___ in enumerable) + iterations++; + Assert.Equal(count * count * count, iterations); + } + + [Fact] + public void IEnumerable_Generic_GetEnumerator_EmptyCollection_UsesSingleton() + { + IEnumerable enumerable = GenericIEnumerableFactory(0); + + var enumerator1 = enumerable.GetEnumerator(); + try + { + var enumerator2 = enumerable.GetEnumerator(); + try + { + Assert.Equal(Enumerator_Empty_UsesSingletonInstance, ReferenceEquals(enumerator1, enumerator2)); + } + finally + { + if (enumerator2 is IDisposable d2) d2.Dispose(); + } + } + finally + { + if (enumerator1 is IDisposable d1) d1.Dispose(); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_GetEnumerator_NoExceptionsWhileGetting(int count) + { + var enumerable = GenericIEnumerableFactory(count); + enumerable.GetEnumerator().Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_GetEnumerator_ReturnsUniqueEnumerator(int count) + { + //Tests that the enumerators returned by GetEnumerator operate independently of one another + var enumerable = GenericIEnumerableFactory(count); + var iterations = 0; + foreach (var _ in enumerable) + foreach (var __ in enumerable) + foreach (var ___ in enumerable) + iterations++; + Assert.Equal(count * count * count, iterations); + } + + #endregion + + #region Enumerator.MoveNext + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_MoveNext_FromStartToFinish(int count) + { + var iterations = 0; + var enumerator = NonGenericIEnumerableFactory(count).GetEnumerator(); + while (enumerator.MoveNext()) + iterations++; + Assert.Equal(count, iterations); + if (enumerator is IDisposable d) + d.Dispose(); + } + + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_MoveNext_AfterEndOfCollection(int count) + { + var enumerator = NonGenericIEnumerableFactory(count).GetEnumerator(); + for (var i = 0; i < count; i++) + enumerator.MoveNext(); + Assert.False(enumerator.MoveNext()); + Assert.False(enumerator.MoveNext()); + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_MoveNext_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.MoveNext()); + else + _ = enumerator.MoveNext(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_MoveNext_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.MoveNext()); + else + enumerator.MoveNext(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_MoveNext_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) ; + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.MoveNext()); + else + _ = enumerator.MoveNext(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_FromStartToFinish(int count) + { + var iterations = 0; + using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); + while (enumerator.MoveNext()) + iterations++; + Assert.Equal(count, iterations); + } + + /// + /// For most collections, all calls to MoveNext after disposal of an enumerator will return false. + /// Some collections (SortedList), however, treat a call to dispose as if it were a call to Reset. Since the docs + /// specify neither of these as being strictly correct, we leave the method virtual. + /// + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public virtual void Enumerator_MoveNext_AfterDisposal(int count) + { + var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); + for (var i = 0; i < count; i++) + enumerator.MoveNext(); + enumerator.Dispose(); + Assert.False(enumerator.MoveNext()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_AfterEndOfCollection(int count) + { + using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); + for (var i = 0; i < count; i++) + enumerator.MoveNext(); + Assert.False(enumerator.MoveNext()); + Assert.False(enumerator.MoveNext()); + } + + [Fact] + public void IEnumerable_Generic_Enumerator_MoveNextHitsAllItems() + { + RepeatTest((enumerator, items) => + { + var iterations = 0; + while (enumerator.MoveNext()) + { + iterations++; + } + Assert.Equal(items.Length, iterations); + }); + } + + [Fact] + public void IEnumerable_Generic_Enumerator_MoveNextFalseAfterEndOfCollection() + { + RepeatTest((enumerator, _) => + { + while (enumerator.MoveNext()) + { + } + + Assert.False(enumerator.MoveNext()); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(() => enumerator.MoveNext()); + } + else + { + enumerator.MoveNext(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedBeforeEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + if (Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + enumerator.MoveNext(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(() => enumerator.MoveNext()); + } + else + { + enumerator.MoveNext(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedDuringEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + if (modifyEnumerable(enumerable)) + { + enumerator.MoveNext(); + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) + { + } + + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(() => enumerator.MoveNext()); + } + else + { + enumerator.MoveNext(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedAfterEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) + { + } + + if (modifyEnumerable(enumerable)) + { + enumerator.MoveNext(); + } + } + } + + #endregion + + #region Enumerator.Current + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Current_FromStartToFinish(int count) + { + var enumerator = NonGenericIEnumerableFactory(count).GetEnumerator(); + while (enumerator.MoveNext()) + _ = enumerator.Current; + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Current_ReturnsSameValueOnRepeatedCalls(int count) + { + var enumerator = NonGenericIEnumerableFactory(count).GetEnumerator(); + while (enumerator.MoveNext()) + { + var current = enumerator.Current; + Assert.Equal(current, enumerator.Current); + Assert.Equal(current, enumerator.Current); + Assert.Equal(current, enumerator.Current); + } + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Current_ReturnsSameObjectsOnDifferentEnumerators(int count) + { +#pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. + // Ensures that the elements returned from enumeration are exactly the same collection of + // elements returned from a previous enumeration + var enumerable = NonGenericIEnumerableFactory(count); + var comparer = GetIEqualityComparer(); + var firstValues = new Dictionary(count, comparer); + var secondValues = new Dictionary(count, comparer); + foreach (T item in enumerable) + firstValues[item] = firstValues.ContainsKey(item) ? firstValues[item]++ : 1; + foreach (T item in enumerable) + secondValues[item] = secondValues.ContainsKey(item) ? secondValues[item]++ : 1; + Assert.Equal(firstValues.Count, secondValues.Count); + foreach (var key in firstValues.Keys) + Assert.Equal(firstValues[key], secondValues[key]); +#pragma warning restore CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public virtual void Enumerator_Current_BeforeFirstMoveNext_UndefinedBehavior(int count) + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + if (count == 0 ? NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw : NonGenericEnumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public virtual void Enumerator_Current_AfterEndOfEnumerable_UndefinedBehavior(int count) + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) ; + if (count == 0 ? NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw : NonGenericEnumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public virtual void Enumerator_Current_ModifiedDuringEnumeration_UndefinedBehavior(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 ? NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw : NonGenericEnumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Fact] + public void IEnumerable_Generic_Enumerator_Current() + { + // Verify that current returns proper result. + RepeatTest((enumerator, items, iteration) => + { + if (iteration == 1) + VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); + else + VerifyEnumerator(enumerator, items); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_ReturnsSameValueOnRepeatedCalls(int count) + { + using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); + while (enumerator.MoveNext()) + { + var current = enumerator.Current; + Assert.Equal(current, enumerator.Current); + Assert.Equal(current, enumerator.Current); + Assert.Equal(current, enumerator.Current); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_ReturnsSameObjectsOnDifferentEnumerators(int count) + { + // Ensures that the elements returned from enumeration are exactly the same collection of + // elements returned from a previous enumeration + var enumerable = GenericIEnumerableFactory(count); + var comparer = GetIEqualityComparer(); +#pragma warning disable CS8714 + var firstValues = new Dictionary(count, comparer); + var secondValues = new Dictionary(count, comparer); +#pragma warning restore CS8714 + foreach (var item in enumerable) + firstValues[item] = firstValues.ContainsKey(item) ? firstValues[item]++ : 1; + foreach (var item in enumerable) + secondValues[item] = secondValues.ContainsKey(item) ? secondValues[item]++ : 1; + Assert.Equal(firstValues.Count, secondValues.Count); + foreach (var key in firstValues.Keys) + Assert.Equal(firstValues[key], secondValues[key]); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_BeforeFirstMoveNext_UndefinedBehavior(int count) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_AfterEndOfEnumerable_UndefinedBehavior(int count) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) + { + } + + if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_ModifiedDuringEnumeration_UndefinedBehavior(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) + Assert.Throws(() => enumerator.Current); + else + _ = enumerator.Current; + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Current_ModifiedDuringEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + _ = enumerator.Current; + } + } + } + + #endregion + + #region Enumerator.Reset + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Reset_BeforeIteration_Support(int count) + { + var enumerator = NonGenericIEnumerableFactory(count).GetEnumerator(); + if (ResetImplemented) + enumerator.Reset(); + else + Assert.Throws(() => enumerator.Reset()); + if (enumerator is IDisposable d) + d.Dispose(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Reset_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.Reset()); + else + enumerator.Reset(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Reset_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.Reset()); + else + enumerator.Reset(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_NonGeneric_Enumerator_Reset_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) + { + Assert.All(GetModifyEnumerables(ModifyEnumeratorThrows), ModifyEnumerable => + { + var enumerable = NonGenericIEnumerableFactory(count); + var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) ; + if (ModifyEnumerable((IEnumerable)enumerable)) + { + if (count == 0 + ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException + : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + Assert.Throws(() => enumerator.Reset()); + else + enumerator.Reset(); + } + if (enumerator is IDisposable d) + d.Dispose(); + }); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_BeforeIteration_Support(int count) + { + using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); + if (ResetImplemented) + enumerator.Reset(); + else + Assert.Throws(enumerator.Reset); + } + + [Fact] + public void IEnumerable_Generic_Enumerator_Reset() + { + if (!ResetImplemented) + { + RepeatTest((enumerator, _) => + { + Assert.Throws(enumerator.Reset); + }); + RepeatTest((enumerator, items, iter) => + { + if (iter == 1) + { + VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); + for (var i = 0; i < 3; i++) + { + Assert.Throws(enumerator.Reset); + } + + VerifyEnumerator(enumerator, items, items.Length / 2, items.Length - items.Length / 2, false, true); + } + else if (iter == 2) + { + VerifyEnumerator(enumerator, items); + for (var i = 0; i < 3; i++) + { + Assert.Throws(enumerator.Reset); + } + + VerifyEnumerator(enumerator, items, 0, 0, false, true); + } + else + { + VerifyEnumerator(enumerator, items); + } + }); + } + else + { + RepeatTest((enumerator, items, iter) => + { + if (iter == 1) + { + VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); + enumerator.Reset(); + enumerator.Reset(); + } + else if (iter == 3) + { + VerifyEnumerator(enumerator, items); + enumerator.Reset(); + enumerator.Reset(); + } + else + { + VerifyEnumerator(enumerator, items); + } + }, 5); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(enumerator.Reset); + } + else + { + enumerator.Reset(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedBeforeEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + if (modifyEnumerable(enumerable)) + { + enumerator.Reset(); + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(enumerator.Reset); + } + else + { + enumerator.Reset(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedDuringEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + for (var i = 0; i < count / 2; i++) + enumerator.MoveNext(); + if (modifyEnumerable(enumerable)) + { + enumerator.Reset(); + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) + { + } + + if (modifyEnumerable(enumerable)) + { + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) + { + Assert.Throws(enumerator.Reset); + } + else + { + enumerator.Reset(); + } + } + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IEnumerable_Generic_Enumerator_Reset_ModifiedAfterEnumeration_Succeeds(int count) + { + foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) + { + var enumerable = GenericIEnumerableFactory(count); + using var enumerator = enumerable.GetEnumerator(); + while (enumerator.MoveNext()) + { + } + + if (modifyEnumerable(enumerable)) + enumerator.Reset(); + } + } + + #endregion + +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member +} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/IListTestSuite.cs b/src/CommonUtilities.Testing/Collections/IListTestSuite.cs similarity index 96% rename from src/CommonUtilities.TestingUtilities/Collections/IListTestSuite.cs rename to src/CommonUtilities.Testing/Collections/IListTestSuite.cs index b3bbc1d2..67c45ee8 100644 --- a/src/CommonUtilities.TestingUtilities/Collections/IListTestSuite.cs +++ b/src/CommonUtilities.Testing/Collections/IListTestSuite.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; +using Xunit; namespace AnakinRaW.CommonUtilities.Testing.Collections; @@ -13,6 +17,15 @@ namespace AnakinRaW.CommonUtilities.Testing.Collections; [SuppressMessage("ReSharper", "InconsistentNaming")] public abstract class IListTestSuite : ICollectionTestSuite { + /// + /// Gets the of the exception that is expected to be thrown + /// when accessing or modifying an with an invalid index. + /// + /// + /// By default, this property returns . + /// Derived classes can override this property to specify a different exception type + /// if the behavior of the implementation differs. + /// protected virtual Type IList_Generic_Item_InvalidIndex_ThrowType => typeof(ArgumentOutOfRangeException); /// @@ -90,16 +103,20 @@ protected override IEnumerable GetModifyEnumerables(ModifyOper } } + /// protected override ICollection GenericICollectionFactory() { return GenericIListFactory(); } + /// protected override ICollection GenericICollectionFactory(int count) { return GenericIListFactory(count); } +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + #region Item Getter [Theory] @@ -131,7 +148,7 @@ public void IList_Generic_ItemGet_ValidGetWithinListBounds(int count) return; [MethodImpl(MethodImplOptions.NoInlining)] - void Sink(T _) { } + static void Sink(T _) { } } #endregion @@ -656,20 +673,6 @@ public void IList_Generic_CurrentAtEnd_AfterAdd(int count) } #endregion -} -/// -/// Helper class to provide means to modify an enumerable, which is not the to be tested type. -/// -internal class ModifyEnumerableList(Func createT) : IListTestSuite -{ - protected override T CreateT(int seed) - { - return createT(seed); - } - - protected override IList GenericIListFactory() - { - throw new NotImplementedException(); - } +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Collections/ModifyEnumerableList.cs b/src/CommonUtilities.Testing/Collections/ModifyEnumerableList.cs new file mode 100644 index 00000000..338fee25 --- /dev/null +++ b/src/CommonUtilities.Testing/Collections/ModifyEnumerableList.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Testing.Collections; + +/// +/// Helper class to provide means to modify an enumerable, which is not the to be tested type. +/// +internal class ModifyEnumerableList(Func createT) : IListTestSuite +{ + protected override T CreateT(int seed) + { + return createT(seed); + } + + protected override IList GenericIListFactory() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/CommonUtilities.Testing.csproj b/src/CommonUtilities.Testing/CommonUtilities.Testing.csproj new file mode 100644 index 00000000..5896fc63 --- /dev/null +++ b/src/CommonUtilities.Testing/CommonUtilities.Testing.csproj @@ -0,0 +1,50 @@ + + + + + CommonUtilities.Testing + AnakinRaW.CommonUtilities.Testing + Provides common utilities for testing projects. + + + + true + false + AnakinRaW.CommonUtilities.Testing + AnakinRaW.CommonUtilities.Testing + netstandard2.0;netstandard2.1;net10.0 + en + true + + + + true + snupkg + true + true + true + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/CommonUtilities.Testing/EqualityComparers/ConstantHashCodeEqualityComparer.cs b/src/CommonUtilities.Testing/EqualityComparers/ConstantHashCodeEqualityComparer.cs new file mode 100644 index 00000000..5377662e --- /dev/null +++ b/src/CommonUtilities.Testing/EqualityComparers/ConstantHashCodeEqualityComparer.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Testing.EqualityComparers; + +/// +/// Provides an equality comparer for objects of type that always returns a constant hash code. +/// +/// The type of objects to compare. +/// +/// This comparer uses a constant hash code for all objects, which can be useful for testing scenarios where hash code collisions need to be simulated. +/// +public sealed class ConstantHashCodeEqualityComparer(IEqualityComparer comparer) : IEqualityComparer +{ + /// + /// Determines whether the specified objects are equal. + /// + /// + /// This method delegates the equality comparison to the underlying comparer provided during the construction of the . + /// + /// The first object to compare. + /// The second object to compare. + /// if the specified objects are equal; otherwise, . + public bool Equals(T? x, T? y) + { +#pragma warning disable CS8604 // Possible null reference argument. + return comparer.Equals(x, y); +#pragma warning restore CS8604 // Possible null reference argument. + } + + /// + /// Returns a constant hash code for the specified object. + /// + /// + /// This method always returns the same hash code value (42) regardless of the input object. + /// + /// The object for which the hash code is to be generated. + /// A constant hash code value. + public int GetHashCode(T obj) + { + return 42; + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/EqualityComparers/ReferenceEqualityComparer.cs b/src/CommonUtilities.Testing/EqualityComparers/ReferenceEqualityComparer.cs new file mode 100644 index 00000000..5d04f651 --- /dev/null +++ b/src/CommonUtilities.Testing/EqualityComparers/ReferenceEqualityComparer.cs @@ -0,0 +1,48 @@ +#if !NET10_0_OR_GREATER +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace AnakinRaW.CommonUtilities.Testing.EqualityComparers; + +/// +/// An that uses reference equality () +/// instead of value equality() when comparing two object instances. +/// +/// +/// The type cannot be instantiated. +/// Instead, use the property to access the singleton instance of this type. +/// +public sealed class ReferenceEqualityComparer : IEqualityComparer, IEqualityComparer +{ + /// + /// Gets the singleton instance. + /// + public static ReferenceEqualityComparer Instance { get; } = new(); + + private ReferenceEqualityComparer() + { + } + + /// + /// Determines whether two object references refer to the same object instance. + /// + /// The first object to compare. + /// The second object to compare. + /// + /// if both and + /// refer to the same object instance or if both are ; otherwise, . + /// + public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); + + /// + /// Returns a hash code for the specified object. The returned hash code is based on the object identity, not on the contents of the object. + /// + /// The object for which to retrieve the hash code. + /// A hash code for the identity of . + public int GetHashCode(object? obj) + { + return RuntimeHelpers.GetHashCode(obj!); + } +} +#endif \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Extensions/AssertExtensions.cs b/src/CommonUtilities.Testing/Extensions/AssertExtensions.cs new file mode 100644 index 00000000..00f81826 --- /dev/null +++ b/src/CommonUtilities.Testing/Extensions/AssertExtensions.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Testing.Extensions; + +/// +/// Provides extension methods for the class. +/// +public static class AssertExtensions +{ + private static bool IsNetFramework => RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework"); + + extension(Assert) + { + /// + /// Verifies that the specified action does not throw any exception. + /// + /// The type of the result returned by the action. + /// A delegate to the code to be tested. + /// The result of the executed test code. + public static T DoesNotThrowException(Func action) + { + try + { + return action(); + } + catch (Exception e) + { + Assert.Fail($"Expected no exception to be thrown but got '{e.GetType().Name}' instead"); + return default; + } + } + + /// + /// Verifies that the specified action does not throw any exception. + /// + /// A delegate to the code to be tested. + public static void DoesNotThrowException(Action action) + { + Assert.DoesNotThrowException(() => action); + } + + /// + /// Verifies that the specified action throws an exception of the specified and that the exception's parameter name matches the expected value. + /// + /// The type of the exception expected to be thrown. + /// The expected name of the parameter that caused the exception. + /// A delegate to the code expected to throw the exception. + /// The exception that was thrown. + public static T Throws(string? expectedParamName, Action action) where T : ArgumentException + { + var exception = Assert.Throws(action); + Assert.Equal(expectedParamName, exception.ParamName); + return exception; + } + + /// + /// Verifies that the specified action throws an exception of the specified and that the exception's parameter name matches the expected value. + /// + /// The type of the exception expected to be thrown. + /// The expected name of the parameter that caused the exception when executing the test on .NET Core. + /// The expected name of the parameter that caused the exception when executing the test on .NET Framework. + /// A delegate to the code expected to throw the exception. + /// The exception that was thrown. + public static T Throws(string netCoreParamName, string? netFxParamName, Action action) + where T : ArgumentException + { + var exception = Assert.Throws(action); + + if (netFxParamName == null && IsNetFramework) + { + // Param name varies between .NET Framework versions -- skip checking it + return exception; + } + + var expectedParamName = IsNetFramework ? netFxParamName : netCoreParamName; + + Assert.Equal(expectedParamName, exception.ParamName); + return exception; + } + + // From https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/Collections/CollectionAsserts.cs + + /// + /// Verifies that two collections contain the same elements, regardless of order. + /// + /// The expected collection. + /// The actual collection. + public static void EqualUnordered(ICollection expected, ICollection actual) + { + Assert.Equal(expected == null, actual == null); + if (expected == null) + return; + + // Lookups are an aggregated collections (enumerable contents), but ordered. + var e = expected.Cast().ToLookup(key => key); + var a = actual!.Cast().ToLookup(key => key); + + // Dictionaries can't handle null keys, which is a possibility + Assert.Equal( + e.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count()), + a.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count())); + + // Get count of null keys. Returns an empty sequence (and thus a 0 count) if no null key + Assert.Equal(e[null!].Count(), a[null!].Count()); + } + + /// + /// Verifies that two collections contain the same elements, regardless of order. + /// + /// The expected collection. + /// The actual collection. + public static void EqualUnordered(ICollection expected, ICollection actual) + { + Assert.Equal(expected == null, actual == null); + if (expected == null) + return; + + // Lookups are an aggregated collections (enumerable contents), but ordered. + var e = expected.Cast().ToLookup(key => key); + var a = actual!.Cast().ToLookup(key => key); + + // Dictionaries can't handle null keys, which is a possibility + Assert.Equal( + e.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count()), + a.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count())); + + // Get count of null keys. Returns an empty sequence (and thus a 0 count) if no null key + Assert.Equal(e[null!].Count(), a[null!].Count()); + } + + // Based on https://github.com/dotnet/runtime/blob/main/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Exception.Helpers.cs + + /// + /// Validates the properties of the specified instance against the provided values. + /// + /// The exception instance to validate. + /// The expected inner exception of . Defaults to null. + /// The expected message of . Defaults to null. + /// The expected source of . Defaults to null. + /// The expected stack trace of . Defaults to null. + /// A value indicating whether to validate the property. + public static void Exception(Exception e, + Exception? innerException = null, + string? message = null, + string? source = null, + string? stackTrace = null, + bool validateMessage = true) + { + Assert.Equal(innerException, e.InnerException); + if (validateMessage) + Assert.Equal(message, e.Message); + else + Assert.NotNull(e.Message); + Assert.Equal(source, e.Source); + Assert.Equal(stackTrace, e.StackTrace); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Extensions/RandomExtensions.cs b/src/CommonUtilities.Testing/Extensions/RandomExtensions.cs new file mode 100644 index 00000000..a94621dc --- /dev/null +++ b/src/CommonUtilities.Testing/Extensions/RandomExtensions.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Testing.Extensions; + +/// +/// Provides extension methods for the class. +/// +public static class RandomExtensions +{ + private static readonly Random Random = new(); + private static readonly string AllowedChars = "ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789!@$?_-"; + + extension(Random) + { + /// + /// Generates a random value. + /// + /// + /// A randomly generated value. + /// + public static bool Bool() + { + return Random.Next() % 2 == 0; + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static sbyte SByte() + { + return (sbyte)Random.Next(sbyte.MinValue, sbyte.MaxValue); + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static byte Byte() + { + return (byte)Random.Next(byte.MinValue, byte.MaxValue); + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static short Short() + { + return (short)Random.Next(short.MinValue, short.MaxValue); + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static ushort UShort() + { + return (ushort)Random.Next(ushort.MinValue, ushort.MaxValue); + } + + /// + /// Generates a random value. + /// + /// + /// In contrast to , this method can return the full range of values, including negative values. + /// + /// + /// A randomly generated value within the range of to . + /// + public static int Int() + { + return Random.Next(int.MinValue, int.MaxValue); + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static uint UInt() + { + return (uint)Random.Int(); + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static long Long() + { +#if NET || NETSTANDARD2_1_OR_GREATER + Span buf = stackalloc byte[8]; + Random.NextBytes(buf); + return BitConverter.ToInt64(buf); +#else + var buf = new byte[8]; + Random.NextBytes(buf); + return BitConverter.ToInt64(buf, 0); +#endif + } + + /// + /// Generates a random value. + /// + /// + /// A randomly generated value within the range of to . + /// + public static ulong ULong() + { + return (ulong)Random.Long(); + } + + /// + /// Returns a random value from the enumeration type . + /// + /// + /// The enumeration type from which a random value will be selected. Must be a struct and an . + /// + /// + /// A randomly selected value from the enumeration type . + /// + /// is not an enumeration type. + public static T Enum() where T : struct, Enum + { + var values = +#if NET5_0_OR_GREATER + System.Enum.GetValues(); +#else + (T[])System.Enum.GetValues(typeof(T)); +#endif + return values[Random.Next(values.Length)]; + } + + + // From: https://stackoverflow.com/questions/648196/random-row-from-linq-to-sql/648240#648240 + /// + /// Selects a random item from the provided sequence of items. + /// + /// The type of the elements in the sequence. + /// The sequence of items to select from. + /// A randomly selected item from the sequence. + /// The sequence is empty. + public static T Item(IEnumerable items) + { + T current = default!; + var count = 0; + foreach (var element in items) + { + count++; + if (Random.Next(count) == 0) + current = element; + } + return count == 0 + ? throw new InvalidOperationException("Sequence was empty") + : current; + } + + /// + /// Generates a random string of the specified length using a mix of letters (any case), numbers and special characters + /// + /// The desired length of the generated string. Must be a non-negative value. + /// A randomly generated string of the specified length. Returns an empty string if is 0. + /// Thrown when is less than 0. + public static string String(int length) + { + return Random.String(length, AllowedChars); + } + + /// + /// Generates a random string of the specified length using the specified pool of characters. + /// + /// The desired length of the generated string. Must be a non-negative value. + /// The pool of characters to pick random characters from. + /// A randomly generated string of the specified length. Returns an empty string if is 0. + /// Thrown when is less than 0. + /// Thrown when is empty or . + public static unsafe string String(int length, ReadOnlySpan charPool) + { + if (charPool == ReadOnlySpan.Empty || charPool.IsEmpty) + throw new ArgumentException("charPool must not be null or empty", nameof(charPool)); + switch (length) + { + case < 0: + throw new ArgumentOutOfRangeException(nameof(length)); + case 0: + return string.Empty; + } + + var buffer = length <= 256 + ? stackalloc char[length] + : new char[length]; + + var random = Random; + for (var i = 0; i < buffer.Length; i++) + { + var index = random.Next(charPool.Length); + buffer[i] = charPool[index]; + } + +#if NET + return new string(buffer); +#else + fixed (char* t = buffer) + return new string(t, 0, length); +#endif + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/Extensions/StringExtensions.cs b/src/CommonUtilities.Testing/Extensions/StringExtensions.cs new file mode 100644 index 00000000..fc486d3c --- /dev/null +++ b/src/CommonUtilities.Testing/Extensions/StringExtensions.cs @@ -0,0 +1,58 @@ +using System; + +namespace AnakinRaW.CommonUtilities.Testing.Extensions; + +/// +/// Provides extension methods for string manipulation and testing. +/// +public static class StringExtensions +{ + private static readonly Random Random = new(); + + extension(string) + { + /// + /// Randomly shuffles the casing of the characters in the specified string. + /// + /// The input string whose character casing will be shuffled. + /// A new string with randomly shuffled character casing. + /// is . + public static unsafe string ShuffleCasing(string input) + { + if (input is null) + throw new ArgumentNullException(nameof(input)); + + if (input.Length == 0) + return string.Empty; + + var buffer = input.Length <= 256 + ? stackalloc char[input.Length] + : new char[input.Length]; + + input.AsSpan().CopyTo(buffer); + + var rnd = Random; + + for (var i = 0; i < buffer.Length; i++) + { + var c = buffer[i]; + if (!char.IsLetter(c)) + continue; + + if (rnd.Next(2) != 0) + continue; + + buffer[i] = char.IsUpper(c) + ? char.ToLower(c) + : char.ToUpper(c); + } + +#if NET + return new string(buffer); +#else + fixed (char* t = buffer) + return new string(t, 0, buffer.Length); +#endif + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/TestBaseWithFileSystem.cs b/src/CommonUtilities.Testing/TestBaseWithFileSystem.cs new file mode 100644 index 00000000..192e26b2 --- /dev/null +++ b/src/CommonUtilities.Testing/TestBaseWithFileSystem.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Testably.Abstractions.Testing; + +namespace AnakinRaW.CommonUtilities.Testing; + +/// +/// A test base that automatically registers an as service. +/// +public abstract class TestBaseWithFileSystem : TestBaseWithServiceProvider +{ + /// + /// Gets the file system abstraction used for testing purposes. + /// This property provides access to an instance, which is lazily initialized + /// and can be overridden by derived classes to customize the file system behavior. + /// + /// + /// The file system is initialized using the method. If the initialization + /// fails or returns null, an is thrown. + /// + [field: MaybeNull, AllowNull] + protected IFileSystem FileSystem => LazyInitializer.EnsureInitialized(ref field, CreateFileSystem) + ?? throw new InvalidOperationException("Creation of file system must not return null."); + + /// + /// Initializes a new instance of the class and configures the service provider. + /// + /// + /// This constructor creates a new service collection, invokes the method to allow + /// derived classes to register services, and then builds the service provider. Derived classes should override + /// SetupServices to customize service registration. + /// + protected TestBaseWithFileSystem() + { + } + + /// + /// Creates and returns a new instance of the file system abstraction for testing purposes. + /// + /// + /// This method is invoked to initialize the property. By default, it returns + /// a instance, but derived classes can override this method to provide + /// a custom implementation of . + /// + /// + /// An instance of representing the file system abstraction to be used in tests. + /// + protected virtual IFileSystem CreateFileSystem() + { + return new MockFileSystem(); + } + + + /// + protected override void SetupServices(IServiceCollection serviceCollection) + { + base.SetupServices(serviceCollection); + serviceCollection.AddSingleton(FileSystem); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/TestBaseWithServiceProvider.cs b/src/CommonUtilities.Testing/TestBaseWithServiceProvider.cs new file mode 100644 index 00000000..232d931a --- /dev/null +++ b/src/CommonUtilities.Testing/TestBaseWithServiceProvider.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace AnakinRaW.CommonUtilities.Testing; + +/// +/// Provides a base class for test fixtures that provides and for dependency injection. +/// +/// Derive from this class to set up and access services using dependency injection in test scenarios. +/// Override to register custom services required for your +/// tests. +public abstract class TestBaseWithServiceProvider +{ + /// + /// Provides access to the application's service provider for resolving dependencies within derived classes. + /// + protected readonly IServiceProvider ServiceProvider; + + /// + /// Initializes a new instance of the class and configures the service provider. + /// + /// + /// This constructor creates a new service collection, invokes the method to allow + /// derived classes to register services, and then builds the service provider. Derived classes should override + /// SetupServices to customize service registration. + /// + protected TestBaseWithServiceProvider() + { + var sc = new ServiceCollection(); + // ReSharper disable once VirtualMemberCallInConstructor + SetupServices(sc); + ServiceProvider = sc.BuildServiceProvider(); + } + + /// + /// Configures test services by adding them to the specified . + /// + /// The to which services will be added. + protected virtual void SetupServices(IServiceCollection serviceCollection) + { + } +} \ No newline at end of file diff --git a/src/CommonUtilities.Testing/TestingHelpers.cs b/src/CommonUtilities.Testing/TestingHelpers.cs new file mode 100644 index 00000000..46c05074 --- /dev/null +++ b/src/CommonUtilities.Testing/TestingHelpers.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; + +namespace AnakinRaW.CommonUtilities.Testing; + +/// +/// Provides common helper methods useful creating test code. +/// +public class TestingHelpers +{ + /// + /// Retrieves an embedded resource stream from the specified assembly and path. + /// + /// + /// Embedded resources are expected to be located in the "Resources" folder of the assembly. + /// + /// A from the assembly containing the embedded resource. + /// The relative path of the embedded resource within the assembly. + /// A representing the embedded resource. + /// Thrown when the specified embedded resource cannot be found. + public static Stream GetEmbeddedResource(Type type, string path) + { + var assembly = type.Assembly; + var resourcePath = $"{assembly.GetName().Name}.Resources.{path}"; + return assembly.GetManifestResourceStream(resourcePath) ?? + throw new IOException($"Could not find embedded resource: '{resourcePath}'"); + } + + /// + /// Retrieves an embedded resource as a byte array from the specified assembly and path. + /// + /// + /// Embedded resources are expected to be located in the "Resources" folder of the assembly. + /// + /// A from the assembly containing the embedded resource. + /// The relative path of the embedded resource within the assembly. + /// A byte array containing the content of the embedded resource. + /// Thrown when the specified embedded resource cannot be found. + public static byte[] GetEmbeddedResourceAsByteArray(Type type, string path) + { + using var stream = GetEmbeddedResource(type, path); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + ms.Position = 0; + return ms.ToArray(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/AssertExtensions.cs b/src/CommonUtilities.TestingUtilities/AssertExtensions.cs deleted file mode 100644 index 3ac28e6e..00000000 --- a/src/CommonUtilities.TestingUtilities/AssertExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -namespace AnakinRaW.CommonUtilities.Testing; - -public static class AssertExtensions -{ - private static bool IsNetFramework => RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework"); - - public static void Throws_IgnoreTargetInvocationException(Func action) where T : Exception - { - Throws_IgnoreTargetInvocationException(typeof(T), action); - } - - public static void Throws_IgnoreTargetInvocationException(Type expectedException, Func action) - { - if (expectedException.IsAssignableFrom(typeof(Exception))) - throw new ArgumentException("Type argument must be assignable from System.Exception", nameof(expectedException)); - try - { - action(); - } - catch (TargetInvocationException e) - { - if (e.InnerException?.GetType() != expectedException) - Assert.Fail($"Expected exception of type {expectedException.Name} but got {e.InnerException?.GetType().Name}"); - return; - } - catch (Exception e) - { - if (e.GetType() == expectedException) - return; - Assert.Fail($"Expected exception of type {expectedException.Name} but got {e.GetType().Name}"); - } - Assert.Fail($"Excepted exception of type {expectedException.Name} but non was thrown."); - } - - public static T Throws(string? expectedParamName, Action action) where T : ArgumentException - { - T exception = Assert.Throws(action); - Assert.Equal(expectedParamName, exception.ParamName); - return exception; - } - - public static T Throws(string netCoreParamName, string? netFxParamName, Action action) - where T : ArgumentException - { - var exception = Assert.Throws(action); - - if (netFxParamName == null && IsNetFramework) - { - // Param name varies between .NET Framework versions -- skip checking it - return exception; - } - - var expectedParamName = IsNetFramework ? netFxParamName : netCoreParamName; - - Assert.Equal(expectedParamName, exception.ParamName); - return exception; - } -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/CollectionAsserts.cs b/src/CommonUtilities.TestingUtilities/CollectionAsserts.cs deleted file mode 100644 index 5a426cc9..00000000 --- a/src/CommonUtilities.TestingUtilities/CollectionAsserts.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace AnakinRaW.CommonUtilities.Testing; - -internal class CollectionAsserts -{ - public static void EqualUnordered(ICollection expected, ICollection actual) - { - Assert.Equal(expected == null, actual == null); - if (expected == null) - { - return; - } - - // Lookups are an aggregated collections (enumerable contents), but ordered. - var e = expected.Cast().ToLookup(key => key); - var a = actual!.Cast().ToLookup(key => key); - - // Dictionaries can't handle null keys, which is a possibility - Assert.Equal(e.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count()), - a.Where(kv => kv.Key != null).ToDictionary(g => g.Key, g => g.Count())); - - // Get count of null keys. Returns an empty sequence (and thus a 0 count) if no null key - Assert.Equal(e[null!].Count(), a[null!].Count()); - } -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/IEnumerableTestSuite.cs b/src/CommonUtilities.TestingUtilities/Collections/IEnumerableTestSuite.cs deleted file mode 100644 index 868d1c22..00000000 --- a/src/CommonUtilities.TestingUtilities/Collections/IEnumerableTestSuite.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace AnakinRaW.CommonUtilities.Testing.Collections; - -// This test suite is taken from the .NET runtime repository (https://github.com/dotnet/runtime) and adapted to the VSTesting Framework. -// The .NET Foundation licenses this under the MIT license. -/// -/// Contains tests that ensure the correctness of any class that implements the generic -/// IEnumerable interface. -/// -[SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] -[SuppressMessage("ReSharper", "AccessToDisposedClosure")] -[SuppressMessage("ReSharper", "InconsistentNaming")] -public abstract class IEnumerableTestSuite : INonModifyingEnumerableTestSuite -{ - /// - /// Modifies the given IEnumerable such that any enumerators for that IEnumerable will be - /// invalidated. - /// - /// An IEnumerable to modify - /// true if the enumerable was successfully modified. Else false. - public delegate bool ModifyEnumerable(IEnumerable enumerable); - - /// - /// When calling MoveNext or Reset after modification of the enumeration, the resulting behavior is - /// undefined. Tests are included to cover two behavioral scenarios: - /// - Throwing an InvalidOperationException - /// - Execute MoveNext or Reset. - /// - /// If this property is set to true, the tests ensure that the exception is thrown. The default value is - /// true. - /// - protected virtual bool Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException => true; - - /// - /// When calling MoveNext or Reset after modification of an empty enumeration, the resulting behavior is - /// undefined. Tests are included to cover two behavioral scenarios: - /// - Throwing an InvalidOperationException - /// - Execute MoveNext or Reset. - /// - /// If this property is set to true, the tests ensure that the exception is thrown. The default value is - /// . - /// - protected virtual bool Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException => Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException; - - protected virtual ModifyOperation ModifyEnumeratorThrows => ModifyOperation.Add | ModifyOperation.Insert | ModifyOperation.Overwrite | ModifyOperation.Remove | ModifyOperation.Clear; - - protected virtual ModifyOperation ModifyEnumeratorAllowed => ModifyOperation.None; - - /// - /// To be implemented in the concrete collections test classes. Returns a set of ModifyEnumerable delegates - /// that modify the enumerable passed to them. - /// - protected abstract IEnumerable GetModifyEnumerables(ModifyOperation operations); - - #region Enumerator.MoveNext - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(() => enumerator.MoveNext()); - } - else - { - enumerator.MoveNext(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedBeforeEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - if (Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - enumerator.MoveNext(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - for (var i = 0; i < count / 2; i++) - enumerator.MoveNext(); - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(() => enumerator.MoveNext()); - } - else - { - enumerator.MoveNext(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedDuringEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - for (var i = 0; i < count / 2; i++) - enumerator.MoveNext(); - if (modifyEnumerable(enumerable)) - { - enumerator.MoveNext(); - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - while (enumerator.MoveNext()) - { - } - - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(() => enumerator.MoveNext()); - } - else - { - enumerator.MoveNext(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_ModifiedAfterEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - while (enumerator.MoveNext()) - { - } - - if (modifyEnumerable(enumerable)) - { - enumerator.MoveNext(); - } - } - } - - #endregion - - #region Enumerator.Current - - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_ModifiedDuringEnumeration_UndefinedBehavior(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) - Assert.Throws(() => enumerator.Current); - else - _ = enumerator.Current; - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_ModifiedDuringEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - _ = enumerator.Current; - } - } - } - - #endregion - - #region Enumerator.Reset - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedBeforeEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(enumerator.Reset); - } - else - { - enumerator.Reset(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedBeforeEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (modifyEnumerable(enumerable)) - { - enumerator.Reset(); - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedDuringEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - for (var i = 0; i < count / 2; i++) - enumerator.MoveNext(); - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(enumerator.Reset); - } - else - { - enumerator.Reset(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedDuringEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - for (var i = 0; i < count / 2; i++) - enumerator.MoveNext(); - if (modifyEnumerable(enumerable)) - { - enumerator.Reset(); - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedAfterEnumeration_ThrowsInvalidOperationException(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - while (enumerator.MoveNext()) - { - } - - if (modifyEnumerable(enumerable)) - { - if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException) - { - Assert.Throws(enumerator.Reset); - } - else - { - enumerator.Reset(); - } - } - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_ModifiedAfterEnumeration_Succeeds(int count) - { - foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorAllowed)) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - while (enumerator.MoveNext()) - { - } - - if (modifyEnumerable(enumerable)) - enumerator.Reset(); - } - } - - #endregion -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/INonModifyingEnumerableTestSuite.cs b/src/CommonUtilities.TestingUtilities/Collections/INonModifyingEnumerableTestSuite.cs deleted file mode 100644 index a273bd56..00000000 --- a/src/CommonUtilities.TestingUtilities/Collections/INonModifyingEnumerableTestSuite.cs +++ /dev/null @@ -1,489 +0,0 @@ -using System.Collections; -using System.Diagnostics.CodeAnalysis; - -namespace AnakinRaW.CommonUtilities.Testing.Collections; - - -// This test suite is taken from the .NET runtime repository (https://github.com/dotnet/runtime) and adapted to the VSTesting Framework. -// The .NET Foundation licenses this under the MIT license. - -/// -/// Contains tests that ensure the correctness of any class that implements the generic -/// IEnumerable interface. -/// -[SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] -[SuppressMessage("ReSharper", "AccessToDisposedClosure")] -[SuppressMessage("ReSharper", "InconsistentNaming")] -public abstract class INonModifyingEnumerableTestSuite : CollectionsTestSuite -{ - /// - /// An enum to allow specification of the order of the Enumerable. Used in validation for enumerables. - /// - protected enum EnumerableOrder - { - Unspecified, - Sequential - } - - /// - /// The Reset method is provided for COM interoperability. It does not necessarily need to be - /// implemented; instead, the implementer can simply throw a NotSupportedException. - /// - /// If Reset is not implemented, this property must return False. The default value is true. - /// - protected virtual bool ResetImplemented => true; - - /// Whether the enumerator returned from GetEnumerator is a singleton instance when the collection is empty. - protected virtual bool Enumerator_Empty_UsesSingletonInstance => false; - - /// - /// When calling Current of the enumerator before the first MoveNext, after the end of the collection, - /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included - /// to cover two behavioral scenarios: - /// - Throwing an InvalidOperationException - /// - Returning an undefined value. - /// - /// If this property is set to true, the tests ensure that the exception is thrown. The default value is - /// false. - /// - protected virtual bool Enumerator_Current_UndefinedOperation_Throws => false; - - /// - /// When calling Current of the empty enumerator before the first MoveNext, after the end of the collection, - /// or after modification of the enumeration, the resulting behavior is undefined. Tests are included - /// to cover two behavioral scenarios: - /// - Throwing an InvalidOperationException - /// - Returning an undefined value. - /// - /// If this property is set to true, the tests ensure that the exception is thrown. The default value is - /// . - /// - protected virtual bool Enumerator_Empty_Current_UndefinedOperation_Throws => Enumerator_Current_UndefinedOperation_Throws; - - /// - /// Specifies whether this IEnumerable follows some sort of ordering pattern. - /// - protected virtual EnumerableOrder Order => EnumerableOrder.Sequential; - - /// - /// Creates an instance of an IEnumerable{T} that can be used for testing. - /// - /// The number of unique items that the returned IEnumerable{T} contains. - /// An instance of an IEnumerable{T} that can be used for testing. - protected abstract IEnumerable GenericIEnumerableFactory(int count); - - - private void RepeatTest(Action, T[]> testCode, int iters = 3) - { - RepeatTest((e, i, _) => testCode(e, i), iters); - } - - private void RepeatTest(Action, T[], int> testCode, int iters = 3) - { - var enumerable = GenericIEnumerableFactory(32); - var items = enumerable.ToArray(); - var enumerator = enumerable.GetEnumerator(); - for (var i = 0; i < iters; i++) - { - testCode(enumerator, items, i); - if (!ResetImplemented) - { - enumerator = enumerable.GetEnumerator(); - } - else - { - enumerator.Reset(); - } - } - } - - private void VerifyEnumerator(IEnumerator enumerator, T[] expectedItems) - { - VerifyEnumerator(enumerator, expectedItems, 0, expectedItems.Length, true, true); - } - - private void VerifyEnumerator(IEnumerator enumerator, T[] expectedItems, int startIndex, int count, bool validateStart, bool validateEnd) - { - var needToMatchAllExpectedItems = count - startIndex == expectedItems.Length; - if (validateStart) - { - for (var i = 0; i < 3; i++) - { - if (Enumerator_Current_UndefinedOperation_Throws) - { - Assert.Throws(() => enumerator.Current); - } - else - { - _ = enumerator.Current; - } - } - } - - int iterations; - if (Order == EnumerableOrder.Unspecified) - { - var itemsVisited = new BitArray(needToMatchAllExpectedItems ? count : expectedItems.Length, false); - for (iterations = 0; iterations < count && enumerator.MoveNext(); iterations++) - { - object? currentItem = enumerator.Current; - - var itemFound = false; - for (var i = 0; i < itemsVisited.Length; ++i) - { - if (!itemsVisited[i] && Equals(currentItem, expectedItems[i + (needToMatchAllExpectedItems ? startIndex : 0)])) - { - itemsVisited[i] = true; - itemFound = true; - break; - } - } - - Assert.True(itemFound, "itemFound"); - - for (var i = 0; i < 3; i++) - { - object? tempItem = enumerator.Current; - Assert.Equal(currentItem, tempItem); - } - } - - if (needToMatchAllExpectedItems) - { - for (var i = 0; i < itemsVisited.Length; i++) - { - Assert.True(itemsVisited[i]); - } - } - else - { - var visitedItemCount = 0; - for (var i = 0; i < itemsVisited.Length; i++) - { - if (itemsVisited[i]) - { - ++visitedItemCount; - } - } - - Assert.Equal(count, visitedItemCount); - } - } - else if (Order == EnumerableOrder.Sequential) - { - for (iterations = 0; iterations < count && enumerator.MoveNext(); iterations++) - { - object? currentItem = enumerator.Current; - Assert.Equal(expectedItems[iterations], currentItem); - for (var i = 0; i < 3; i++) - { - object? tempItem = enumerator.Current; - Assert.Equal(currentItem, tempItem); - } - } - } - else - { - throw new ArgumentException( - "EnumerableOrder is invalid."); - } - - Assert.Equal(count, iterations); - - if (validateEnd) - { - for (var i = 0; i < 3; i++) - { - Assert.False(enumerator.MoveNext(), "enumerator.MoveNext() returned true past the expected end."); - - if (Enumerator_Current_UndefinedOperation_Throws) - { - Assert.Throws(() => enumerator.Current); - } - else - { - _ = enumerator.Current; - } - } - } - } - - - #region GetEnumerator() - - [Fact] - public void IEnumerable_NonGeneric_GetEnumerator_EmptyCollection_UsesSingleton() - { - IEnumerable enumerable = GenericIEnumerableFactory(0); - - var enumerator1 = enumerable.GetEnumerator(); - try - { - var enumerator2 = enumerable.GetEnumerator(); - try - { - Assert.Equal(Enumerator_Empty_UsesSingletonInstance, ReferenceEquals(enumerator1, enumerator2)); - } - finally - { - if (enumerator2 is IDisposable d2) d2.Dispose(); - } - } - finally - { - if (enumerator1 is IDisposable d1) d1.Dispose(); - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_GetEnumerator_NoExceptionsWhileGetting(int count) - { - var enumerable = GenericIEnumerableFactory(count); - enumerable.GetEnumerator().Dispose(); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_GetEnumerator_ReturnsUniqueEnumerator(int count) - { - //Tests that the enumerators returned by GetEnumerator operate independently of one another - var enumerable = GenericIEnumerableFactory(count); - var iterations = 0; - foreach (var _ in enumerable) - foreach (var __ in enumerable) - foreach (var ___ in enumerable) - iterations++; - Assert.Equal(count * count * count, iterations); - } - - #endregion - - #region Enumerator.MoveNext - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_FromStartToFinish(int count) - { - var iterations = 0; - using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); - while (enumerator.MoveNext()) - iterations++; - Assert.Equal(count, iterations); - } - - /// - /// For most collections, all calls to MoveNext after disposal of an enumerator will return false. - /// Some collections (SortedList), however, treat a call to dispose as if it were a call to Reset. Since the docs - /// specify neither of these as being strictly correct, we leave the method virtual. - /// - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public virtual void Enumerator_MoveNext_AfterDisposal(int count) - { - var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); - for (var i = 0; i < count; i++) - enumerator.MoveNext(); - enumerator.Dispose(); - Assert.False(enumerator.MoveNext()); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_MoveNext_AfterEndOfCollection(int count) - { - using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); - for (var i = 0; i < count; i++) - enumerator.MoveNext(); - Assert.False(enumerator.MoveNext()); - Assert.False(enumerator.MoveNext()); - } - - [Fact] - public void IEnumerable_Generic_Enumerator_MoveNextHitsAllItems() - { - RepeatTest((enumerator, items) => - { - var iterations = 0; - while (enumerator.MoveNext()) - { - iterations++; - } - Assert.Equal(items.Length, iterations); - }); - } - - [Fact] - public void IEnumerable_Generic_Enumerator_MoveNextFalseAfterEndOfCollection() - { - RepeatTest((enumerator, _) => - { - while (enumerator.MoveNext()) - { - } - - Assert.False(enumerator.MoveNext()); - }); - } - - #endregion - - #region Enumerator.Current - - [Fact] - public void IEnumerable_Generic_Enumerator_Current() - { - // Verify that current returns proper result. - RepeatTest((enumerator, items, iteration) => - { - if (iteration == 1) - { - VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); - } - else - { - VerifyEnumerator(enumerator, items); - } - }); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_ReturnsSameValueOnRepeatedCalls(int count) - { - using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); - while (enumerator.MoveNext()) - { - var current = enumerator.Current; - Assert.Equal(current, enumerator.Current); - Assert.Equal(current, enumerator.Current); - Assert.Equal(current, enumerator.Current); - } - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_ReturnsSameObjectsOnDifferentEnumerators(int count) - { - // Ensures that the elements returned from enumeration are exactly the same collection of - // elements returned from a previous enumeration - var enumerable = GenericIEnumerableFactory(count); -#pragma warning disable CS8714 - var firstValues = new Dictionary(count); - var secondValues = new Dictionary(count); -#pragma warning restore CS8714 - foreach (var item in enumerable) - firstValues[item] = firstValues.ContainsKey(item) ? firstValues[item]++ : 1; - foreach (var item in enumerable) - secondValues[item] = secondValues.ContainsKey(item) ? secondValues[item]++ : 1; - Assert.Equal(firstValues.Count, secondValues.Count); - foreach (var key in firstValues.Keys) - Assert.Equal(firstValues[key], secondValues[key]); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_BeforeFirstMoveNext_UndefinedBehavior(int count) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) - Assert.Throws(() => enumerator.Current); - else - _ = enumerator.Current; - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Current_AfterEndOfEnumerable_UndefinedBehavior(int count) - { - var enumerable = GenericIEnumerableFactory(count); - using var enumerator = enumerable.GetEnumerator(); - while (enumerator.MoveNext()) - { - } - - if (count == 0 ? Enumerator_Empty_Current_UndefinedOperation_Throws : Enumerator_Current_UndefinedOperation_Throws) - Assert.Throws(() => enumerator.Current); - else - _ = enumerator.Current; - } - - #endregion - - #region Enumerator.Reset - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IEnumerable_Generic_Enumerator_Reset_BeforeIteration_Support(int count) - { - using var enumerator = GenericIEnumerableFactory(count).GetEnumerator(); - if (ResetImplemented) - enumerator.Reset(); - else - Assert.Throws(enumerator.Reset); - } - - [Fact] - public void IEnumerable_Generic_Enumerator_Reset() - { - if (!ResetImplemented) - { - RepeatTest((enumerator, _) => - { - Assert.Throws(enumerator.Reset); - }); - RepeatTest((enumerator, items, iter) => - { - if (iter == 1) - { - VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); - for (var i = 0; i < 3; i++) - { - Assert.Throws(enumerator.Reset); - } - - VerifyEnumerator(enumerator, items, items.Length / 2, items.Length - items.Length / 2, false, true); - } - else if (iter == 2) - { - VerifyEnumerator(enumerator, items); - for (var i = 0; i < 3; i++) - { - Assert.Throws(enumerator.Reset); - } - - VerifyEnumerator(enumerator, items, 0, 0, false, true); - } - else - { - VerifyEnumerator(enumerator, items); - } - }); - } - else - { - RepeatTest((enumerator, items, iter) => - { - if (iter == 1) - { - VerifyEnumerator(enumerator, items, 0, items.Length / 2, true, false); - enumerator.Reset(); - enumerator.Reset(); - } - else if (iter == 3) - { - VerifyEnumerator(enumerator, items); - enumerator.Reset(); - enumerator.Reset(); - } - else - { - VerifyEnumerator(enumerator, items); - } - }, 5); - } - } - - #endregion -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyCollectionTestSuite.cs b/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyCollectionTestSuite.cs deleted file mode 100644 index b48018d8..00000000 --- a/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyCollectionTestSuite.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace AnakinRaW.CommonUtilities.Testing.Collections; - -// This test suite is taken from the .NET runtime repository (https://github.com/dotnet/runtime) and adapted to the VSTesting Framework. -// The .NET Foundation licenses this under the MIT license. -/// -/// Contains tests that ensure the correctness of any class that implements the generic -/// interface -/// -[SuppressMessage("ReSharper", "InconsistentNaming")] -public abstract class IReadOnlyCollectionTestSuite : INonModifyingEnumerableTestSuite -{ - /// - /// Creates an instance of an that can be used for testing. - /// - /// An instance of an that can be used for testing. - protected abstract IReadOnlyCollection GenericIReadOnlyCollectionFactory(IEnumerable baseCollection); - - protected override IEnumerable GenericIEnumerableFactory(int count) - { - return GenericIReadOnlyCollectionFactory(count); - } - - /// - /// Creates an instance of an that can be used for testing. - /// - /// The number of unique items that the returned contains. - /// An instance of an that can be used for testing. - protected virtual IReadOnlyCollection GenericIReadOnlyCollectionFactory(int count) - { - var collection = CreateEnumerable(null, count, 0, 0); - return GenericIReadOnlyCollectionFactory(collection); - } - - #region Count - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void ICollection_Generic_Count_Validity(int count) - { - var collection = GenericIReadOnlyCollectionFactory(count); - Assert.Equal(count, collection.Count); - } - - #endregion -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyListTestSuite.cs b/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyListTestSuite.cs deleted file mode 100644 index fb0ef1b1..00000000 --- a/src/CommonUtilities.TestingUtilities/Collections/IReadOnlyListTestSuite.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; - -namespace AnakinRaW.CommonUtilities.Testing.Collections; - -// This test suite is taken from the .NET runtime repository (https://github.com/dotnet/runtime) and adapted to the VSTesting Framework. -// The .NET Foundation licenses this under the MIT license. -/// -/// Contains tests that ensure the correctness of any class that implements the generic -/// interface -/// -[SuppressMessage("ReSharper", "InconsistentNaming")] -public abstract class IReadOnlyListTestSuite : IReadOnlyCollectionTestSuite -{ - protected virtual Type IList_Generic_Item_InvalidIndex_ThrowType => typeof(ArgumentOutOfRangeException); - - /// - /// Creates an instance of an that can be used for testing. - /// - /// An instance of an that can be used for testing. - protected abstract IReadOnlyList GenericIReadOnlyListFactory(IEnumerable baseCollection); - - /// - /// Creates an instance of an that can be used for testing. - /// - /// The number of unique items that the returned contains. - /// An instance of an that can be used for testing. - protected virtual IReadOnlyList GenericIReadOnlyListFactory(int count) - { - var baseCollection = CreateEnumerable(null, count, 0, 0); - return GenericIReadOnlyListFactory(baseCollection); - } - - protected override IReadOnlyCollection GenericIReadOnlyCollectionFactory(IEnumerable baseCollection) - { - return GenericIReadOnlyListFactory(baseCollection); - } - - #region FromEnumerable - - [Theory] - [MemberData(nameof(GetEnumerableTestData))] - #pragma warning disable xUnit1026 - public void From_IEnumerable(int _, int enumerableLength, int __, int numberOfDuplicateElements) - #pragma warning restore xUnit1026 - { - var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); - var list = GenericIReadOnlyListFactory(enumerable); - - var expected = enumerable.ToList(); - - Assert.Equal(enumerableLength, list.Count); - - for (var i = 0; i < enumerableLength; i++) - Assert.Equal(expected[i], list[i]); - } - - #endregion - - #region Item Getter - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IList_Generic_ItemGet_NegativeIndex_ThrowsException(int count) - { - var list = GenericIReadOnlyListFactory(count); - Assert.Throws(IList_Generic_Item_InvalidIndex_ThrowType, () => list[-1]); - Assert.Throws(IList_Generic_Item_InvalidIndex_ThrowType, () => list[int.MinValue]); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IList_Generic_ItemGet_IndexGreaterThanListCount_ThrowsException(int count) - { - var list = GenericIReadOnlyListFactory(count); - Assert.Throws(IList_Generic_Item_InvalidIndex_ThrowType, () => list[count]); - Assert.Throws(IList_Generic_Item_InvalidIndex_ThrowType, () => list[count + 1]); - } - - [Theory] - [MemberData(nameof(ValidCollectionSizes))] - public void IList_Generic_ItemGet_ValidGetWithinListBounds(int count) - { - var list = GenericIReadOnlyListFactory(count); - - foreach (var i in Enumerable.Range(0, count)) - Sink(list[i]); - return; - - [MethodImpl(MethodImplOptions.NoInlining)] - void Sink(T _) { } - } - - #endregion -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/CommonTestBase.cs b/src/CommonUtilities.TestingUtilities/CommonTestBase.cs deleted file mode 100644 index 3bbf416a..00000000 --- a/src/CommonUtilities.TestingUtilities/CommonTestBase.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.IO.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Testably.Abstractions.Testing; - -namespace AnakinRaW.CommonUtilities.Testing; - -public abstract class CommonTestBase -{ - protected readonly IServiceProvider ServiceProvider; - - protected readonly MockFileSystem FileSystem = new (); - - protected CommonTestBase() - { - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(FileSystem); - - // ReSharper disable once VirtualMemberCallInConstructor - SetupServices(serviceCollection); - ServiceProvider = serviceCollection.BuildServiceProvider(); - } - - protected virtual void SetupServices(IServiceCollection serviceCollection) - { - } -} \ No newline at end of file diff --git a/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj b/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj deleted file mode 100644 index f80f3b13..00000000 --- a/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - net10.0;net8.0 - $(TargetFrameworks);net481 - enable - enable - false - false - AnakinRaW.CommonUtilities.Testing - AnakinRaW.CommonUtilities.Testing - AnakinRaW.CommonUtilities.Testing - - - - xUnit2013 - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - diff --git a/src/CommonUtilities.TestingUtilities/ConditionalFactAttribute.cs b/src/CommonUtilities.TestingUtilities/ConditionalFactAttribute.cs deleted file mode 100644 index d8d29089..00000000 --- a/src/CommonUtilities.TestingUtilities/ConditionalFactAttribute.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Runtime.InteropServices; - -namespace AnakinRaW.CommonUtilities.Testing; - -public sealed class PlatformSpecificFactAttribute : FactAttribute -{ - public PlatformSpecificFactAttribute(params TestPlatformIdentifier[] platformIds) - { - var platforms = platformIds.Select(targetPlatform => OSPlatform.Create(Enum.GetName(typeof(TestPlatformIdentifier), targetPlatform)!.ToUpper())); - var platformMatches = platforms.Any(RuntimeInformation.IsOSPlatform); - - if (!platformMatches) - Skip = "Test execution is not supported on the current platform"; - } -} - -public sealed class PlatformSpecificTheoryAttribute : TheoryAttribute -{ - public PlatformSpecificTheoryAttribute(params TestPlatformIdentifier[] platformIds) - { - var platforms = platformIds.Select(targetPlatform => OSPlatform.Create(Enum.GetName(typeof(TestPlatformIdentifier), targetPlatform)!.ToUpper())); - var platformMatches = platforms.Any(RuntimeInformation.IsOSPlatform); - - if (!platformMatches) - Skip = "Test execution is not supported on the current platform"; - } -} - - -[Flags] -public enum TestPlatformIdentifier -{ - Windows = 1, - Linux = 2, -} \ No newline at end of file diff --git a/src/CommonUtilities/src/AwaitExtensions.cs b/src/CommonUtilities/src/AwaitExtensions.cs index 8d4379f3..3bf13b93 100644 --- a/src/CommonUtilities/src/AwaitExtensions.cs +++ b/src/CommonUtilities/src/AwaitExtensions.cs @@ -25,7 +25,6 @@ public static #if !NET async #endif - Task WaitForExitAsync(this Process process, CancellationToken cancellationToken = default) { if (process == null) @@ -48,7 +47,7 @@ Task WaitForExitAsync(this Process process, CancellationToken cancellationToken throw; } - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); try { process.Exited += Handler; diff --git a/src/CommonUtilities/src/Collections/DebugViews.cs b/src/CommonUtilities/src/Collections/DebugViews.cs new file mode 100644 index 00000000..0d05abe0 --- /dev/null +++ b/src/CommonUtilities/src/Collections/DebugViews.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace AnakinRaW.CommonUtilities.Collections; + +internal sealed class IValueListDictionaryDebugView(IReadOnlyValueListDictionary dictionary) + where TKey : notnull +{ + private readonly IReadOnlyValueListDictionary _dict = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public DebugViewValueListDictionaryItem[] Items => + _dict.Select(keyValuePair => new DebugViewValueListDictionaryItem(keyValuePair)) + .ToArray(); +} + +[DebuggerDisplay("{ValueList}", Name = "[{Key}]")] +internal readonly struct DebugViewValueListDictionaryItem(KeyValuePair> keyValue) +{ + [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] + public TKey Key { get; } = keyValue.Key; + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public IReadOnlyList ValueList { get; } = keyValue.Value; +} + + +// From https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/ICollectionDebugView.cs +internal sealed class IReadOnlyCollectionDebugView(IReadOnlyCollection collection) +{ + private readonly IReadOnlyCollection _collection = collection ?? throw new ArgumentNullException(nameof(collection)); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public T[] Items => _collection.ToArray(); +} + +internal sealed class ICollectionDebugView(ICollection collection) +{ + private readonly ICollection _collection = collection ?? throw new ArgumentNullException(nameof(collection)); + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public T[] Items + { + get + { + var items = new T[_collection.Count]; + _collection.CopyTo(items, 0); + return items; + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/EmptyEnumerator.cs b/src/CommonUtilities/src/Collections/EmptyEnumerator.cs new file mode 100644 index 00000000..0d1e999a --- /dev/null +++ b/src/CommonUtilities/src/Collections/EmptyEnumerator.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Collections; + +internal sealed class EmptyEnumerator : IEnumerator +{ + public static readonly EmptyEnumerator Instance = new(); + + public T Current => throw new InvalidOperationException("Enumeration has not started."); + + object? IEnumerator.Current => Current; + + private EmptyEnumerator() { } + + public bool MoveNext() + { + return false; + } + + public void Reset() { } + + public void Dispose() { } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/FrugalList.cs b/src/CommonUtilities/src/Collections/FrugalList.cs index b4e489c1..e0556a4b 100644 --- a/src/CommonUtilities/src/Collections/FrugalList.cs +++ b/src/CommonUtilities/src/Collections/FrugalList.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; namespace AnakinRaW.CommonUtilities.Collections; @@ -54,7 +55,9 @@ namespace AnakinRaW.CommonUtilities.Collections; /// /// /// The type of elements in the list. -public struct FrugalList : IList +[DebuggerTypeProxy(typeof(ICollectionDebugView<>))] +[DebuggerDisplay("Count = {Count}")] +public struct FrugalList : IList, IReadOnlyList { private static readonly EqualityComparer ItemComparer = EqualityComparer.Default; private static readonly EmptyList EmptyDummyList = EmptyList.Instance; @@ -62,13 +65,13 @@ public struct FrugalList : IList private T _firstItem = default!; private List? _tailList; - /// + /// public readonly int Count => _tailList is null ? 0 : 1 + _tailList.Count; /// - public readonly bool IsReadOnly => false; + readonly bool ICollection.IsReadOnly => false; - /// + /// public T this[int index] { readonly get @@ -103,6 +106,9 @@ public FrugalList(T item) /// /// The list whose elements are copied to the new list. /// is . + /// + /// Modifications to will not be reflected to this instance. + /// public FrugalList(IEnumerable collection) { if (collection == null) @@ -112,7 +118,7 @@ public FrugalList(IEnumerable collection) } /// - /// Initializes a new instance of the structure that copies all elements from the given list. + /// Initializes a new instance of the structure that contains elements copied from the specified list. /// /// The list whose elements are copied to the new list. /// @@ -133,9 +139,9 @@ public FrugalList(in FrugalList list) /// Modifications to this instance will not be reflected to the newly create readonly list. /// /// The read-only list. - public readonly ReadOnlyFrugalList AsReadOnly() + public readonly ImmutableFrugalList ToImmutableList() { - return new ReadOnlyFrugalList(in this); + return new ImmutableFrugalList(in this); } /// @@ -326,7 +332,7 @@ public readonly T[] ToArray() public readonly T First() { if (Count == 0) - throw new InvalidOperationException("The list contains no elements"); + throw new InvalidOperationException("The sequence contains no elements"); return _firstItem; } @@ -339,7 +345,7 @@ public readonly T Last() { var count = Count; if (count == 0) - throw new InvalidOperationException("The list contains no elements"); + throw new InvalidOperationException("The sequence contains no elements"); return count switch { 1 => _firstItem, @@ -375,12 +381,40 @@ public readonly T Last() /// /// Returns an enumerator that iterates through the /// - /// A for the . - public FrugalEnumerator GetEnumerator() => new(ref this); + /// A for the . + /// + /// + /// Important: Unlike standard .NET collection enumerators (e.g., ), + /// this enumerator does NOT detect modifications to the source collection and will NOT throw + /// when the collection is modified during enumeration. + /// + /// + /// Instead, the enumerator operates on a snapshot of the taken at the time + /// is called. Because is a , + /// the enumerator stores a copy of the list's state by value. + /// + /// + /// For predictable behavior, avoid modifying a while enumerating it. + /// If you need to modify the list during iteration, consider using a loop with an index, + /// or copy the list first using or . + /// + /// + /// The enumerator does not have exclusive access to the collection; therefore, enumerating through a collection is + /// intrinsically not a thread-safe procedure. To guarantee thread safety during enumeration, you can lock the collection + /// during the entire enumeration. To allow the collection to be accessed by multiple threads for reading and writing, + /// you must implement your own synchronization. + /// + /// + public Enumerator GetEnumerator() => new(ref this); - IEnumerator IEnumerable.GetEnumerator() => new FrugalEnumerator(ref this); + IEnumerator IEnumerable.GetEnumerator() + { + if (Count == 0) + return EmptyEnumerator.Instance; + return GetEnumerator(); + } - IEnumerator IEnumerable.GetEnumerator() => new FrugalEnumerator(ref this); + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); /// /// Private type exists so that we can perform type checking on that type rather than reference checking. @@ -397,14 +431,45 @@ private EmptyList() : base(0) /// /// Enumerates the elements of a . /// - public struct FrugalEnumerator : IEnumerator + /// + /// + /// Important: Unlike standard .NET collection enumerators (e.g., ), + /// this enumerator does NOT detect modifications to the source collection and will NOT throw + /// when the collection is modified during enumeration. + /// + /// + /// Instead, the enumerator operates on a snapshot of the taken at the time + /// is called. Because is a , + /// the enumerator stores a copy of the list's state by value. + /// + /// + /// For predictable behavior, avoid modifying a while enumerating it. + /// If you need to modify the list during iteration, consider using a loop with an index, + /// or copy the list first using or . + /// + /// + /// The enumerator does not have exclusive access to the collection; therefore, enumerating through a collection is + /// intrinsically not a thread-safe procedure. To guarantee thread safety during enumeration, you can lock the collection + /// during the entire enumeration. To allow the collection to be accessed by multiple threads for reading and writing, + /// you must implement your own synchronization. + /// + /// + public struct Enumerator : IEnumerator { private readonly FrugalList _list; private int _position; private T _current; - readonly object IEnumerator.Current => Current!; + readonly object? IEnumerator.Current + { + get + { + if (_position <= 0) + throw new InvalidOperationException(); + return _current; + } + } /// public readonly T Current @@ -414,7 +479,7 @@ public readonly T Current } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal FrugalEnumerator(ref FrugalList list) + internal Enumerator(ref FrugalList list) { _list = list; _position = 0; @@ -425,13 +490,14 @@ internal FrugalEnumerator(ref FrugalList list) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool MoveNext() { - if (_position < _list.Count) + var localList = _list; + if ((uint)_position < (uint)localList.Count) { - _current = _list[_position]; + _current = localList[_position]; ++_position; return true; } - _position = _list.Count + 1; + _position = -1; _current = default!; return false; } @@ -441,7 +507,6 @@ public void Reset() { _current = default!; _position = 0; - } /// diff --git a/src/CommonUtilities/src/Collections/FrugalValueListDictionary.cs b/src/CommonUtilities/src/Collections/FrugalValueListDictionary.cs new file mode 100644 index 00000000..d6439ae3 --- /dev/null +++ b/src/CommonUtilities/src/Collections/FrugalValueListDictionary.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +#if NET6_0_OR_GREATER +using System.Runtime.InteropServices; +#endif + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a specialized dictionary that maps keys to lists of values, optimized for scenarios +/// where the number of values per key is expected to be one. +/// +/// The type of the keys in the dictionary. +/// The type of the values in the lists associated with the keys. +[DebuggerTypeProxy(typeof(IValueListDictionaryDebugView<,>))] +[DebuggerDisplay("Count = {Count}")] +public class FrugalValueListDictionary + : ValueListDictionaryBase>, IFrugalValueListDictionary + where TKey : notnull +{ + /// + public new ImmutableFrugalList this[TKey key] => GetValues(key); + + /// + /// Initializes a new instance of the class + /// that is empty and uses the specified . + /// + public FrugalValueListDictionary() + { + } + + /// + /// Initializes a new instance of the class + /// that is empty and uses the specified + /// + /// + /// The implementation to use when comparing keys, + /// or to use the default for the type of the key. + /// + /// + /// This constructor allows customization of how keys are compared in the dictionary. + /// If no equality comparer is provided, the default comparer for the key type is used. + /// + public FrugalValueListDictionary(IEqualityComparer? equalityComparer) : base(equalityComparer) + { + } + + /// + /// Creates a new instance of the value store specific to the implementation of the dictionary. + /// + /// + /// A new instance to be used as the value store for the dictionary. + /// + protected override FrugalList CreateValueStore() + { + return default; + } + + /// + public new ImmutableFrugalList GetValues(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + return list.ToImmutableList(); + throw new KeyNotFoundException($"The key '{key}' was not found."); + } + + /// + public bool TryGetValues(TKey key, out ImmutableFrugalList values) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + { + values = list.ToImmutableList(); + return true; + } + values = ImmutableFrugalList.Empty; + return false; + } + + /// + /// Creates a snapshot of the specified . + /// + /// The to create a snapshot from. + /// + /// An immutable, read-only list containing the elements of the specified . + /// + protected override IReadOnlyList CreateSnapshot(FrugalList list) + { + return list.ToImmutableList(); + } + + /// + /// Returns an enumerator that iterates through the . + /// + /// An for the . + /// + /// + /// The enumerator returns each key exactly once, paired with a + /// containing all values associated with that key. + /// + /// + /// Enumerators can be used to read the data in the collection, but they cannot be used to modify + /// the underlying collection. + /// + /// + public new Enumerator GetEnumerator() => new(this, Enumerator.Frugal); + + /// + IEnumerator>> IReadOnlyFrugalValueListDictionary.GetEnumerator() + { + if (ValueCount == 0) + return EmptyEnumerator>>.Instance; + return GetEnumerator(); + } + + /// + IEnumerator>> IEnumerable>>.GetEnumerator() + { + if (ValueCount == 0) + return EmptyEnumerator>>.Instance; + return new Enumerator(this, Enumerator.AsReadOnly); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IReadOnlyValueListDictionary)this).GetEnumerator(); + } + + /// + /// Enumerates the elements of a . + /// + /// + /// + /// The enumerator provides a way to iterate through the key-value pairs in the dictionary, where each key is associated + /// with an containing all the values for that key. + /// + /// + /// The enumerator is a value type and does not allocate additional memory during enumeration. It is designed to be + /// efficient for scenarios where performance is critical. + /// + /// + /// Modifying the dictionary while enumerating through it will invalidate the enumerator, and any subsequent operation + /// on the enumerator will throw an . + /// + /// + public new struct Enumerator : + IEnumerator>>, + IEnumerator>> + { + private readonly FrugalValueListDictionary _dictionary; + private readonly int _getEnumeratorRetType; + + internal const int Frugal = 1; + internal const int AsReadOnly = 2; + + private readonly int _version; + private readonly int _count; + private int _index; + + private Entry _currentEntry; + + internal Enumerator(FrugalValueListDictionary dictionary, int getEnumeratorRetType) + { + _dictionary = dictionary; + _getEnumeratorRetType = getEnumeratorRetType; + _count = dictionary.KeyOrderStore.Count; + _index = 0; + _currentEntry = default; + _version = dictionary.Version; + } + + /// + public KeyValuePair> Current => _currentEntry.AsFrugal(); + + KeyValuePair> IEnumerator>>.Current + => _currentEntry.AsReadOnlyList(); + + object IEnumerator.Current + { + get + { + if (_index == 0 || _index == _count + 1) + throw new InvalidOperationException("Enumeration has either not started or has already finished."); + if (_getEnumeratorRetType == AsReadOnly) + return _currentEntry.AsReadOnlyList(); + return _currentEntry.AsFrugal(); + } + } + + /// + public bool MoveNext() + { + if (_version != _dictionary.Version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + if ((uint)_index < (uint)_count) + { + var key = _dictionary.KeyOrderStore[_index]; + +#if NET6_0_OR_GREATER + ref var list = ref CollectionsMarshal.GetValueRefOrNullRef(_dictionary.ValueStore, key); + _currentEntry = new Entry(key, list.ToImmutableList()); +#else + _currentEntry = new Entry(key, _dictionary.ValueStore[key].ToImmutableList()); +#endif + _index++; + return true; + } + + _index = _count + 1; + _currentEntry = default; + return false; + } + + /// + public void Reset() + { + if (_version != _dictionary.Version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + _index = 0; + _currentEntry = default; + } + + /// + public void Dispose() { } + } + + private readonly struct Entry(TKey key, ImmutableFrugalList values) + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public KeyValuePair> AsFrugal() + { + return new KeyValuePair>(key, values); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public KeyValuePair> AsReadOnlyList() + { + return new KeyValuePair>(key, values); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/IFrugalValueListDictionary.cs b/src/CommonUtilities/src/Collections/IFrugalValueListDictionary.cs new file mode 100644 index 00000000..2f448ce6 --- /dev/null +++ b/src/CommonUtilities/src/Collections/IFrugalValueListDictionary.cs @@ -0,0 +1,29 @@ +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a mutable generic collection that maps keys to lists of values, +/// using a memory-efficient representation optimized for one value per key. +/// +/// +/// +/// This interface combines the memory-efficient storage of +/// with the mutation capabilities of . +/// +/// +/// The underlying storage uses , which is optimized for cases where +/// most keys have zero or one value. +/// +/// +/// All methods returning return snapshots. +/// The returned lists are not affected by subsequent modifications to the dictionary. +/// +/// +/// The type of the keys in the dictionary. +/// The type of the values in the lists associated with the keys. +/// +/// +/// +public interface IFrugalValueListDictionary : + IReadOnlyFrugalValueListDictionary, + IValueListDictionary + where TKey : notnull; \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/IReadOnlyFrugalValueListDictionary.cs b/src/CommonUtilities/src/Collections/IReadOnlyFrugalValueListDictionary.cs new file mode 100644 index 00000000..b2033c33 --- /dev/null +++ b/src/CommonUtilities/src/Collections/IReadOnlyFrugalValueListDictionary.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a read-only generic collection that maps keys to lists of values, +/// using a memory-efficient representation optimized for one value per key. +/// +/// +/// +/// This interface extends with +/// methods that return , a value type that provides +/// efficient storage for zero and one value. +/// +/// +/// All methods returning return snapshots. +/// The returned lists are not affected by subsequent modifications to the dictionary. +/// +/// +/// The type of the keys in the dictionary. +/// The type of the values in the lists associated with the keys. +public interface IReadOnlyFrugalValueListDictionary : + IReadOnlyValueListDictionary + where TKey : notnull +{ + /// + /// Gets the values associated with the specified key. + /// + /// The key whose values to get. + /// + /// An containing all values for the specified key. + /// + /// + /// The returned list is a snapshot; subsequent modifications to the dictionary + /// are not reflected in the returned list. + /// + /// is . + /// The key does not exist in the dictionary. + new ImmutableFrugalList this[TKey key] { get; } + + /// + /// Gets the values associated with the specified key. + /// + /// The key whose values to get. + /// + /// An containing all values for the specified key. + /// + /// + /// The returned list is a snapshot; subsequent modifications to the dictionary + /// are not reflected in the returned list. + /// + /// is . + /// The key does not exist in the dictionary. + new ImmutableFrugalList GetValues(TKey key); + + /// + /// Attempts to get the values associated with the specified key. + /// + /// The key whose values to get. + /// + /// When this method returns, contains an of values + /// associated with the specified key, if the key is found; otherwise, an empty list. + /// This parameter is passed uninitialized. + /// + /// + /// if the dictionary contains at least one value with the specified key; + /// otherwise, . + /// + /// + /// The returned list is a snapshot; subsequent modifications to the dictionary + /// are not reflected in the returned list. + /// + /// is . + bool TryGetValues(TKey key, out ImmutableFrugalList values); + + /// + /// Returns an enumerator that iterates through the dictionary. + /// + /// + /// An enumerator that yields key-value pairs where each value is an + /// of all values for that key. + /// + new IEnumerator>> GetEnumerator(); +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/IReadOnlyValueListDictionary.cs b/src/CommonUtilities/src/Collections/IReadOnlyValueListDictionary.cs new file mode 100644 index 00000000..77146eb2 --- /dev/null +++ b/src/CommonUtilities/src/Collections/IReadOnlyValueListDictionary.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a generic read-only collection that maps keys to a list of values. +/// +/// +/// +/// Unlike a standard , this dictionary +/// allows multiple values to be associated with a single key using an +/// as the underlying value type. +/// +/// +/// When enumerating, each key appears exactly once with all its associated values +/// stored to an . +/// +/// +/// The type of keys in the dictionary. +/// The type of the values in the lists associated with the keys. +public interface IReadOnlyValueListDictionary + : IEnumerable>> where TKey : notnull +{ + /// + /// Gets the list of values associated with the specified key. + /// + /// The key whose values to get. + /// + /// An containing all values for the specified key. + /// + /// + /// + /// Whether the returned list is a live view or a snapshot is implementation-defined. + /// Do not rely on the returned list reflecting subsequent modifications to the dictionary. + /// + /// + /// For consistent behavior across implementations, treat the returned list as valid + /// only until the next modification to the dictionary. + /// + /// + /// is . + /// The key does not exist in the dictionary. + IReadOnlyList this[TKey key] { get; } + + /// + /// Gets a collection containing all values in the dictionary. + /// + /// + /// + /// Returns a flattened collection of all values across all keys. + /// If a key has multiple values, each value appears separately in the collection. + /// + /// + /// Values appear in insertion order: first all values for the first key (in the order they were added), + /// then all values for the second key, and so on. + /// + /// + /// The collection count equals , not . + /// Modifications to the returned collection are not reflected in the dictionary. + /// + /// + /// To get values for a specific key without flattening, use . + /// + /// + ICollection Values { get; } + + /// + /// Gets an containing the keys in the dictionary. + /// + /// + /// Modifications to the collection are not reflected in the dictionary. + ///
+ /// The keys in the returned are ordered by their first insertion into the dictionary. + ///
+ ICollection Keys { get; } + + /// + /// Gets the number of distinct keys in the dictionary. + /// + int Count { get; } + + /// + /// Gets the total number of values across all keys in the dictionary. + /// + /// + /// This is the sum of all values for all keys, not the number of distinct keys. + /// Use to get the number of distinct keys. + /// + int ValueCount { get; } + + /// + /// Determines whether the dictionary contains the specified key. + /// + /// The key to locate in the dictionary. + /// if the dictionary contains the key; otherwise, . + /// is . + bool ContainsKey(TKey key); + + /// + /// Gets the list of values associated with the specified key. + /// + /// The key whose values to get. + /// + /// + /// Whether the returned list is a live view or a snapshot is implementation-defined. + /// Do not rely on the returned list reflecting subsequent modifications to the dictionary. + /// + /// + /// For consistent behavior across implementations, treat the returned list as valid + /// only until the next modification to the dictionary. + /// + /// + /// + /// An containing all values for the specified key. + /// + /// is . + /// The key does not exist in the dictionary. + IReadOnlyList GetValues(TKey key); + + /// + /// Gets the last element with the specified key. + /// + /// The key of the element to get. + /// The last element with the specified key. + /// is . + /// The key does not exist in the dictionary. + TValue GetLastValue(TKey key); + + /// + /// Gets the first element with the specified key. + /// + /// The key of the element to get. + /// The first element with the specified key. + /// is . + /// The key does not exist in the dictionary. + TValue GetFirstValue(TKey key); + + /// + /// Gets the first value associated with the specified key. + /// + /// The key whose value to get. + /// + /// When this method returns, the first value associated with the specified key, if the key is found; + /// otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// if the dictionary contains a value with the specified key; otherwise, . + /// is . + bool TryGetFirstValue(TKey key, [MaybeNullWhen(false)] out TValue value); + + /// + /// Gets the last value associated with the specified key. + /// + /// The key whose value to get. + /// + /// When this method returns, the last value associated with the specified key, if the key is found; + /// otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// if the dictionary contains a value with the specified key; otherwise, . + /// is . + bool TryGetLastValue(TKey key, [MaybeNullWhen(false)] out TValue value); + + /// + /// Attempts to get the list of values associated with the specified key. + /// + /// The key whose values to get. + /// + /// When this method returns, contains an of values associated + /// with the specified key, if the key is found; otherwise, an empty read-only list. + /// This parameter is passed uninitialized. + /// + /// + /// + /// Whether the returned list is a live view or a snapshot is implementation-defined. + /// Do not rely on the returned list reflecting subsequent modifications to the dictionary. + /// + /// + /// For consistent behavior across implementations, treat the returned list as valid + /// only until the next modification to the dictionary. + /// + /// + /// + /// if the dictionary contains at least one value with the specified key; + /// otherwise, . + /// + /// is . + bool TryGetValues(TKey key, out IReadOnlyList values); +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/IValueListDictionary.cs b/src/CommonUtilities/src/Collections/IValueListDictionary.cs new file mode 100644 index 00000000..0141d8d5 --- /dev/null +++ b/src/CommonUtilities/src/Collections/IValueListDictionary.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a generic collection that maps keys to a list of values, while maintaining the order of key insertion. +/// +/// The type of the keys in the dictionary. +/// The type of the values in the lists associated with the keys. +public interface IValueListDictionary : IReadOnlyValueListDictionary where TKey : notnull +{ + /// + /// Adds a value to the dictionary under the specified key. + /// + /// + /// Multiple values can be added under the same key. Values are stored in insertion order. + /// + /// The key under which to add the value. + /// The value to add. + /// + /// if a new key is created; + /// otherwise, if the key already exists. + /// + /// is . + bool Add(TKey key, TValue value); + + /// + /// Removes all values associated with the specified key from the dictionary. + /// + /// The key to remove. + /// if the key was found and removed; otherwise, . + /// is . + bool Remove(TKey key); + + /// + /// Removes a specific value associated with the specified key. + /// + /// + /// If this was the last value for the key, the key is also removed from the dictionary. + /// If multiple identical values exist for the key, only the first occurrence is removed. + /// + /// The key whose value to remove. + /// The value to remove. + /// + /// if the value was found and removed; + /// if the key or value was not found. + /// + /// is . + bool Remove(TKey key, TValue value); + + /// + /// Removes all keys and values from the . + /// + void Clear(); + + /// + /// Adds multiple values under the specified key. + /// + /// + /// If is not empty, a new key is created if not already present. + /// Values are appended in enumeration order. + /// + /// The key under which to add the values. + /// The values to add. + /// + /// if a new key is created; + /// otherwise, if the key already exists, or is empty. + /// + /// or is . + bool AddRange(TKey key, IEnumerable values); + + /// + /// Removes all values that match the predicate from the specified key. + /// + /// + /// If all values for the key are removed, the key itself is also removed. + /// + /// The key whose values to filter. + /// The predicate that defines the conditions for removal. + /// The number of values removed. + /// or is . + int RemoveAll(TKey key, Predicate match); +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ImmutableFrugalList.cs b/src/CommonUtilities/src/Collections/ImmutableFrugalList.cs new file mode 100644 index 00000000..44a4d3a6 --- /dev/null +++ b/src/CommonUtilities/src/Collections/ImmutableFrugalList.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Provides an immutable variant of the . +/// +/// The type of elements in the list. +[DebuggerTypeProxy(typeof(IReadOnlyCollectionDebugView<>))] +[DebuggerDisplay("Count = {Count}")] +public readonly struct ImmutableFrugalList : IList, IReadOnlyList +{ + /// + /// Returns an empty that has the specified type argument. + /// + public static readonly ImmutableFrugalList Empty = default; + + private readonly FrugalList _list; + + /// + public int Count => _list.Count; + + /// + public T this[int index] => _list[index]; + + /// + /// Initializes a new instance of the structure with the specified item. + /// + /// The item of the list. + internal ImmutableFrugalList(T item) + { + _list = new FrugalList(item); + } + + /// + /// Initializes a new instance of the structure from a . + /// + /// The items of this list. + /// + /// Modifications to will not be reflected to this instance. + /// + internal ImmutableFrugalList(in FrugalList items) + { + _list = new FrugalList(in items); + } + + /// + public void CopyTo(T[] array, int index) + { + _list.CopyTo(array, index); + } + + #region Explixit IList/ICollection implementations + + /// + /// Gets the element at the specified index. An occurs if you try to set the item at the specified index. + /// + /// + /// Because the collection is immutable, you can only get this item at the specified index. + /// An exception will occur if you try to set the item. This member is an explicit interface member implementation. + /// It can be used only when the instance is cast to an interface. + /// + /// The zero-based index of the element to get. + /// The element at the specified index. + /// The element at the specified index. + T IList.this[int index] + { + get => _list[index]; + set => throw new NotSupportedException("Collection is read-only."); + } + + /// + /// Gets a value indicating whether the is read-only. + /// + /// + /// if the is read-only; otherwise, . + /// In the implementation of , this property always returns . + /// + bool ICollection.IsReadOnly => true; + + /// + /// Adds an item to the . This implementation always throws . + /// + /// The object to add to the . + /// Always thrown. + void ICollection.Add(T item) => throw new NotSupportedException(); + + /// + /// Inserts an item to the at the specified index. + /// This implementation always throws . + /// + /// The zero-based index at which the item should be inserted. + /// The object to insert into the . + /// Always thrown. + void IList.Insert(int index, T item) => throw new NotSupportedException("Collection is read-only."); + + /// + /// Removes the first occurrence of a specific object from the . + /// This implementation always throws . + /// + /// The object to remove from the list. + /// + /// Always throws a because the collection is read-only. + /// + /// Always thrown. + bool ICollection.Remove(T item) => throw new NotSupportedException("Collection is read-only."); + + /// + /// Removes the item at the specified index. + /// This implementation always throws . + /// + /// The zero-based index of the element to remove. + /// Always thrown. + void IList.RemoveAt(int index) => throw new NotSupportedException("Collection is read-only."); + + /// + /// Removes all items from the . + /// This implementation always throws . + /// + /// Always thrown. + void ICollection.Clear() => throw new NotSupportedException("Collection is read-only."); + + #endregion + + #region Linq Re-Implemenations + + // Natively implementing frequent Linq functions avoids boxing. Add more if necessary. + + /// + /// Creates a from the . + /// + /// A that contains elements from the . + public List ToList() + { + return _list.ToList(); + } + + /// + /// Copies the elements of the to a new array. + /// + /// An array containing copies of the elements of the . + public T[] ToArray() + { + return _list.ToArray(); + } + + /// + /// Returns the first element of the . + /// + /// The first element of the specified + /// The is empty. + public T First() + { + return _list.First(); + } + + /// + /// Returns the last element of the . + /// + /// The last element of the specified + /// The is empty. + public T Last() + { + return _list.Last(); + } + + /// + /// Returns the first element of the , or a default value if no element is found. + /// + /// if source is empty; otherwise, the first element in source. + public T? FirstOrDefault() + { + return _list.FirstOrDefault(); + } + + /// + /// Returns the last element of the , or a default value if no element is found. + /// + /// if source is empty; otherwise, the last element in source. + public T? LastOrDefault() + { + return _list.LastOrDefault(); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// if is found in the ; otherwise, . + public bool Contains(T item) + { + return _list.Contains(item); + } + + /// + /// Searches for the specified object and returns the zero-based index of the first occurrence within the entire . + /// + /// The object to locate in the . The value can be for reference types. + /// The zero-based index of the first occurrence of within the entire , if found; otherwise, -1. + public int IndexOf(T item) + { + return _list.IndexOf(item); + } + + #endregion + + /// + /// Returns an enumerator that iterates through the immutable list. + /// + /// An enumerator that can be used to iterate through the immutable list. + public FrugalList.Enumerator GetEnumerator() + { + // ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable + return _list.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// + /// A that can be used to iterate through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() + { + // ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable + return Count == 0 + ? EmptyEnumerator.Instance + : _list.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An object that can be used to iterate through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); +} + +/// +/// Provides static methods for immutable frugal lists. +/// +public static class ImmutableFrugalList +{ + /// + /// Creates a new instance of from the specified collection of items. + /// + /// The type of elements in the list. + /// The collection of items to initialize the list with. + /// + /// An containing the elements from the specified collection. + /// + /// is . + public static ImmutableFrugalList Create(IEnumerable items) + { + if (items is null) + throw new ArgumentNullException(nameof(items)); + + if (items is ImmutableFrugalList immutable) + return immutable; + if (items is FrugalList frugal) + return new ImmutableFrugalList(in frugal); + if (items is ICollection { Count: 0 }) + return ImmutableFrugalList.Empty; + if (items is IList { Count: 1 } list) + return new ImmutableFrugalList(list[0]); + return new FrugalList(items).ToImmutableList(); + } + + /// + /// Creates a new instance of containing a single specified item. + /// + /// The type of the item. + /// The single item to include in the list. + /// An containing the specified item. + public static ImmutableFrugalList Single(T item) + { + return new ImmutableFrugalList(item); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ReadOnlyFrugalList.cs b/src/CommonUtilities/src/Collections/ReadOnlyFrugalList.cs deleted file mode 100644 index 7b5119a7..00000000 --- a/src/CommonUtilities/src/Collections/ReadOnlyFrugalList.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; - -namespace AnakinRaW.CommonUtilities.Collections; - -/// -/// A read-only variant of the . -/// -/// The type of elements in the list. -public readonly struct ReadOnlyFrugalList : IReadOnlyList -{ - /// - /// Returns an empty that has the specified type argument. - /// - public static readonly ReadOnlyFrugalList Empty = default; - - private readonly FrugalList _list; - - /// - public int Count => _list.Count; - - /// - public T this[int index] => _list[index]; - - /// - /// Initializes a new instance of the structure to one item. - /// - /// The item of the list. - public ReadOnlyFrugalList(T item) - { - _list = new FrugalList(item); - } - - /// - /// Initializes a new instance of the structure with the given enumerable. - /// - /// The items of this list. - public ReadOnlyFrugalList(IEnumerable items) - { - _list = new FrugalList(items); - } - - /// - /// Initializes a new instance of the structure from a . - /// - /// The items of this list. - /// - /// Modifications to will not be reflected to this instance. - /// - internal ReadOnlyFrugalList(in FrugalList items) - { - _list = new FrugalList(in items); - } - - - /// - public void CopyTo(T[] array, int index) - { - _list.CopyTo(array, index); - } - - #region Linq Re-Implemenations - - // Natively implementing frequent Linq functions avoids boxing. Add more if necessary. - - /// - /// Creates a from the . - /// - /// A that contains elements from the . - public List ToList() - { - return _list.ToList(); - } - - /// - /// Copies the elements of the to a new array. - /// - /// An array containing copies of the elements of the . - public T[] ToArray() - { - return _list.ToArray(); - } - - /// - /// Returns the first element of the . - /// - /// The first element of the specified - /// The is empty. - public T First() - { - return _list.First(); - } - - /// - /// Returns the last element of the . - /// - /// The last element of the specified - /// The is empty. - public T Last() - { - return _list.Last(); - } - - /// - /// Returns the first element of the , or a default value if no element is found. - /// - /// if source is empty; otherwise, the first element in source. - public T? FirstOrDefault() - { - return _list.FirstOrDefault(); - } - - /// - /// Returns the last element of the , or a default value if no element is found. - /// - /// if source is empty; otherwise, the last element in source. - public T? LastOrDefault() - { - return _list.LastOrDefault(); - } - - /// - /// Determines whether the contains a specific value. - /// - /// The object to locate in the . - /// if is found in the ; otherwise, . - public bool Contains(T item) - { - return _list.Contains(item); - } - - /// - /// Searches for the specified object and returns the zero-based index of the first occurrence within the entire . - /// - /// The object to locate in the . The value can be for reference types. - /// The zero-based index of the first occurrence of within the entire , if found; otherwise, -1. - public int IndexOf(T item) - { - return _list.IndexOf(item); - } - - #endregion - - /// - /// Returns an enumerator that iterates through the - /// - /// A for the . - public FrugalList.FrugalEnumerator GetEnumerator() - { - // ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable - return _list.GetEnumerator(); - } - - /// - IEnumerator IEnumerable.GetEnumerator() - { - // ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable - return _list.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } -} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ReadOnlyFrugalValueListDictionary.cs b/src/CommonUtilities/src/Collections/ReadOnlyFrugalValueListDictionary.cs new file mode 100644 index 00000000..3f20febe --- /dev/null +++ b/src/CommonUtilities/src/Collections/ReadOnlyFrugalValueListDictionary.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a read-only dictionary that maps keys to immutable frugal lists of values. +/// +/// The type of the keys in the dictionary. +/// The type of the values in the dictionary. +[DebuggerTypeProxy(typeof(IValueListDictionaryDebugView<,>))] +[DebuggerDisplay("Count = {Count}")] +public class ReadOnlyFrugalValueListDictionary + : ReadOnlyValueListDictionaryBase, IReadOnlyFrugalValueListDictionary + where TKey : notnull +{ + private readonly IReadOnlyFrugalValueListDictionary _frugalValueList; + + /// Gets an empty . + /// An empty . + /// The returned instance is immutable and will always be empty. + public static ReadOnlyFrugalValueListDictionary Empty { get; } = + new(new FrugalValueListDictionary()); + + /// + public new ImmutableFrugalList this[TKey key] => GetValues(key); + + /// + /// Initializes a new instance of the class + /// that is a wrapper around the specified dictionary. + /// + /// The dictionary to wrap. + /// is . + public ReadOnlyFrugalValueListDictionary(IReadOnlyFrugalValueListDictionary dictionary) + : base(dictionary) + { + _frugalValueList = dictionary; + } + + /// + public new ImmutableFrugalList GetValues(TKey key) + { + return _frugalValueList.GetValues(key); + } + + /// + public bool TryGetValues(TKey key, out ImmutableFrugalList values) + { + return _frugalValueList.TryGetValues(key, out values); + } + + /// + public new IEnumerator>> GetEnumerator() + { + return _frugalValueList.GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionary.cs b/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionary.cs new file mode 100644 index 00000000..dde07364 --- /dev/null +++ b/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionary.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a read-only, generic dictionary that maps keys to a list of values. +/// +/// The type of keys in the dictionary. +/// The type of values in the dictionary. +[DebuggerTypeProxy(typeof(IValueListDictionaryDebugView<,>))] +[DebuggerDisplay("Count = {Count}")] +public class ReadOnlyValueListDictionary : ReadOnlyValueListDictionaryBase + where TKey : notnull +{ + /// Gets an empty . + /// An empty . + /// The returned instance is immutable and will always be empty. + public static ReadOnlyValueListDictionary Empty { get; } = new(new ValueListDictionary()); + + /// + /// Initializes a new instance of the class + /// that is a wrapper around the specified dictionary. + /// + /// The dictionary to wrap. + /// is . + public ReadOnlyValueListDictionary(IReadOnlyValueListDictionary dictionary) : base(dictionary) + { + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionaryBase.cs b/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionaryBase.cs new file mode 100644 index 00000000..797f3654 --- /dev/null +++ b/src/CommonUtilities/src/Collections/ReadOnlyValueListDictionaryBase.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents an abstract base class for a read-only, generic dictionary that maps keys to a list of values. +/// +/// The type of keys in the dictionary. +/// The type of values in the dictionary. +public abstract class ReadOnlyValueListDictionaryBase + : IReadOnlyValueListDictionary + where TKey : notnull +{ + private readonly IReadOnlyValueListDictionary _dictionary; + + /// + public IReadOnlyList this[TKey key] => _dictionary[key]; + + /// + /// Gets a key collection that contains the keys of the dictionary. + /// + public KeyCollection Keys => field ??= new KeyCollection(_dictionary.Keys); + + /// + /// Gets a collection that contains the values in the dictionary. + /// + public ValueCollection Values => field ??= new ValueCollection(_dictionary.Values); + + ICollection IReadOnlyValueListDictionary.Values => Values; + + ICollection IReadOnlyValueListDictionary.Keys => Keys; + + /// + public int ValueCount => _dictionary.ValueCount; + + /// + public int Count => _dictionary.Count; + + /// + /// Initializes a new instance of the class that is a wrapper around the specified value list dictionary. + /// + /// The dictionary to wrap. + /// is . + protected ReadOnlyValueListDictionaryBase(IReadOnlyValueListDictionary dictionary) + { + _dictionary = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + } + + /// + public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); + + /// + public IReadOnlyList GetValues(TKey key) => _dictionary.GetValues(key); + + /// + public TValue GetLastValue(TKey key) => _dictionary.GetLastValue(key); + + /// + public TValue GetFirstValue(TKey key) => _dictionary.GetFirstValue(key); + + /// + public bool TryGetFirstValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetFirstValue(key, out value); + + /// + public bool TryGetLastValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetLastValue(key, out value); + + /// + public bool TryGetValues(TKey key, out IReadOnlyList values) => _dictionary.TryGetValues(key, out values); + + /// + public IEnumerator>> GetEnumerator() => _dictionary.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_dictionary).GetEnumerator(); + } + + /// + /// Represents a read-only collection of the keys of a object. + /// + [DebuggerTypeProxy(typeof(ICollectionDebugView<>))] + [DebuggerDisplay("ValueCount = {Count}")] + public sealed class KeyCollection : ICollection, IReadOnlyCollection + { + private readonly ICollection _collection; + + /// + public int Count => _collection.Count; + + bool ICollection.IsReadOnly => true; + + internal KeyCollection(ICollection collection) + { + _collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + /// + public bool Contains(TKey item) => _collection.Contains(item); + + /// + public void CopyTo(TKey[] array, int arrayIndex) => _collection.CopyTo(array, arrayIndex); + + /// + public IEnumerator GetEnumerator() => _collection.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_collection).GetEnumerator(); + + void ICollection.Add(TKey item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Remove(TKey item) => throw new NotSupportedException(); + } + + /// + /// Represents a read-only collection of the values of a object. + /// + [DebuggerTypeProxy(typeof(ICollectionDebugView<>))] + [DebuggerDisplay("ValueCount = {Count}")] + public sealed class ValueCollection : ICollection, IReadOnlyCollection + { + private readonly ICollection _collection; + + /// + public int Count => _collection.Count; + + bool ICollection.IsReadOnly => true; + + internal ValueCollection(ICollection collection) + { + _collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + bool ICollection.Contains(TValue item) => _collection.Contains(item); + + /// + public void CopyTo(TValue[] array, int arrayIndex) => _collection.CopyTo(array, arrayIndex); + + /// + public IEnumerator GetEnumerator() => _collection.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_collection).GetEnumerator(); + + void ICollection.Add(TValue item) => throw new NotSupportedException(); + + void ICollection.Clear() => throw new NotSupportedException(); + + bool ICollection.Remove(TValue item) => throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/Collections/ValueListDictionary.cs b/src/CommonUtilities/src/Collections/ValueListDictionary.cs new file mode 100644 index 00000000..cf689dca --- /dev/null +++ b/src/CommonUtilities/src/Collections/ValueListDictionary.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Represents a generic dictionary that maps keys to one or more values, +/// while maintaining the order of key insertion. +/// +/// The type of the keys in the dictionary. Keys must be non-nullable. +/// The type of the values in the lists associated with the keys. +/// +/// +/// Unlike a standard , this dictionary allows multiple values +/// to be associated with a single key. Values are stored in the order they were added. +/// +/// +/// A can support multiple readers concurrently, +/// as long as the collection is not modified. Even so, enumerating through a collection is +/// intrinsically not a thread-safe procedure. In the rare case where an enumeration contends +/// with write accesses, the collection must be locked during the entire enumeration. +/// +/// +[DebuggerTypeProxy(typeof(IValueListDictionaryDebugView<,>))] +[DebuggerDisplay("Count = {Count}")] +public class ValueListDictionary + : ValueListDictionaryBase> + where TKey : notnull +{ + /// + /// Initializes a new instance of the class + /// that is empty and uses the default equality comparer for the key type. + /// + public ValueListDictionary() + { + } + + /// + /// Initializes a new instance of the class + /// that is empty and uses the specified + /// + /// + /// The implementation to use when comparing keys, + /// or to use the default for the type of the key. + /// + /// + /// This constructor allows customization of how keys are compared in the dictionary. + /// If no equality comparer is provided, the default comparer for the key type is used. + /// + public ValueListDictionary(IEqualityComparer? equalityComparer) : base(equalityComparer) + { + + } + + /// + /// Creates a new instance of the value store for the dictionary. + /// + /// + /// A new instance of to be used as the value store for the dictionary. + /// + protected override IList CreateValueStore() + { + return new ReadOnlyCachingList(); + } + + /// + /// Creates a snapshot of the specified list, providing a read-only view of its current state. + /// + /// The list from which to create the snapshot. + /// A read-only view of the specified list. + protected override IReadOnlyList CreateSnapshot(IList list) + { + return ((ReadOnlyCachingList)list).GetReadOnlyView(); + } + + /// + /// Invoked after a value list associated with a specific key has been modified. + /// + /// + /// This method invalidates the cached read-only view of . + /// + /// + /// The key associated with the modified value list. + /// + /// + /// The list of values that has been modified. + /// + protected override void OnAfterValueListModified(TKey key, IList list) + { + ((ReadOnlyCachingList)list).InvalidateCache(); + } + + /// + /// Represents a specialized list that supports caching of its read-only view. + /// + /// + /// This class is used internally by to manage + /// the storage of values associated with a key. It provides efficient caching of a read-only + /// view of the list to minimize redundant allocations and improve performance. + /// + internal sealed class ReadOnlyCachingList : List + { + private ReadOnlyCollection? _cachedReadOnly; + + internal ReadOnlyCollection GetReadOnlyView() + { + return _cachedReadOnly ??= new ReadOnlyCollection(this); + } + + internal void InvalidateCache() + { + _cachedReadOnly = null; + } + } +} + diff --git a/src/CommonUtilities/src/Collections/ValueListDictionaryBase.cs b/src/CommonUtilities/src/Collections/ValueListDictionaryBase.cs new file mode 100644 index 00000000..a8429dc2 --- /dev/null +++ b/src/CommonUtilities/src/Collections/ValueListDictionaryBase.cs @@ -0,0 +1,893 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +#if NET6_0_OR_GREATER +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +#endif + +namespace AnakinRaW.CommonUtilities.Collections; + +/// +/// Provides a base class for a generic collection that maps keys to lists of values, +/// while maintaining the order of key insertion. +/// +/// The type of the keys in the dictionary. +/// The type of the values stored in the lists associated with each key. +/// The type of the list used to store values for each key. +/// +/// This class serves as an abstract base for collections that associate keys with multiple values stored in lists. +/// It ensures that keys are unique and maintains the order of value insertion within each list. +/// +public abstract class ValueListDictionaryBase : IValueListDictionary + where TKey : notnull + where TList : IList +{ + private int _version; + + /// + /// Gets the list of Keys present in the + /// with their insertion order. + /// + protected readonly List KeyOrderStore = []; + + /// + /// Gets the dictionary that stores the mutable value lists associated lists mapped to their associated keys. + /// + protected readonly Dictionary ValueStore; + + /// + /// Gets the version of the dictionary that is used for recognizing dictionary modifications. + /// + // ReSharper disable once ConvertToAutoPropertyWhenPossible + // Do not convert to auto-property, so we still can benefit from direct field access when writing the value + protected int Version => _version; + + /// + public IReadOnlyList this[TKey key] => GetValues(key); + + /// + public int ValueCount { get; private set; } + + /// + public int Count => KeyOrderStore.Count; + + /// + /// Gets a collection containing all values in the . + /// + /// + /// A containing all values in the . + /// + /// + /// + /// Returns a flattened collection of all values across all keys. + /// If a key has multiple values, each value appears separately in the collection. + /// + /// + /// Values appear in insertion order: first all values for the first key (in the order they were added), + /// then all values for the second key, and so on. + /// + /// + /// The collection count equals , not . + /// + /// + /// The returned is not a static copy; instead, it + /// refers back to the values in the original . + /// Therefore, changes to the continue to be + /// reflected in the . + /// + /// + /// To get values for a specific key without flattening, use . + /// + /// + public ValueCollection Values => field ??= new ValueCollection(this); + + /// + ICollection IReadOnlyValueListDictionary.Values => Values; + + /// + /// Gets a collection containing the keys in the . + /// + /// + /// A containing the keys in the . + /// + /// + /// + /// The keys in the are returned in the order they were first inserted. + /// + /// + /// The returned is not a static copy; instead, the + /// refers back to the keys in the original . + /// Therefore, changes to the continue to be + /// reflected in the . + /// + /// + public KeyCollection Keys => field ??= new KeyCollection(this); + + ICollection IReadOnlyValueListDictionary.Keys => Keys; + + + /// + /// Initializes a new instance of the class + /// that is empty and uses the default equality comparer for the key type. + /// + protected ValueListDictionaryBase() : this(null) + { + } + + /// + /// Initializes a new instance of the class + /// that is empty and uses the specified . + /// + /// + /// The implementation to use when comparing keys, + /// or to use the default for the type of the key. + /// + protected ValueListDictionaryBase(IEqualityComparer? comparer) + { + ValueStore = new Dictionary(comparer ?? EqualityComparer.Default); + } + + /// + /// Creates a new instance of the value store specific to the derived dictionary implementation. + /// + /// + /// A new instance of , which represents the collection of values + /// associated with a key in the dictionary. + /// + /// + /// This method is abstract and must be implemented by derived classes to provide the specific + /// type of value store used by the dictionary. + /// + protected abstract TList CreateValueStore(); + + /// + /// Creates a snapshot of the specified list, providing a read-only view of its current state. + /// + /// The list from which to create the snapshot. + /// A read-only list containing the current elements of the specified list. + protected abstract IReadOnlyList CreateSnapshot(TList list); + + /// + /// Invoked after a value list associated with a specific key has been modified. + /// + /// The key associated with the modified value list. + /// The value list that has been modified. + protected virtual void OnAfterValueListModified(TKey key, TList list) + { + } + + /// + public bool ContainsKey(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + return ValueStore.ContainsKey(key); + } + + /// + public IReadOnlyList GetValues(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + return CreateSnapshot(list); + throw new KeyNotFoundException($"The key '{key}' was not found."); + } + + /// + public TValue GetLastValue(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) +#if NETSTANDARD2_1_OR_GREATER || NET + return list[^1]; +#else + return list[list.Count - 1]; +#endif + + throw new KeyNotFoundException($"The key '{key}' was not found."); + } + + /// + public TValue GetFirstValue(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + return list[0]; + throw new KeyNotFoundException($"The key '{key}' was not found."); + } + + /// + public bool TryGetFirstValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + { + value = list[0]; + return true; + } + + value = default!; + return false; + } + + /// + public bool TryGetLastValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + { +#if NETSTANDARD2_1_OR_GREATER || NET + value = list[^1]; +#else + value = list[list.Count - 1]; +#endif + return true; + } + + value = default!; + return false; + } + + /// + public bool TryGetValues(TKey key, out IReadOnlyList values) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + { + values = CreateSnapshot(list); + return true; + } + + values = []; + return false; + } + + /// + public bool Add(TKey key, TValue value) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + + ValueCount++; + +#if NET6_0_OR_GREATER + ref var valueList = ref CollectionsMarshal.GetValueRefOrAddDefault(ValueStore, key, out var exists); + if (!exists) + { + valueList = CreateValueStoreInternal(); + KeyOrderStore.Add(key); + _version++; + } + Debug.Assert(valueList is not null); + + valueList.Add(value); + OnAfterValueListModified(key, valueList); + + return !exists; +#else + var exists = ValueStore.TryGetValue(key, out var valueList); + if (!exists) + { + valueList = CreateValueStoreInternal(); + KeyOrderStore.Add(key); + _version++; + } + + valueList!.Add(value); + OnAfterValueListModified(key, valueList); + + if (typeof(TList).IsValueType || !exists) + ValueStore[key] = valueList; + return !exists; +#endif + } + + + private TList CreateValueStoreInternal() + { + return CreateValueStore() ?? throw new InvalidOperationException("value store cannot be null"); + } + + /// + public bool Remove(TKey key) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (ValueStore.TryGetValue(key, out var list)) + { + ValueCount -= list.Count; + ValueStore.Remove(key); + KeyOrderStore.Remove(key); + _version++; + return true; + } + + return false; + } + + /// + public bool Remove(TKey key, TValue value) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + +#if NET6_0_OR_GREATER + ref var list = ref CollectionsMarshal.GetValueRefOrNullRef(ValueStore, key); + if (Unsafe.IsNullRef(ref list)) + return false; + + if (!list.Remove(value)) + return false; + + ValueCount--; + OnAfterValueListModified(key, list); + + if (list.Count == 0) + { + ValueStore.Remove(key); + KeyOrderStore.Remove(key); + _version++; + } + + return true; +#else + if (!ValueStore.TryGetValue(key, out var list)) + return false; + + if (!list.Remove(value)) + return false; + + ValueCount--; + OnAfterValueListModified(key, list); + + if (list.Count == 0) + { + ValueStore.Remove(key); + KeyOrderStore.Remove(key); + _version++; + } + else if (typeof(TList).IsValueType) + { + ValueStore[key] = list; + } + + return true; +#endif + } + + /// + public void Clear() + { + if (KeyOrderStore.Count > 0) + { + KeyOrderStore.Clear(); + ValueStore.Clear(); + ValueCount = 0; + _version++; + } + } + + /// + public bool AddRange(TKey key, IEnumerable values) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (values == null) + throw new ArgumentNullException(nameof(values)); + + using var enumerator = values.GetEnumerator(); + if (!enumerator.MoveNext()) + return false; // Empty collection, nothing to add + +#if NET6_0_OR_GREATER + ref var valueList = ref CollectionsMarshal.GetValueRefOrAddDefault(ValueStore, key, out var exists); + if (!exists) + { + valueList = CreateValueStoreInternal(); + KeyOrderStore.Add(key); + _version++; + } + + Debug.Assert(valueList is not null); + + var countBefore = valueList.Count; + + valueList.Add(enumerator.Current); + while (enumerator.MoveNext()) + { + valueList.Add(enumerator.Current); + } + + var added = valueList.Count - countBefore; + ValueCount += added; + OnAfterValueListModified(key, valueList); + return !exists; +#else + var exists = ValueStore.TryGetValue(key, out var valueList); + if (!exists) + { + valueList = CreateValueStoreInternal(); + KeyOrderStore.Add(key); + _version++; + } + + var countBefore = valueList!.Count; + + valueList.Add(enumerator.Current); + while (enumerator.MoveNext()) + { + valueList.Add(enumerator.Current); + } + + var added = valueList.Count - countBefore; + ValueCount += added; + OnAfterValueListModified(key, valueList); + + if (typeof(TList).IsValueType || !exists) + ValueStore[key] = valueList; + + return !exists; +#endif + } + + /// + public int RemoveAll(TKey key, Predicate match) + { + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (match == null) + throw new ArgumentNullException(nameof(match)); + +#if NET6_0_OR_GREATER + ref var list = ref CollectionsMarshal.GetValueRefOrNullRef(ValueStore, key); + if (Unsafe.IsNullRef(ref list)) + return 0; + + var removed = 0; + for (var i = list.Count - 1; i >= 0; i--) + { + if (match(list[i])) + { + list.RemoveAt(i); + removed++; + } + } + + if (removed > 0) + { + ValueCount -= removed; + OnAfterValueListModified(key, list); + + if (list.Count == 0) + { + ValueStore.Remove(key); + KeyOrderStore.Remove(key); + _version++; + } + } + + return removed; +#else + if (!ValueStore.TryGetValue(key, out var list)) + return 0; + + var removed = 0; + for (var i = list.Count - 1; i >= 0; i--) + { + if (match(list[i])) + { + list.RemoveAt(i); + removed++; + } + } + + if (removed > 0) + { + ValueCount -= removed; + OnAfterValueListModified(key, list); + + if (list.Count == 0) + { + ValueStore.Remove(key); + KeyOrderStore.Remove(key); + _version++; + } + else if (typeof(TList).IsValueType) + { + ValueStore[key] = list; + } + } + + return removed; +#endif + } + + /// + /// Returns an enumerator that iterates through the . + /// + /// An for the . + /// + /// + /// The enumerator returns each key exactly once, paired with a + /// containing all values associated with that key. + /// + /// + /// Enumerators can be used to read the data in the collection, but they cannot be used to modify + /// the underlying collection. + /// + /// + public Enumerator GetEnumerator() => new(this); + + IEnumerator>> IEnumerable>>. + GetEnumerator() => ValueCount == 0 + ? EmptyEnumerator>>.Instance + : GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable>>)this).GetEnumerator(); + + /// + /// Enumerates the elements of a . + /// + /// + /// Each element is a where the key is unique + /// and the value is a containing all values for that key. + /// + public struct Enumerator : IEnumerator>> + { + private readonly ValueListDictionaryBase _dictionary; + private readonly int _version; + private int _index; + private KeyValuePair> _current; + + internal Enumerator(ValueListDictionaryBase dictionary) + { + _dictionary = dictionary; + _version = dictionary.Version; + _index = 0; + _current = default; + } + + /// + public KeyValuePair> Current => _current; + + object IEnumerator.Current + { + get + { + if (_index == 0 || _index == _dictionary.Count + 1) + throw new InvalidOperationException("Enumeration has not started. Call MoveNext."); + return Current; + } + } + + /// + public bool MoveNext() + { + if (_version != _dictionary._version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + var keyOrder = _dictionary.KeyOrderStore; + + if ((uint)_index < (uint)keyOrder.Count) + { + var key = keyOrder[_index++]; + var snapshot = _dictionary.CreateSnapshot(_dictionary.ValueStore[key]); + _current = new KeyValuePair>(key, snapshot); + return true; + } + + _index = keyOrder.Count + 1; + _current = default; + return false; + } + + /// + public void Reset() + { + if (_version != _dictionary._version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + + _index = 0; + _current = default; + } + + /// + public void Dispose() { } + } + + /// + /// Represents the collection of keys in a . + /// + /// + /// + /// The keys in the are returned in the order they were first inserted + /// into the . + /// + /// + /// The is not a static copy; instead, the + /// refers back to the keys in the original . + /// Therefore, changes to the continue to be + /// reflected in the . + /// + /// + [DebuggerTypeProxy(typeof(ICollectionDebugView<>))] + [DebuggerDisplay("ValueCount = {Count}")] + public sealed class KeyCollection : ICollection, IReadOnlyCollection + { + private readonly ValueListDictionaryBase _dictionary; + + internal KeyCollection(ValueListDictionaryBase dictionary) + { + _dictionary = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + } + + /// + /// Gets the number of elements contained in the . + /// + /// The number of elements contained in the . + public int Count => _dictionary.KeyOrderStore.Count; + + /// + /// Gets a value indicating whether the is read-only. + /// + /// Always returns . + public bool IsReadOnly => true; + + /// + /// Determines whether the contains a specific key. + /// + /// The key to locate in the . + /// + /// if is found in the ; + /// otherwise, . + /// + public bool Contains(TKey item) => _dictionary.ContainsKey(item); + + /// + public void CopyTo(TKey[] array, int arrayIndex) + { + if (array == null) + throw new ArgumentNullException(nameof(array)); + if (arrayIndex < 0) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + if (array.Length - arrayIndex < Count) + throw new ArgumentException("Destination array is not long enough."); + + _dictionary.KeyOrderStore.CopyTo(array, arrayIndex); + } + + /// + /// Returns an enumerator that iterates through the . + /// + /// A for the . + public List.Enumerator GetEnumerator() => _dictionary.KeyOrderStore.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => Count == 0 + ? EmptyEnumerator.Instance + : GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + void ICollection.Add(TKey item) => throw new NotSupportedException(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + void ICollection.Clear() => throw new NotSupportedException(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + bool ICollection.Remove(TKey item) => throw new NotSupportedException(); + } + + /// + /// Represents the collection of values in a . + /// + /// + /// + /// The values in the are returned grouped by key, in the order + /// the keys were first inserted into the . + /// Within each key group, values appear in the order they were added. + /// + /// + /// The is not a static copy; instead, the + /// refers back to the values in the original . + /// Therefore, changes to the continue to be + /// reflected in the . + /// + /// + [DebuggerTypeProxy(typeof(ICollectionDebugView<>))] + [DebuggerDisplay("ValueCount = {Count}")] + public sealed class ValueCollection : ICollection, IReadOnlyCollection + { + private readonly ValueListDictionaryBase _dictionary; + + internal ValueCollection(ValueListDictionaryBase dictionary) + { + _dictionary = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + } + + /// + public int Count => _dictionary.ValueCount; + + /// + /// Gets a value indicating whether the is read-only. + /// + /// Always returns . + public bool IsReadOnly => true; + + /// + public bool Contains(TValue item) + { + foreach (var list in _dictionary.ValueStore.Values) + { + if (list.Contains(item)) + return true; + } + return false; + } + + /// + public void CopyTo(TValue[] array, int arrayIndex) + { + if (array == null) + throw new ArgumentNullException(nameof(array)); + if (arrayIndex < 0) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + if (array.Length - arrayIndex < Count) + throw new ArgumentException("Destination array is not long enough."); + + foreach (var value in this) + array[arrayIndex++] = value; + } + + /// + /// Returns an enumerator that iterates through the . + /// + /// + /// An enumerator for the . + /// + public Enumerator GetEnumerator() => new(_dictionary); + + IEnumerator IEnumerable.GetEnumerator() + { + if (Count == 0) + return EmptyEnumerator.Instance; + return new Enumerator(_dictionary); + } + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + void ICollection.Add(TValue item) => throw new NotSupportedException(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + void ICollection.Clear() => throw new NotSupportedException(); + + /// + /// This operation is not supported on a read-only collection. + /// + /// Always thrown. + bool ICollection.Remove(TValue item) => throw new NotSupportedException(); + + /// + /// Enumerates the elements of a . + /// + /// + /// Enumerates the elements of a . + /// + public struct Enumerator : IEnumerator + { + private readonly ValueListDictionaryBase _dictionary; + private readonly int _version; + private TList? _currentList; + private int _keyIndex; + private int _valueIndex; + private TValue _current; + + internal Enumerator(ValueListDictionaryBase dictionary) + { + _dictionary = dictionary; + _currentList = default; + _keyIndex = 0; + _valueIndex = -1; + _current = default!; + _version = dictionary._version; + } + + /// + public TValue Current => _current; + + /// + object? IEnumerator.Current + { + get + { + if (_valueIndex < 0) + throw new InvalidOperationException("Enumeration has either not started or has already finished."); + return _current; + } + } + + /// + public bool MoveNext() + { + if (_version != _dictionary._version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + // Try to advance within current cached list + if (_valueIndex >= 0) + { + _valueIndex++; + if (_valueIndex < _currentList!.Count) + { + _current = _currentList[_valueIndex]; + return true; + } + // Current list exhausted, move to next key + _keyIndex++; + } + + // Find next non-empty list + return MoveToNextKey(); + } + + private bool MoveToNextKey() + { + var keyOrder = _dictionary.KeyOrderStore; + var values = _dictionary.ValueStore; + + if (_keyIndex < keyOrder.Count) + { + var key = keyOrder[_keyIndex]; + _currentList = values[key]; + + Debug.Assert(_currentList.Count > 0); + + _valueIndex = 0; + _current = _currentList[0]; + return true; + } + + _valueIndex = -1; + _current = default!; + return false; + } + + /// + public void Reset() + { + if (_version != _dictionary._version) + throw new InvalidOperationException("Collection was modified; enumeration operation may not execute."); + _currentList = default; + _keyIndex = 0; + _valueIndex = -1; + _current = default!; + } + + /// + public void Dispose() { } + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/src/CommonUtilities.csproj b/src/CommonUtilities/src/CommonUtilities.csproj index b2951cdb..41325ac4 100644 --- a/src/CommonUtilities/src/CommonUtilities.csproj +++ b/src/CommonUtilities/src/CommonUtilities.csproj @@ -1,15 +1,17 @@ - + + CommonUtilities + AnakinRaW.CommonUtilities Provides common classes and utilities for personal use. - netstandard2.0;netstandard2.1;net8.0 + true + netstandard2.0;netstandard2.1;net10.0 AnakinRaW.CommonUtilities AnakinRaW.CommonUtilities - enable - true + en @@ -21,18 +23,25 @@ true + + + + + + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - - - all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/CommonUtilities/src/CompilerServices/CallerArgumentExpressionAttribute.cs b/src/CommonUtilities/src/CompilerServices/CallerArgumentExpressionAttribute.cs index 4d6a90ea..33eb37f8 100644 --- a/src/CommonUtilities/src/CompilerServices/CallerArgumentExpressionAttribute.cs +++ b/src/CommonUtilities/src/CompilerServices/CallerArgumentExpressionAttribute.cs @@ -1,5 +1,7 @@ #if !NET +#pragma warning disable IDE0130 namespace System.Runtime.CompilerServices; +#pragma warning restore IDE0130 [AttributeUsage(AttributeTargets.Parameter)] internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute diff --git a/src/CommonUtilities/src/DisposableObject.cs b/src/CommonUtilities/src/DisposableObject.cs index 70b46ab7..b529b998 100644 --- a/src/CommonUtilities/src/DisposableObject.cs +++ b/src/CommonUtilities/src/DisposableObject.cs @@ -29,7 +29,11 @@ public event EventHandler Disposing ///
public bool IsDisposed { get; private set; } - /// + /// + /// Finalizer for the class. + /// Ensures that unmanaged resources are released when the object is garbage collected, + /// if they have not already been released by calling . + /// ~DisposableObject() { Dispose(false); diff --git a/src/CommonUtilities/src/Extensions/EncodingExtensions.cs b/src/CommonUtilities/src/Extensions/EncodingExtensions.cs index 204917ea..c47ce12f 100644 --- a/src/CommonUtilities/src/Extensions/EncodingExtensions.cs +++ b/src/CommonUtilities/src/Extensions/EncodingExtensions.cs @@ -90,7 +90,7 @@ public static int EncodeString(this Encoding encoding, ReadOnlySpan source if (encoding == null) throw new ArgumentNullException(nameof(encoding)); var numMaxBytes = encoding.GetMaxByteCount(source.Length); - return EncodeString(encoding, source, destination, numMaxBytes); + return encoding.EncodeString(source, destination, numMaxBytes); } /// diff --git a/src/CommonUtilities/src/Hashing/Providers/HashAlgorithmProviderBase.cs b/src/CommonUtilities/src/Hashing/Providers/HashAlgorithmProviderBase.cs index 47aa8a39..72f97275 100644 --- a/src/CommonUtilities/src/Hashing/Providers/HashAlgorithmProviderBase.cs +++ b/src/CommonUtilities/src/Hashing/Providers/HashAlgorithmProviderBase.cs @@ -85,7 +85,6 @@ protected async ValueTask ComputeHashAsyncWithHashAlgorithmLegacy(Stream so } } - protected int ComputeHashWithHashAlgorithmLegacy(Stream source, Span destination) { using var algorithm = CreateHashAlgorithm(); diff --git a/src/CommonUtilities/src/TaskExtensions.cs b/src/CommonUtilities/src/TaskExtensions.cs index e1505e4a..895536d8 100644 --- a/src/CommonUtilities/src/TaskExtensions.cs +++ b/src/CommonUtilities/src/TaskExtensions.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -#if !NET6_0_OR_GREATER +#if !NE10_0_OR_GREATER using System; using System.Runtime.InteropServices; using System.Threading; @@ -20,7 +20,7 @@ public static void Forget(this Task? task) { } -#if !NET6_0_OR_GREATER +#if !NE10_0_OR_GREATER /// /// Gets a that will complete when this completes diff --git a/src/CommonUtilities/test/AwaitExtensionsTests.cs b/src/CommonUtilities/test/AwaitExtensionsTests.cs index c7ba8472..8b4e79b5 100644 --- a/src/CommonUtilities/test/AwaitExtensionsTests.cs +++ b/src/CommonUtilities/test/AwaitExtensionsTests.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Attributes; using Xunit; namespace AnakinRaW.CommonUtilities.Test; @@ -14,7 +14,7 @@ public class AwaitExtensionsTests [Fact] public async Task WaitForExitAsync_NullArgument() { - await Assert.ThrowsAsync(() => AwaitExtensions.WaitForExitAsync(null!)); + await Assert.ThrowsAsync(() => AwaitExtensions.WaitForExitAsync(null!, TestContext.Current.CancellationToken)); } [PlatformSpecificFact(TestPlatformIdentifier.Windows)] @@ -26,7 +26,7 @@ public async Task WaitForExitAsync_ExitCode_Windows() CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, })!; - await AwaitExtensions.WaitForExitAsync(p); + await p.WaitForExitAsync(); Assert.Equal(55, p.ExitCode); } @@ -40,7 +40,7 @@ public void WaitForExitAsync_AlreadyExited_Windows() WindowStyle = ProcessWindowStyle.Hidden, })!; p.WaitForExit(); - var t = AwaitExtensions.WaitForExitAsync(p); + var t = p.WaitForExitAsync(); Assert.True(t.IsCompleted); Assert.Equal(55, p.ExitCode); } @@ -52,26 +52,18 @@ public async Task WaitForExitAsync_UnstartedProcess() var process = new System.Diagnostics.Process(); process.StartInfo.FileName = processName; process.StartInfo.CreateNoWindow = true; - await Assert.ThrowsAsync(() => process.WaitForExitAsync()); + await Assert.ThrowsAsync(() => process.WaitForExitAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task WaitForExitAsync_DoesNotCompleteTillKilled() { - var processStartInfo = new ProcessStartInfo - { - FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "/bin/bash", - Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "/c pause" : "-c read", - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardInput = true, - }; - var p = System.Diagnostics.Process.Start(processStartInfo)!; + var p = System.Diagnostics.Process.Start(CreateBlockingProcessStartInfo())!; var expectedExitCode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? -1 : 128 + 9; // https://stackoverflow.com/a/1041309 try { - var t = AwaitExtensions.WaitForExitAsync(p); + var t = p.WaitForExitAsync(TestContext.Current.CancellationToken); Assert.False(t.IsCompleted); p.Kill(); await t; @@ -95,19 +87,11 @@ public async Task WaitForExitAsync_DoesNotCompleteTillKilled() [Fact] public async Task WaitForExitAsync_Canceled() { - var processStartInfo = new ProcessStartInfo - { - FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "/bin/bash", - Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "/c pause" : "-c read", - CreateNoWindow = true, - UseShellExecute = false, - RedirectStandardInput = true, - }; - var p = System.Diagnostics.Process.Start(processStartInfo)!; + var p = System.Diagnostics.Process.Start(CreateBlockingProcessStartInfo())!; try { var cts = new CancellationTokenSource(); - var t = AwaitExtensions.WaitForExitAsync(p, cts.Token); + var t = p.WaitForExitAsync(cts.Token); Assert.False(t.IsCompleted); cts.Cancel(); await Assert.ThrowsAsync(() => t); @@ -117,4 +101,26 @@ public async Task WaitForExitAsync_Canceled() p.Kill(); } } + + private static ProcessStartInfo CreateBlockingProcessStartInfo() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/c ping -n 300 127.0.0.1 > nul", + CreateNoWindow = true, + UseShellExecute = false, + }; + } + + return new ProcessStartInfo + { + FileName = "sleep", + Arguments = "300", + CreateNoWindow = true, + UseShellExecute = false, + }; + } } \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/DebugViewTest.cs b/src/CommonUtilities/test/Collections/DebugViewTest.cs new file mode 100644 index 00000000..92afaac9 --- /dev/null +++ b/src/CommonUtilities/test/Collections/DebugViewTest.cs @@ -0,0 +1,337 @@ +using AnakinRaW.CommonUtilities.Collections; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections; + +// From https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/Collections/DebugView.Tests.cs +// and https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/System/Diagnostics/DebuggerAttributes.cs + +public class DebugViewTests +{ + public static IEnumerable TestDebuggerAttributes_ValueListDictionaryInput() + { + yield return [new ValueListDictionary(), Array.Empty>()]; + yield return [new FrugalValueListDictionary(), Array.Empty>()]; + yield return [new ReadOnlyValueListDictionary(new ValueListDictionary()), Array.Empty>()]; + yield return [new ReadOnlyFrugalValueListDictionary(new FrugalValueListDictionary()), Array.Empty>()]; + + yield return + [ + new ValueListDictionary{{1, "One"}, {2, "Two"}, {1, " Three"}}, + new KeyValuePair[] + { + new ("[1]", "Count = 2"), + new ("[2]", "Count = 1"), + } + ]; + yield return + [ + new FrugalValueListDictionary{{1, "One"}, {2, "Two"}, {1, " Three"}}, + new KeyValuePair[] + { + new ("[1]", "Count = 2"), + new ("[2]", "Count = 1"), + } + ]; + yield return + [ + new ReadOnlyValueListDictionary(new ValueListDictionary{{1, "One"}, {2, "Two"}, {1, " Three"}}), + new KeyValuePair[] + { + new ("[1]", "Count = 2"), + new ("[2]", "Count = 1"), + } + ]; + yield return + [ + new ReadOnlyFrugalValueListDictionary(new FrugalValueListDictionary{{1, "One"}, {2, "Two"}, {1, " Three"}}), + new KeyValuePair[] + { + new ("[1]", "Count = 2"), + new ("[2]", "Count = 1"), + } + ]; + } + + public static IEnumerable TestDebuggerAttributes_FrugalListsInput() + { + yield return [new FrugalList()]; + yield return [new FrugalList { 1, 2 }]; + + yield return [new ImmutableFrugalList()]; + yield return [new ImmutableFrugalList([1,2])]; + } + + public static IEnumerable TestDebuggerAttributes_Inputs() + { + return TestDebuggerAttributes_ValueListDictionaryInput() + .Select(t => new[] { t[0] }) + .Concat(TestDebuggerAttributes_FrugalListsInput()); + } + + [Theory] + [MemberData(nameof(TestDebuggerAttributes_ValueListDictionaryInput))] + public static void TestDebuggerAttributes_ValueListDictionary(IReadOnlyValueListDictionary obj, KeyValuePair[] expected) + { + DebuggerAttributes.ValidateDebuggerDisplayReferences(obj); + var info = DebuggerAttributes.ValidateDebuggerTypeProxyProperties(obj); + var itemProperty = info.Properties.Single(pr => pr.GetCustomAttribute()?.State == DebuggerBrowsableState.RootHidden); + var items = (DebugViewValueListDictionaryItem[])itemProperty.GetValue(info.Instance)!; + var formatted = items + .Select(DebuggerAttributes.ValidateDebugViewValueListDictionaryItem) + .Select(formattedResult => new KeyValuePair(formattedResult.Key, formattedResult.Value)) + .ToList(); + Assert.Equal(expected, formatted); + } + + [Theory] + [MemberData(nameof(TestDebuggerAttributes_FrugalListsInput))] + public static void TestDebuggerAttributes_FrugalList(IEnumerable obj) + { + DebuggerAttributes.ValidateDebuggerDisplayReferences(obj); + var info = DebuggerAttributes.ValidateDebuggerTypeProxyProperties(obj); + var itemProperty = info.Properties.Single(pr => pr.GetCustomAttribute()?.State == DebuggerBrowsableState.RootHidden); + var items = itemProperty.GetValue(info.Instance) as int[]; + Assert.Equal(obj, items); + } + + [Theory] + [MemberData(nameof(TestDebuggerAttributes_Inputs))] + public static void TestDebuggerAttributes_Null(object obj) + { + var tie = Assert.Throws(() => DebuggerAttributes.CreateDebuggerTypeProxyWithNullArgument(obj.GetType())); + Assert.IsType(tie.InnerException); + } + + internal static class DebuggerAttributes + { + internal class DebuggerAttributeInfo + { + public required object Instance { get; init; } + public required IEnumerable Properties { get; init; } + } + + internal class DebuggerDisplayResult + { + public required string Value { get; init; } + public required string Key { get; init; } + public required string Type { get; init; } + } + + internal static Type GetProxyType(object obj) + { + return GetProxyType(obj.GetType()); + } + + internal static Type GetProxyType(Type type) + { + var cad = FindAttribute(type, attributeType: typeof(DebuggerTypeProxyAttribute)); + + var proxyType = cad.ConstructorArguments[0].ArgumentType == typeof(Type) + ? (Type)cad.ConstructorArguments[0].Value! + : Type.GetType((string)cad.ConstructorArguments[0].Value!)!; + if (type.GenericTypeArguments.Length > 0) + { + proxyType = proxyType.MakeGenericType(type.GenericTypeArguments); + } + + return proxyType; + } + + internal static void CreateDebuggerTypeProxyWithNullArgument(Type type) + { + var proxyType = GetProxyType(type); + Activator.CreateInstance(proxyType, [null]); + } + + internal static string ValidateDebuggerDisplayReferences(object obj) + { + var cad = FindAttribute(obj.GetType(), attributeType: typeof(DebuggerDisplayAttribute)); + + // Get the text of the DebuggerDisplayAttribute + var attrText = (string)cad.ConstructorArguments[0].Value!; + + return EvaluateDisplayString(attrText, obj); + } + + internal static DebuggerAttributeInfo ValidateDebuggerTypeProxyProperties(object obj) + { + var proxyType = GetProxyType(obj); + + // Create an instance of the proxy type, and make sure we can access all of the instance properties + // on the type without exception + var proxyInstance = Activator.CreateInstance(proxyType, obj) ?? throw new InvalidOperationException(); + var properties = GetDebuggerVisibleProperties(proxyType); + return new DebuggerAttributeInfo + { + Instance = proxyInstance, + Properties = properties + }; + } + + internal static DebuggerBrowsableState? GetDebuggerBrowsableState(MemberInfo info) + { + var debuggerBrowsableAttribute = info.CustomAttributes + .SingleOrDefault(a => a.AttributeType == typeof(DebuggerBrowsableAttribute)); + // Enums in attribute constructors are boxed as ints, so cast to int? first. + return (DebuggerBrowsableState?)(int?)debuggerBrowsableAttribute?.ConstructorArguments.Single().Value; + } + + internal static IEnumerable GetDebuggerVisibleProperties(Type debuggerAttributeType) + { + // The debugger doesn't evaluate non-public members of type proxies. GetGetMethod returns null if the getter is non-public. + var visibleProperties = debuggerAttributeType.GetProperties() + .Where(pi => pi.GetGetMethod() != null && GetDebuggerBrowsableState(pi) != DebuggerBrowsableState.Never); + return visibleProperties; + } + + internal static DebuggerDisplayResult ValidateDebugViewValueListDictionaryItem(DebugViewValueListDictionaryItem obj) + { + var cad = FindAttribute(obj.GetType(), attributeType: typeof(DebuggerDisplayAttribute)); + + // Get the text of the DebuggerDisplayAttribute + var formattedValue = ValidateDebuggerDisplayReferences(obj.ValueList); + + var formattedKey = FormatDebuggerDisplayNamedArgument(nameof(DebuggerDisplayAttribute.Name), cad, obj); + var formattedType = FormatDebuggerDisplayNamedArgument(nameof(DebuggerDisplayAttribute.Type), cad, obj); + + + return new DebuggerDisplayResult { Value = formattedValue, Key = formattedKey, Type = formattedType }; + } + + private static string FormatDebuggerDisplayNamedArgument(string argumentName, CustomAttributeData debuggerDisplayAttributeData, object obj) + { + var namedAttribute = debuggerDisplayAttributeData.NamedArguments!.FirstOrDefault(na => na.MemberName == argumentName); + if (namedAttribute != default) + { + var value = (string?)namedAttribute.TypedValue.Value; + if (!string.IsNullOrEmpty(value)) + return EvaluateDisplayString(value!, obj); + } + return ""; + } + + + private static CustomAttributeData FindAttribute(Type type, Type attributeType) + { + for (var t = type; t != null; t = t.BaseType) + { + var attributes = t.GetTypeInfo().CustomAttributes + .Where(a => a.AttributeType == attributeType) + .ToArray(); + if (attributes.Length != 0) + return attributes.Length > 1 + ? throw new InvalidOperationException($"Expected one {attributeType.Name} on {type} but found more.") + : attributes[0]; + } + throw new InvalidOperationException($"Expected one {attributeType.Name} on {type}."); + } + + private static string EvaluateDisplayString(string displayString, object obj) + { + var objType = obj.GetType(); + var segments = displayString.Split('{', '}'); + + if (segments.Length % 2 == 0) + throw new InvalidOperationException($"The DebuggerDisplayAttribute for {objType} lacks a closing brace."); + + if (segments.Length == 1) + throw new InvalidOperationException($"The DebuggerDisplayAttribute for {objType} doesn't reference any expressions."); + + var sb = new StringBuilder(); + + for (var i = 0; i < segments.Length; i += 2) + { + var literal = segments[i]; + sb.Append(literal); + + if (i + 1 < segments.Length) + { + var reference = segments[i + 1]; + var noQuotes = reference.EndsWith(",nq"); + + reference = reference.Replace(",nq", string.Empty); + + // Evaluate the reference. + if (!TryEvaluateReference(obj, reference, out var member)) + throw new InvalidOperationException($"The DebuggerDisplayAttribute for {objType} contains the expression \"{reference}\"."); + + var memberString = GetDebuggerMemberString(member, noQuotes); + + sb.Append(memberString); + } + } + + return sb.ToString(); + } + + private static string GetDebuggerMemberString(object? member, bool noQuotes) + { + var memberString = "null"; + if (member != null) + { + memberString = member.ToString(); + if (member is string) + { + if (!noQuotes) + memberString = '"' + memberString + '"'; + } + else if (!IsPrimitiveType(member)) + memberString = '{' + memberString + '}'; + } + + return memberString!; + } + + private static bool IsPrimitiveType(object obj) => + obj is byte or sbyte or short or ushort or int or uint or long or ulong or float or double; + + private static bool TryEvaluateReference(object obj, string reference, out object? member) + { + var pi = GetProperty(obj, reference); + if (pi != null) + { + member = pi.GetValue(obj); + return true; + } + + var fi = GetField(obj, reference); + if (fi != null) + { + member = fi.GetValue(obj); + return true; + } + + member = null; + return false; + } + + private static FieldInfo? GetField(object obj, string fieldName) + { + for (var t = obj.GetType(); t != null; t = t.GetTypeInfo().BaseType) + { + var fi = t.GetTypeInfo().GetDeclaredField(fieldName); + if (fi != null) + return fi; + } + return null; + } + + private static PropertyInfo? GetProperty(object obj, string propertyName) + { + for (var t = obj.GetType(); t != null; t = t.GetTypeInfo().BaseType) + { + var pi = t.GetTypeInfo().GetDeclaredProperty(propertyName); + if (pi != null) + return pi; + } + return null; + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/FrugalList/FrugalListTest.cs b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTest.cs new file mode 100644 index 00000000..9a69bb79 --- /dev/null +++ b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTest.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +// ReSharper disable InconsistentNaming + +namespace AnakinRaW.CommonUtilities.Test.Collections.FrugalList; + +public class FrugalListTest_String : FrugalListTestBase +{ + protected override string CreateT(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes = new byte[stringLength]; + rand.NextBytes(bytes); + return Convert.ToBase64String(bytes); + } +} + +public class List_Generic_Tests_string_Immutable : FrugalListTest_String +{ + protected override bool IsReadOnly => true; + + protected override IList GenericIListFactory(int setLength) + { + return GenericFrugalListFactory(setLength).ToImmutableList(); + } + + protected override IList GenericIListFactory() + { + return GenericFrugalListFactory().ToImmutableList(); + } + + protected override IEnumerable GetModifyEnumerables(ModifyOperation operations) + { + return new List(); + } +} + +public class FrugalListTest_Int : FrugalListTestBase +{ + protected override int CreateT(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } +} + +public class List_Generic_Tests_int_Immutable : FrugalListTest_Int +{ + protected override bool IsReadOnly => true; + + protected override IList GenericIListFactory(int setLength) + { + return GenericFrugalListFactory(setLength).ToImmutableList(); + } + + protected override IList GenericIListFactory() + { + return GenericFrugalListFactory().ToImmutableList(); + } + + protected override IEnumerable GetModifyEnumerables(ModifyOperation operations) + { + return new List(); + } +} + diff --git a/src/CommonUtilities/test/Collections/FrugalListTestBase.cs b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTestBase.cs similarity index 91% rename from src/CommonUtilities/test/Collections/FrugalListTestBase.cs rename to src/CommonUtilities/test/Collections/FrugalList/FrugalListTestBase.cs index c58981f4..bdd7ae9a 100644 --- a/src/CommonUtilities/test/Collections/FrugalListTestBase.cs +++ b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTestBase.cs @@ -2,19 +2,18 @@ using System.Collections.Generic; using System.Linq; using AnakinRaW.CommonUtilities.Collections; -using AnakinRaW.CommonUtilities.Testing.Collections; using Xunit; -namespace AnakinRaW.CommonUtilities.Test.Collections; - -#pragma warning disable xUnit2013 +namespace AnakinRaW.CommonUtilities.Test.Collections.FrugalList; /// /// Contains tests that ensure the correctness of the class. /// -public abstract class FrugalListTestBase : IListTestSuite +public abstract class FrugalListTestBase : FrugalListTestSuite { protected override bool Enumerator_ModifiedDuringEnumeration_ThrowsInvalidOperationException => false; + + protected override bool Enumerator_Empty_UsesSingletonInstance => true; protected override IList GenericIListFactory() { @@ -26,12 +25,12 @@ protected override IList GenericIListFactory(int count) return GenericFrugalListFactory(count); } - private static FrugalList GenericFrugalListFactory() + protected static FrugalList GenericFrugalListFactory() { return []; } - private FrugalList GenericFrugalListFactory(int count) + protected FrugalList GenericFrugalListFactory(int count) { var toCreateFrom = CreateEnumerable(null, count, 0, 0); return new FrugalList(toCreateFrom); @@ -43,26 +42,34 @@ private FrugalList GenericFrugalListFactory(int count) public void Struct_Default() { var list = default(FrugalList); +#pragma warning disable xUnit2013 Assert.Equal(0, list.Count); - Assert.False(list.IsReadOnly); +#pragma warning restore xUnit2013 + Assert.False(((IList)list).IsReadOnly); } [Fact] public void Constructor_Empty() { + // ReSharper disable once CollectionNeverUpdated.Local var list = new FrugalList(); +#pragma warning disable xUnit2013 Assert.Equal(0, list.Count); - Assert.False(list.IsReadOnly); +#pragma warning restore xUnit2013 + Assert.False(((IList)list).IsReadOnly); } [Fact] public void Constructor_Single() { var t = CreateT(0); + // ReSharper disable once CollectionNeverUpdated.Local var list = new FrugalList(t); +#pragma warning disable xUnit2013 Assert.Equal(1, list.Count); +#pragma warning restore xUnit2013 Assert.Equal(t, list[0]); - Assert.False(list.IsReadOnly); + Assert.False(((IList)list).IsReadOnly); } [Theory] @@ -88,16 +95,15 @@ public void Constructor_OtherFrugalList_Creates_Copy(int count) public void Constructor_IEnumerable(int _, int enumerableLength, int __, int numberOfDuplicateElements) #pragma warning restore xUnit1026 { - var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); + var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements).ToList(); var list = new FrugalList(enumerable); - var expected = enumerable.ToList(); Assert.Equal(enumerableLength, list.Count); //"Number of items in list do not match the number of items given." for (var i = 0; i < enumerableLength; i++) - Assert.Equal(expected[i], list[i]); //"Expected object in item array to be the same as in the list" + Assert.Equal(enumerable[i], list[i]); //"Expected object in item array to be the same as in the list" - Assert.False(list.IsReadOnly); //"List should not be readonly" + Assert.False(((IList)list).IsReadOnly); //"List should not be readonly" } [Theory] @@ -108,7 +114,7 @@ public void Constructor_IEnumerable_Creates_Copy(int _, int enumerableLength, in { foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) { - var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); + var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements).ToList(); var list = new FrugalList(enumerable); if (modifyEnumerable(enumerable)) @@ -130,6 +136,7 @@ public void Constructor_NullIEnumerable_ThrowsArgumentNullException() [MemberData(nameof(ValidCollectionSizes))] public void Boxing_ReflectsAllChanges(int count) { + // ReSharper disable PossibleMultipleEnumeration foreach (var modifyEnumerable in GetModifyEnumerables(ModifyEnumeratorThrows)) { var source = GenericIEnumerableFactory(count); @@ -138,6 +145,7 @@ public void Boxing_ReflectsAllChanges(int count) if (modifyEnumerable(source)) Assert.Equal(source.ToList(), copy.ToList()); } + // ReSharper restore PossibleMultipleEnumeration } [Theory] @@ -263,9 +271,9 @@ public void CopyByValue_SideEffects_OverrideLast(int count) [MemberData(nameof(ValidCollectionSizes))] public void ToList(int count) { - var enumerable = CreateEnumerable(null, count, 0, 0); + var enumerable = CreateEnumerable(null, count, 0, 0).ToList(); var list = new FrugalList(enumerable); - Assert.Equal(enumerable.ToList(), list.ToList()); + Assert.Equal(enumerable, list.ToList()); } @@ -326,7 +334,7 @@ public void ToArray(int count) public void GetEnumerator(int _, int enumerableLength, int __, int numberOfDuplicateElements) #pragma warning restore xUnit1026 { - var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); + var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements).ToList(); var list = new FrugalList(enumerable); var actualList = new List(); @@ -335,7 +343,7 @@ public void GetEnumerator(int _, int enumerableLength, int __, int numberOfDupli while (enumerator.MoveNext()) actualList.Add(enumerator.Current); - Assert.Equal(enumerable.ToList(), actualList); + Assert.Equal(enumerable, actualList); } #endregion diff --git a/src/CommonUtilities/test/Collections/FrugalList/FrugalListTestSuite.cs b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTestSuite.cs new file mode 100644 index 00000000..bdb7ee20 --- /dev/null +++ b/src/CommonUtilities/test/Collections/FrugalList/FrugalListTestSuite.cs @@ -0,0 +1,11 @@ +using AnakinRaW.CommonUtilities.Testing.Collections; + +namespace AnakinRaW.CommonUtilities.Test.Collections.FrugalList; + +public abstract class FrugalListTestSuite : IListTestSuite +{ + protected override bool Enumerator_Empty_Current_UndefinedOperation_Throws => true; + protected override bool NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw => true; + protected override bool NonGenericEnumerator_Current_UndefinedOperation_Throws => true; + protected override bool Enumerator_Empty_UsesSingletonInstance => true; +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ReadOnlyFrugalListTestBase.cs b/src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTestBase.cs similarity index 54% rename from src/CommonUtilities/test/Collections/ReadOnlyFrugalListTestBase.cs rename to src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTestBase.cs index 53313a0c..5bce5d95 100644 --- a/src/CommonUtilities/test/Collections/ReadOnlyFrugalListTestBase.cs +++ b/src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTestBase.cs @@ -1,70 +1,100 @@ -using System; +using AnakinRaW.CommonUtilities.Collections; +using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using AnakinRaW.CommonUtilities.Collections; -using AnakinRaW.CommonUtilities.Testing.Collections; using Xunit; -namespace AnakinRaW.CommonUtilities.Test.Collections; - -#pragma warning disable xUnit2013 +namespace AnakinRaW.CommonUtilities.Test.Collections.FrugalList; /// -/// Contains tests that ensure the correctness of the class. +/// Contains tests that ensure the correctness of the class. /// -[SuppressMessage("ReSharper", "InconsistentNaming")] -public abstract class ReadOnlyFrugalListTestBase : IReadOnlyListTestSuite +public abstract class ImmutableFrugalListTestBase : FrugalListTestSuite { - protected virtual Type ICollection_Generic_CopyTo_IndexLargerThanArrayCount_ThrowType => typeof(ArgumentException); + protected override bool IsReadOnly => true; - protected virtual ReadOnlyFrugalList GenericReadOnlyListFrugalListFactory(IEnumerable enumerable) + /// + protected override IEnumerable GetModifyEnumerables(ModifyOperation operations) { - return new ReadOnlyFrugalList(enumerable); + yield break; } - protected virtual ReadOnlyFrugalList GenericReadOnlyListFrugalListFactory(int count) + protected virtual ImmutableFrugalList GenericReadOnlyListFrugalListFactory(IEnumerable enumerable) + { + return ImmutableFrugalList.Create(enumerable); + } + + protected virtual ImmutableFrugalList GenericReadOnlyListFrugalListFactory(int count) { var baseCollection = CreateEnumerable(null, count, 0, 0); return GenericReadOnlyListFrugalListFactory(baseCollection); } - protected override IReadOnlyList GenericIReadOnlyListFactory(IEnumerable enumerable) + protected override IList GenericIListFactory() + { + return GenericReadOnlyListFrugalListFactory(0); + } + + protected override IList GenericIListFactory(int count) + { + return GenericReadOnlyListFrugalListFactory(count); + } + + #region ICollection{T}.IsReadOnly + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IsReadOnly_ReturnsTrue(int count) { - return GenericReadOnlyListFrugalListFactory(enumerable); + ICollection list = GenericReadOnlyListFrugalListFactory(count); + Assert.True(list.IsReadOnly); } + #endregion + #region Empty [Fact] public void Empty_Idempotent() { #pragma warning disable xUnit2002 - Assert.NotNull(ReadOnlyFrugalList.Empty); + Assert.NotNull(ImmutableFrugalList.Empty); #pragma warning restore xUnit2002 - Assert.Equal(0, ReadOnlyFrugalList.Empty.Count); - Assert.Equal(ReadOnlyFrugalList.Empty, ReadOnlyFrugalList.Empty); +#pragma warning disable xUnit2013 + Assert.Equal(0, ImmutableFrugalList.Empty.Count); +#pragma warning restore xUnit2013 + Assert.Equal(ImmutableFrugalList.Empty, ImmutableFrugalList.Empty); } #endregion #region Ctors - [Fact] - public void Ctor_NullList_ThrowsArgumentNullException() - { - Assert.Throws(() => new ReadOnlyFrugalList(null!)); - } - [Fact] public void Ctor_Single() { var t = CreateT(0); - var list = new ReadOnlyFrugalList(t); + // ReSharper disable once CollectionNeverUpdated.Local + var list = new ImmutableFrugalList(t); +#pragma warning disable xUnit2013 Assert.Equal(1, list.Count); +#pragma warning restore xUnit2013 Assert.Equal(t, list[0]); } + [Theory] + [MemberData(nameof(GetEnumerableTestData))] +#pragma warning disable xUnit1026 + public void Ctor_FrugalListIn(int _, int enumerableLength, int __, int numberOfDuplicateElements) + { + var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); + var frugal = new FrugalList(enumerable); + var immutable = new ImmutableFrugalList(in frugal); + + Assert.Equal(frugal, immutable); + } +#pragma warning restore xUnit1026 + [Theory] [MemberData(nameof(GetEnumerableTestData))] #pragma warning disable xUnit1026 @@ -76,9 +106,9 @@ public void Ctor_ModificationsGetNotReflectedWhenOriginalListChanges(int _, int var frugal = new FrugalList(enumerable); ref var refFrugal = ref frugal; - var roFrugal = new ReadOnlyFrugalList(in frugal); + var immutable = new ImmutableFrugalList(in frugal); - Assert.Equal(refFrugal.ToList(), roFrugal.ToList()); + Assert.Equal(refFrugal.ToList(), immutable.ToList()); if (enumerableLength == 0) return; @@ -87,9 +117,12 @@ public void Ctor_ModificationsGetNotReflectedWhenOriginalListChanges(int _, int var mods = ModifyOperation.Add | ModifyOperation.Insert | ModifyOperation.Overwrite | ModifyOperation.Remove | ModifyOperation.Clear; - foreach (var modifyEnumerable in IListTestSuite.GetModifyEnumerables(mods, CreateT)) - if (modifyEnumerable(asEnumerable)) - Assert.NotEqual(asEnumerable.ToList(), roFrugal.ToList()); + foreach (var modifyEnumerable in GetModifyEnumerables(mods, CreateT)) + { + var listCopy = new List(asEnumerable); + if (modifyEnumerable(listCopy)) + Assert.NotEqual(listCopy, immutable.ToList()); + } } #endregion @@ -169,13 +202,104 @@ public void CopyTo_ArrayIsLargerThanCollection(int count) #endregion + #region Contains + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Contains_ValidValueOnCollectionNotContainingThatValue(int count) + { + var collection = GenericReadOnlyListFrugalListFactory(count); + var seed = 4315; + var item = CreateT(seed++); + while (collection.Contains(item)) + item = CreateT(seed++); +#pragma warning disable xUnit2017 + Assert.False(collection.Contains(item)); +#pragma warning restore xUnit2017 + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Contains_ValidValueOnCollectionContainingThatValue(int count) + { +#pragma warning disable xUnit2017 + var collection = GenericReadOnlyListFrugalListFactory(count); + foreach (var item in collection) + Assert.True(collection.Contains(item)); +#pragma warning restore xUnit2017 + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Contains_DefaultValueOnCollectionNotContainingDefaultValue(int count) + { +#pragma warning disable xUnit2017 + var collection = GenericReadOnlyListFrugalListFactory(count); + if (default(T) is null) + Assert.False(collection.Contains(default!)); +#pragma warning restore xUnit2017 + } + + #endregion + + #region IndexOf + + //[Theory] + //[MemberData(nameof(ValidCollectionSizes))] + //public void IList_Generic_IndexOf_DefaultValueNotContainedInList(int count) + //{ + // var list = GenericReadOnlyListFrugalListFactory(count); + // var value = default(T); + // if (list.Contains(value!)) + // return; + // Assert.Equal(-1, list.IndexOf(value!)); + //} + + //[Theory] + //[MemberData(nameof(ValidCollectionSizes))] + //public void IList_Generic_IndexOf_DefaultValueContainedInList(int count) + //{ + // if (count > 0) + // { + // var list = GenericReadOnlyListFrugalListFactory(count); + // var value = default(T); + // if (!list.Contains(value!)) + // return; + // Assert.Equal(0, list.IndexOf(value!)); + // } + //} + + //[Theory] + //[MemberData(nameof(ValidCollectionSizes))] + //public void IList_Generic_IndexOf_ValidValueNotContainedInList(int count) + //{ + // var list = GenericReadOnlyListFrugalListFactory(count); + // var seed = 54321; + // var value = CreateT(seed++); + // while (list.Contains(value)) + // value = CreateT(seed++); + // Assert.Equal(-1, list.IndexOf(value)); + //} + + //[Theory] + //[MemberData(nameof(ValidCollectionSizes))] + //public void IList_Generic_IndexOf_EachValueNoDuplicates(int count) + //{ + // // Assumes no duplicate elements contained in the list returned by GenericIListFactory + // var list = GenericReadOnlyListFrugalListFactory(count); + // foreach (var i in Enumerable.Range(0, count)) + // Assert.Equal(i, list.IndexOf(list[i])); + //} + + #endregion + #region Linq Equivalents [Theory] [MemberData(nameof(ValidCollectionSizes))] public void ToList(int count) { - var enumerable = CreateEnumerable(null, count, 0, 0); + var enumerable = CreateEnumerable(null, count, 0, 0).ToList(); var list = new FrugalList(enumerable); Assert.Equal(enumerable.ToList(), list.ToList()); } @@ -238,7 +362,7 @@ public void ToArray(int count) public void GetEnumerator(int _, int enumerableLength, int __, int numberOfDuplicateElements) #pragma warning restore xUnit1026 { - var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements); + var enumerable = CreateEnumerable(null, enumerableLength, 0, numberOfDuplicateElements).ToList(); var list = new FrugalList(enumerable); var actualList = new List(); diff --git a/src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTests.cs b/src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTests.cs new file mode 100644 index 00000000..f3b35f48 --- /dev/null +++ b/src/CommonUtilities/test/Collections/FrugalList/ImmutableFrugalListTests.cs @@ -0,0 +1,102 @@ +using AnakinRaW.CommonUtilities.Collections; +using System; +using System.Linq; +using Xunit; +// ReSharper disable InconsistentNaming + +namespace AnakinRaW.CommonUtilities.Test.Collections.FrugalList; + +public class ImmutableFrugalListTestString : ImmutableFrugalListTestBase +{ + protected override string CreateT(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes = new byte[stringLength]; + rand.NextBytes(bytes); + return Convert.ToBase64String(bytes); + } +} + +public class ImmutableFrugalListTestInt : ImmutableFrugalListTestBase +{ + private static readonly int[] _intArray = [-4, 5, -2, 3, 1, 2, -1, -3, 0, 4, -5, 3, 3]; + private static readonly int[] _excludedFromIntArray = [100, -34, 42, int.MaxValue, int.MinValue]; + + protected override int CreateT(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + [Fact] + public static void Contains() + { +#pragma warning disable xUnit2017 + var collection = ImmutableFrugalList.Create(_intArray); + foreach (var item in _intArray) + Assert.True(collection.Contains(item)); + + foreach (var excluded in _excludedFromIntArray) + Assert.False(collection.Contains(excluded)); +#pragma warning restore xUnit2017 + } + + [Fact] + public static void IndexOf() + { + var collection = ImmutableFrugalList.Create(_intArray); + + foreach (var item in _intArray) + Assert.Equal(Array.IndexOf(_intArray, item), collection.IndexOf(item)); + + foreach (var excluded in _excludedFromIntArray) + Assert.Equal(-1, collection.IndexOf(excluded)); + } +} + +public class ImmutableFrugalListTest +{ + #region Create{T} + + [Fact] + public void Create_NullArg_ThrowsArgumentNullException() + { + Assert.Throws("items", () => ImmutableFrugalList.Create(null!)); + } + + [Fact] +#pragma warning disable xUnit1026 + public void Create_CreatesCorrectImmutableList() + { + var list = new[] { 1, 2, 3, 4, 5, 6 }; + + var listAsFrugal = new FrugalList(list); + var listAsImmutable = listAsFrugal.ToImmutableList(); + var listAsSet = list.ToHashSet(); + var listAsEnumerable = list.Where(_ => true); + + Assert.Equal(list, ImmutableFrugalList.Create(listAsFrugal)); + Assert.Equal(list, ImmutableFrugalList.Create(listAsImmutable)); + Assert.Equal(list, ImmutableFrugalList.Create(listAsSet)); + Assert.Equal(list, ImmutableFrugalList.Create(listAsEnumerable)); + } +#pragma warning restore xUnit1026 + + #endregion + + #region Single{T} + + [Theory] + [InlineData(null)] + [InlineData(1)] + [InlineData("123")] + public void Single_CreatesCorrectImmutableListWithOneItem(object? data) + { + var list = ImmutableFrugalList.Single(data); + Assert.Equal([data], list); + Assert.Single(list); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/FrugalListTest.cs b/src/CommonUtilities/test/Collections/FrugalListTest.cs deleted file mode 100644 index 264e3200..00000000 --- a/src/CommonUtilities/test/Collections/FrugalListTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -// ReSharper disable InconsistentNaming - -namespace AnakinRaW.CommonUtilities.Test.Collections; - -public class FrugalListTest_String : FrugalListTestBase -{ - protected override string CreateT(int seed) - { - var stringLength = seed % 10 + 5; - var rand = new Random(seed); - var bytes = new byte[stringLength]; - rand.NextBytes(bytes); - return Convert.ToBase64String(bytes); - } -} - -public class FrugalListTest_Int : FrugalListTestBase -{ - protected override int CreateT(int seed) - { - var rand = new Random(seed); - return rand.Next(); - } -} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ReadOnlyFrugalListTests.cs b/src/CommonUtilities/test/Collections/ReadOnlyFrugalListTests.cs deleted file mode 100644 index aca8c84f..00000000 --- a/src/CommonUtilities/test/Collections/ReadOnlyFrugalListTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using AnakinRaW.CommonUtilities.Collections; -// ReSharper disable InconsistentNaming - -namespace AnakinRaW.CommonUtilities.Test.Collections; - -public class ReadOnlyFrugalListTest_String : ReadOnlyFrugalListTestBase -{ - protected override string CreateT(int seed) - { - var stringLength = seed % 10 + 5; - var rand = new Random(seed); - var bytes = new byte[stringLength]; - rand.NextBytes(bytes); - return Convert.ToBase64String(bytes); - } -} - -public class ReadOnlyFrugalListTest_Int : ReadOnlyFrugalListTestBase -{ - protected override int CreateT(int seed) - { - var rand = new Random(seed); - return rand.Next(); - } -} - - -public class ReadOnlyFrugalListTest_Int_FromFrugal : ReadOnlyFrugalListTestBase -{ - protected override int CreateT(int seed) - { - var rand = new Random(seed); - return rand.Next(); - } - - protected override ReadOnlyFrugalList GenericReadOnlyListFrugalListFactory(IEnumerable enumerable) - { - var frugal = new FrugalList(enumerable); - return frugal.AsReadOnly(); - } -} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Keys.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Keys.cs new file mode 100644 index 00000000..80653963 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Keys.cs @@ -0,0 +1,30 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +// ReSharper disable once InconsistentNaming +public class FrugalValueListDictionary_Keys : ValueListDictionary_Keys_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return (FrugalValueListDictionary)MutableValueListDictionaryFactory(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return (FrugalValueListDictionary)dictionary; + } + + protected override IValueListDictionary MutableValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + [Fact] + public void FrugalValueListDictionary_KeyCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new FrugalValueListDictionary.KeyCollection(null!)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Values.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Values.cs new file mode 100644 index 00000000..b26012c6 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTest.Values.cs @@ -0,0 +1,29 @@ +using AnakinRaW.CommonUtilities.Collections; +using System; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +public class FrugalValueListDictionary_Values : ValueListDictionary_Values_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return (FrugalValueListDictionary)MutableValueListDictionaryFactory(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return (FrugalValueListDictionary)dictionary; + } + + protected override IValueListDictionary MutableValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + [Fact] + public void FrugalValueListDictionary_ValueCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new FrugalValueListDictionary.ValueCollection(null!)); + } +} diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTestBase.cs new file mode 100644 index 00000000..c38908bd --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTestBase.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +public abstract class FrugalValueListDictionaryTestBase + : ValueListDictionaryBaseTestSuite> + where TKey : notnull +{ + protected override bool ValueList_IsReadOnlyView => false; + + protected FrugalValueListDictionary FrugalValueListDictionaryFactory(IEqualityComparer? comparer = null) + { + return new FrugalValueListDictionary(comparer); + } + + protected FrugalValueListDictionary FrugalValueListDictionaryFactory(int count) + { + var dict = FrugalValueListDictionaryFactory(); + AddToCollection(dict, count); + return dict; + } + + protected override ValueListDictionaryBase> ValueListDictionaryFactory(int count) + { + return FrugalValueListDictionaryFactory(count); + } + + protected override ValueListDictionaryBase> ValueListDictionaryFactory( + IEqualityComparer? comparer = null) + { + return FrugalValueListDictionaryFactory(); + } + + #region Constructors + + [Fact] + public void Ctor_InitializesCorrectly() + { + var dict = new FrugalValueListDictionary(); + Assert.Equal(0, dict.ValueCount); + Assert.Equal(0, dict.Count); + } + + #endregion + + #region GetEnumerator + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetEnumerator_EnumeratesDictionaryCorrectly(int count) + { + var collection = FrugalValueListDictionaryFactory(count); + + using var enumerator1 = collection.GetEnumerator(); + using var enumerator2 = ((IReadOnlyFrugalValueListDictionary)collection).GetEnumerator(); + IEnumerator enumerator3 = collection.GetEnumerator(); + + foreach (var keyValuePair in collection) + { + Assert.True(enumerator1.MoveNext()); + Assert.True(enumerator2.MoveNext()); + Assert.True(enumerator3.MoveNext()); + + Assert.Equal(keyValuePair.Key, enumerator1.Current.Key); + Assert.Equal(keyValuePair.Key, enumerator2.Current.Key); + Assert.Equal(keyValuePair.Key, ((KeyValuePair>)enumerator3.Current).Key); + + Assert.Equal(keyValuePair.Value, enumerator1.Current.Value); + Assert.Equal(keyValuePair.Value, enumerator2.Current.Value); + Assert.Equal(keyValuePair.Value, ((KeyValuePair>)enumerator3.Current).Value); + } + Assert.False(enumerator1.MoveNext()); + + if (enumerator3 is IDisposable disposable) + disposable.Dispose(); + } + + #endregion + + #region Item Getter + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_ItemGet_DefaultKey(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary[default!]); + return; + } + + var value = CreateTValue(3452); + dictionary.Add(default!, value); + Assert.Equal(value, dictionary[default!].First()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_ItemGet_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary[missingKey]); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_ItemGet_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + dictionary.Remove(missingKey); + Assert.Throws(() => dictionary[missingKey]); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_ItemGet_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary[pair.Key]); + } + + #endregion + + #region GetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_GetValues_DefaultKey(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.GetValues(default!)); + return; + } + + var value = CreateTValue(3452); + dictionary.Add(default!, value); + Assert.Equal(value, dictionary.GetValues(default!).First()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_GetValues_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_GetValues_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + dictionary.Remove(missingKey); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_GetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary.GetValues(pair.Key)); + } + + #endregion + + #region TryGetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_TryGetValues_DefaultKey(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.TryGetValues(default!, out _)); + return; + } + + var first = CreateTValue(3452); + var second = CreateTValue(5431); + dictionary.Add(default!, first); + dictionary.Add(default!, second); + Assert.True(dictionary.TryGetValues(default!, out var valueList)); + Assert.Equal([first, second], valueList); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_TryGetValues_MissingNonDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_TryGetValues_MissingDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + if (DefaultValueAllowed) + { + var dictionary = FrugalValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + dictionary.Remove(missingKey); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void FrugalValueListDictionary_TryGetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = FrugalValueListDictionaryFactory(count); + foreach (var pair in dictionary) + { + Assert.True(dictionary.TryGetValues(pair.Key, out var valueList)); + Assert.Equal(pair.Value, valueList); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTests.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTests.cs new file mode 100644 index 00000000..949f4d74 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/FrugalValueListDictionaryTests.cs @@ -0,0 +1,38 @@ +using System; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +public class FrugalValueListDictionaryTest_int_int : FrugalValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => true; + + protected override int CreateTKey(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + protected override int CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + +public class FrugalValueListDictionaryTest_string_string : FrugalValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => false; + + protected override string CreateTKey(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes1 = new byte[stringLength]; + rand.NextBytes(bytes1); + return Convert.ToBase64String(bytes1); + } + + protected override string CreateTValue(int seed) + { + return CreateTKey(seed); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Keys.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Keys.cs new file mode 100644 index 00000000..264b109b --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Keys.cs @@ -0,0 +1,30 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +// ReSharper disable once InconsistentNaming +public class ReadOnlyFrugalValueListDictionary_Keys : ValueListDictionary_Keys_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return new ReadOnlyFrugalValueListDictionary(new FrugalValueListDictionary()); + } + + protected override IValueListDictionary MutableValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return new ReadOnlyFrugalValueListDictionary((IReadOnlyFrugalValueListDictionary)dictionary); + } + + [Fact] + public void ReadOnlyFrugalValueListDictionary_KeyCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ReadOnlyFrugalValueListDictionary.KeyCollection(null!)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Values.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Values.cs new file mode 100644 index 00000000..64f2d23d --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTest.Values.cs @@ -0,0 +1,32 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +// ReSharper disable once InconsistentNaming + +public class ReadOnlyFrugalValueListDictionary_Values : ValueListDictionary_Values_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return new ReadOnlyFrugalValueListDictionary(new FrugalValueListDictionary()); + } + + protected override IValueListDictionary MutableValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return new ReadOnlyFrugalValueListDictionary((IReadOnlyFrugalValueListDictionary)dictionary); + } + + [Fact] + public void ReadOnlyFrugalValueListDictionary_ValueCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ReadOnlyFrugalValueListDictionary.ValueCollection(null!)); + } +} + diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTestBase.cs new file mode 100644 index 00000000..8f3fa416 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTestBase.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using AnakinRaW.CommonUtilities.Collections; +using AnakinRaW.CommonUtilities.Testing.Extensions; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +public abstract class ReadOnlyFrugalValueListDictionaryTestBase + : ReadOnlyValueListDictionaryBaseTestSuite + where TKey : notnull +{ + protected override bool DefaultValueAllowed => false; + + protected ReadOnlyFrugalValueListDictionary ReadOnlyFrugalValueListDictionaryFactory( + int count, out FrugalValueListDictionary mutableDict) + { + mutableDict = MutableFrugalValueListDictionaryFactory(); + AddToCollection(mutableDict, count); + return new ReadOnlyFrugalValueListDictionary(mutableDict); + } + + + protected ReadOnlyFrugalValueListDictionary ReadOnlyFrugalValueListDictionaryFactory( + IReadOnlyFrugalValueListDictionary dictionary) + { + return new ReadOnlyFrugalValueListDictionary(dictionary); + } + + protected sealed override ReadOnlyValueListDictionaryBase ReadOnlyValueListDictionaryFactory( + IReadOnlyValueListDictionary dictionary) + { + if (dictionary is not IReadOnlyFrugalValueListDictionary frugalValueList) + throw new InvalidOperationException("invalid test construction"); + return ReadOnlyFrugalValueListDictionaryFactory(frugalValueList); + } + + protected FrugalValueListDictionary MutableFrugalValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + protected sealed override IValueListDictionary MutableValueListDictionaryFactory() + { + return MutableFrugalValueListDictionaryFactory(); + } + + protected override IEnumerable NonGenericIEnumerableFactory(int count) + { + return ReadOnlyFrugalValueListDictionaryFactory(count, out _); + } + + #region Ctor + + [Fact] + public void CtorTests_Negative() + { + AssertExtensions.Throws("dictionary", + () => _ = new ReadOnlyFrugalValueListDictionary(null!)); + } + + #endregion + + #region ReadOnlyFrugalValueListDictionary{TKey, TValue}.Empty + + [Fact] + public static void Empty_Idempotent() + { + Assert.NotNull(ReadOnlyFrugalValueListDictionary.Empty); + Assert.Equal(0, ReadOnlyFrugalValueListDictionary.Empty.ValueCount); + Assert.Same(ReadOnlyFrugalValueListDictionary.Empty, ReadOnlyFrugalValueListDictionary.Empty); + } + + #endregion + + #region GetEnumerator + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetEnumerator(int count) + { + // ReSharper disable GenericEnumeratorNotDisposed + + var ro = ReadOnlyFrugalValueListDictionaryFactory(count, out var expectedDictionary); + + // Verify struct enumerator can be obtained without using statement + _ = ro.GetEnumerator(); + + // IReadOnlyFrugalValueListDictionary enumerators + AssertEnumeratorBehavior( + count, + ro.GetEnumerator(), + expectedDictionary.GetEnumerator(), + e => e.Current); + + AssertEnumeratorBehavior( + count, + ((IReadOnlyFrugalValueListDictionary)ro).GetEnumerator(), + ((IReadOnlyFrugalValueListDictionary)expectedDictionary).GetEnumerator(), + e => e.Current); + + // IReadOnlyValueListDictionary enumerator + AssertEnumeratorBehavior( + count, + ((IReadOnlyValueListDictionary)ro).GetEnumerator(), + ((IReadOnlyValueListDictionary)expectedDictionary).GetEnumerator(), + e => e.Current); + + // IEnumerable enumerator + AssertEnumeratorBehavior( + count, + ((IEnumerable)ro).GetEnumerator(), + ((IEnumerable)expectedDictionary).GetEnumerator(), + e => e.Current); + + // ReSharper restore GenericEnumeratorNotDisposed + } + + #endregion + + #region Item Getter + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_ItemGet_DefaultKey(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary[default!]); + return; + } + + var value = CreateTValue(3452); + collection.Add(default!, value); + Assert.Equal(value, dictionary[default!].First()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_ItemGet_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary[missingKey]); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_ItemGet_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + collection.Remove(missingKey); + Assert.Throws(() => dictionary[missingKey]); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_ItemGet_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary[pair.Key]); + } + + #endregion + + #region GetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_GetValues_DefaultKey(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.GetValues(default!)); + return; + } + + var value = CreateTValue(3452); + collection.Add(default!, value); + Assert.Equal(value, dictionary.GetValues(default!).First()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_GetValues_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_GetValues_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + collection.Remove(missingKey); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_GetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary.GetValues(pair.Key)); + } + + #endregion + + #region TryGetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_TryGetValues_DefaultKey(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.TryGetValues(default!, out _)); + return; + } + + var first = CreateTValue(3452); + var second = CreateTValue(5431); + collection.Add(default!, first); + collection.Add(default!, second); + Assert.True(dictionary.TryGetValues(default!, out var valueList)); + Assert.Equal([first, second], valueList); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_TryGetValues_MissingNonDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_TryGetValues_MissingDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + if (DefaultValueAllowed) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out var collection); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + collection.Remove(missingKey); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ReadOnlyFrugalValueListDictionary_TryGetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = ReadOnlyFrugalValueListDictionaryFactory(count, out _); + foreach (var pair in dictionary) + { + Assert.True(dictionary.TryGetValues(pair.Key, out var valueList)); + Assert.Equal(pair.Value, valueList); + } + } + + #endregion + + private static void AssertEnumeratorBehavior( + int count, + TEnumerator roEnumerator, + TEnumerator expectedEnumerator, + Func getCurrent) + where TEnumerator : IEnumerator + { + try + { + for (var iteration = 0; iteration < 3; iteration++) + { + for (var j = 0; j < count; j++) + { + Assert.True(expectedEnumerator.MoveNext()); + Assert.True(roEnumerator.MoveNext()); + + var expectedCurrent = getCurrent(expectedEnumerator); + var roCurrent = getCurrent(roEnumerator); + + Assert.Equal(expectedCurrent!.GetType(), roCurrent!.GetType()); + Assert.Equal(expectedCurrent, roCurrent); + } + + Assert.False(roEnumerator.MoveNext()); + Assert.False(expectedEnumerator.MoveNext()); + + roEnumerator.Reset(); + expectedEnumerator.Reset(); + } + } + finally + { + (roEnumerator as IDisposable)?.Dispose(); + (expectedEnumerator as IDisposable)?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTests.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTests.cs new file mode 100644 index 00000000..962ceeae --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/Frugal/ReadOnlyFrugalValueListDictionaryTests.cs @@ -0,0 +1,39 @@ +using System; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.Frugal; + +public class ReadOnlyFrugalValueListDictionaryTest_string_string + : ReadOnlyFrugalValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => false; + + protected override string CreateTKey(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes1 = new byte[stringLength]; + rand.NextBytes(bytes1); + return Convert.ToBase64String(bytes1); + } + + protected override string CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + +public class ReadOnlyFrugalValueListDictionaryTest_int_int : ReadOnlyFrugalValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => true; + + protected override int CreateTKey(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + protected override int CreateTValue(int seed) + { + return CreateTKey(seed); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Keys.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Keys.cs new file mode 100644 index 00000000..e3ebb9bb --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Keys.cs @@ -0,0 +1,25 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +// ReSharper disable once InconsistentNaming +public class ReadOnlyValueListDictionary_Keys : ValueListDictionary_Keys_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return new ReadOnlyValueListDictionary(new ValueListDictionary()); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return new ReadOnlyValueListDictionary(dictionary); + } + + [Fact] + public void ReadOnlyValueListDictionary_KeyCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ReadOnlyValueListDictionary.KeyCollection(null!)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Values.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Values.cs new file mode 100644 index 00000000..3439b345 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTest.Values.cs @@ -0,0 +1,27 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +// ReSharper disable once InconsistentNaming + +public class ReadOnlyValueListDictionary_Values : ValueListDictionary_Values_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return new ReadOnlyValueListDictionary(new ValueListDictionary()); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return new ReadOnlyValueListDictionary(dictionary); + } + + [Fact] + public void ReadOnlyValueListDictionary_ValueCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ReadOnlyValueListDictionary.ValueCollection(null!)); + } +} + diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTestBase.cs new file mode 100644 index 00000000..d41993ab --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTestBase.cs @@ -0,0 +1,32 @@ +using AnakinRaW.CommonUtilities.Collections; +using AnakinRaW.CommonUtilities.Testing.Extensions; +using System; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +public abstract class ReadOnlyValueListDictionaryTestBase + : ReadOnlyValueListDictionaryBaseTestSuite + where TKey : notnull +{ + protected sealed override ReadOnlyValueListDictionaryBase ReadOnlyValueListDictionaryFactory( + IReadOnlyValueListDictionary dictionary) + { + return new ReadOnlyValueListDictionary(dictionary); + } + + [Fact] + public void CtorTests_Negative() + { + AssertExtensions.Throws("dictionary", + () => _ = new ReadOnlyValueListDictionary(null!)); + } + + [Fact] + public static void Empty_Idempotent() + { + Assert.NotNull(ReadOnlyValueListDictionary.Empty); + Assert.Equal(0, ReadOnlyValueListDictionary.Empty.ValueCount); + Assert.Same(ReadOnlyValueListDictionary.Empty, ReadOnlyValueListDictionary.Empty); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTests.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTests.cs new file mode 100644 index 00000000..a226d264 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ReadOnlyValueListDictionaryTests.cs @@ -0,0 +1,63 @@ +using AnakinRaW.CommonUtilities.Collections; +using System; + +// ReSharper disable InconsistentNaming + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +public class ReadOnlyValueListDictionaryTest_string_string : ReadOnlyValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => false; + + protected override string CreateTKey(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes1 = new byte[stringLength]; + rand.NextBytes(bytes1); + return Convert.ToBase64String(bytes1); + } + + protected override string CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + +public class ReadOnlyValueListDictionaryTest_int_int : ReadOnlyValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => true; + + protected override int CreateTKey(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + protected override int CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + +public class ReadOnlyValueListDictionaryTest_FromFrugal : ReadOnlyValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => true; + + protected override IValueListDictionary MutableValueListDictionaryFactory() + { + return new FrugalValueListDictionary(); + } + + protected override int CreateTKey(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + protected override int CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Keys.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Keys.cs new file mode 100644 index 00000000..3ee9a8c2 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Keys.cs @@ -0,0 +1,25 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +// ReSharper disable once InconsistentNaming +public class ValueListDictionary_Keys : ValueListDictionary_Keys_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return (ValueListDictionary)MutableValueListDictionaryFactory(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return (ValueListDictionary)dictionary; + } + + [Fact] + public void ValueListDictionary_KeyCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ValueListDictionary.KeyCollection(null!)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Values.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Values.cs new file mode 100644 index 00000000..dec00194 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTest.Values.cs @@ -0,0 +1,25 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +// ReSharper disable once InconsistentNaming +public class ValueListDictionary_Values : ValueListDictionary_Values_TestSuite +{ + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory() + { + return (ValueListDictionary)MutableValueListDictionaryFactory(); + } + + protected override IReadOnlyValueListDictionary ValueListDictionaryFactory(IValueListDictionary dictionary) + { + return (ValueListDictionary)dictionary; + } + + [Fact] + public void ValueListDictionary_ValueCollection_Constructor_NullDictionary() + { + Assert.Throws(() => new ValueListDictionary.ValueCollection(null!)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTestBase.cs new file mode 100644 index 00000000..cb009396 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTestBase.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using AnakinRaW.CommonUtilities.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +public abstract class ValueListDictionaryTestBase + : ValueListDictionaryBaseTestSuite> where TKey : notnull +{ + protected override ValueListDictionaryBase> + ValueListDictionaryFactory(IEqualityComparer? comparer = null) + { + return new ValueListDictionary(comparer); + } + + #region Constructors + + [Fact] + public void Ctor_InitializesCorrectly() + { + var dict = new ValueListDictionary(); + Assert.Equal(0, dict.ValueCount); + Assert.Equal(0, dict.Count); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTests.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTests.cs new file mode 100644 index 00000000..fe51cbdc --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IList/ValueListDictionaryTests.cs @@ -0,0 +1,40 @@ +using System; + +// ReSharper disable InconsistentNaming + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary.IList; + +public class ValueListDictionaryTest_string_string : ValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => false; + + protected override string CreateTKey(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes1 = new byte[stringLength]; + rand.NextBytes(bytes1); + return Convert.ToBase64String(bytes1); + } + + protected override string CreateTValue(int seed) + { + return CreateTKey(seed); + } +} + +public class ValueListDictionaryTest_int_int : ValueListDictionaryTestBase +{ + protected override bool DefaultValueAllowed => true; + + protected override int CreateTKey(int seed) + { + var rand = new Random(seed); + return rand.Next(); + } + + protected override int CreateTValue(int seed) + { + return CreateTKey(seed); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IReadOnlyValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IReadOnlyValueListDictionaryTestBase.cs new file mode 100644 index 00000000..d269c051 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IReadOnlyValueListDictionaryTestBase.cs @@ -0,0 +1,810 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AnakinRaW.CommonUtilities.Collections; +using AnakinRaW.CommonUtilities.Testing.Collections; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary; + +#pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. + +public abstract class IReadOnlyValueListDictionaryTestBase : IEnumerableTestSuite>> + where TKey : notnull +{ + protected abstract bool DefaultValueAllowed { get; } + + // ReSharper disable once InconsistentNaming + protected virtual bool ValueList_IsReadOnlyView => true; + protected virtual bool IsReadOnly => true; + + protected sealed override bool Enumerator_Empty_UsesSingletonInstance => true; + protected sealed override bool Enumerator_Empty_Current_UndefinedOperation_Throws => true; + protected sealed override bool Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException => false; + protected sealed override bool NonGenericEnumerator_Current_UndefinedOperation_Throws => true; + protected sealed override bool NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw => true; + + protected abstract TKey CreateTKey(int seed); + + protected abstract TValue CreateTValue(int seed); + + protected abstract IReadOnlyValueListDictionary IReadOnlyValueListDictionaryFactory(int count); + + protected sealed override KeyValuePair> CreateT(int seed) + { + throw new NotSupportedException(); + } + + protected sealed override IEqualityComparer>> GetIEqualityComparer() + { + return new KVPComparer(); + } + + protected sealed override IEnumerable GetModifyEnumerables(ModifyOperation operations) + { + // ReSharper disable UseMethodAny.0 + if (IsReadOnly) + yield break; + + if ((operations & ModifyOperation.Add) == ModifyOperation.Add) + { + yield return enumerable => + { + var casted = (IValueListDictionary)enumerable; + casted.Add(CreateTKey(12), CreateTValue(5123)); + return true; + }; + } + if ((operations & ModifyOperation.Insert) == ModifyOperation.Insert) + { + yield return enumerable => + { + var casted = (IValueListDictionary)enumerable; + casted.Add(CreateTKey(541), CreateTValue(12)); + return true; + }; + } + if ((operations & ModifyOperation.Remove) == ModifyOperation.Remove) + { + yield return enumerable => + { + var casted = (IValueListDictionary)enumerable; + if (casted.Count() > 0) + { + using var keys = casted.Keys.GetEnumerator(); + keys.MoveNext(); + casted.Remove(keys.Current!); + return true; + } + return false; + }; + } + if ((operations & ModifyOperation.Clear) == ModifyOperation.Clear) + { + yield return enumerable => + { + var casted = (IValueListDictionary)enumerable; + if (casted.Count() > 0) + { + casted.Clear(); + return true; + } + return false; + }; + } + //throw new InvalidOperationException(string.Format("{0:G}", operations)); + // ReSharper restore UseMethodAny.0 + } + + protected override IEnumerable>> GenericIEnumerableFactory( + int count) + { + return IReadOnlyValueListDictionaryFactory(count); + } + + protected void AddToCollection(IValueListDictionary dictionary, int numberOfItemsToAdd) + { + var seed = 12353; + var random = new Random(); + var initialCount = dictionary.Count; + while (dictionary.Count - initialCount < numberOfItemsToAdd) + { + var toAdd = CreateTKey(seed++); + while (dictionary.ContainsKey(toAdd)) + toAdd = CreateTKey(seed++); + + dictionary.Add(toAdd, CreateTValue(seed++)); + while (random.Next() % 2 == 0) + dictionary.Add(toAdd, CreateTValue(seed++)); + } + } + + protected TKey GetNewKey(IReadOnlyValueListDictionary dictionary) + { + var seed = 840; + var missingKey = CreateTKey(seed++); + while (dictionary.ContainsKey(missingKey) || missingKey.Equals(default(TKey))) + missingKey = CreateTKey(seed++); + return missingKey; + } + + #region Item Getter + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ItemGet_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary[default!]); + return; + } + + if (!IsReadOnly) + { + var value = CreateTValue(3452); + AddValue(dictionary, default!, value); + Assert.Equal(value, dictionary[default!].First()); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ItemGet_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary[missingKey]); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ItemGet_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.Throws(() => dictionary[missingKey]); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ItemGet_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary[pair.Key]); + } + + #endregion + + #region Keys + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Keys_ContainsAllCorrectKeys(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var expected = dictionary.Select(pair => pair.Key); + Assert.True(expected.SequenceEqual(dictionary.Keys)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Keys_IsReadOnly(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var keys = dictionary.Keys; + Assert.True(keys.IsReadOnly); + Assert.Throws(() => keys.Add(CreateTKey(11))); + Assert.Throws(() => keys.Clear()); + Assert.Throws(() => keys.Remove(CreateTKey(11))); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Keys_Enumeration_Reset(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var keys = dictionary.Keys; + using var enumerator = keys.GetEnumerator(); + enumerator.Reset(); + } + + #endregion + + #region Values + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Values_ContainsAllCorrectFlattenedValues(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var expected = dictionary.SelectMany(pair => pair.Value); + Assert.True(expected.SequenceEqual(dictionary.Values)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Values_IsReadOnly(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var values = dictionary.Values; + Assert.True(values.IsReadOnly); + Assert.Throws(() => values.Add(CreateTValue(11))); + Assert.Throws(() => values.Clear()); + Assert.Throws(() => values.Remove(CreateTValue(11))); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Values_Enumeration_Reset(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var values = dictionary.Values; + using var enumerator = values.GetEnumerator(); + enumerator.Reset(); + } + + #endregion + + #region ValueCount + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ValueCount_Validity(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var expectedCount = dictionary.Sum(pair => pair.Value.Count); + Assert.Equal(expectedCount, dictionary.ValueCount); + } + + #endregion + + #region Count + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void Count_Validity(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + Assert.Equal(count, dictionary.Count); + } + + #endregion + + #region ContainsKey + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ContainsKey_ValidKeyNotContainedInDictionary(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.ContainsKey(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ContainsKey_ValidKeyContainedInDictionary(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (count > 0) + { + var key = dictionary.Keys.First(); + Assert.True(dictionary.ContainsKey(key)); + } + + if (!IsReadOnly) + { + var missingKey = GetNewKey(dictionary); + AddValue(dictionary, missingKey, CreateTValue(34251)); + Assert.True(dictionary.ContainsKey(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ContainsKey_DefaultKeyNotContainedInDictionary(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (DefaultValueAllowed) + { + if (!IsReadOnly) + { + // returns false + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.False(dictionary.ContainsKey(missingKey)); + } + } + else + { + // throws ArgumentNullException + Assert.Throws(() => dictionary.ContainsKey(default!)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ContainsKey_DefaultKeyContainedInDictionary(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + if (!dictionary.ContainsKey(missingKey)) + AddValue(dictionary, missingKey, CreateTValue(5341)); + Assert.True(dictionary.ContainsKey(missingKey)); + } + } + + #endregion + + #region GetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetValues_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.GetValues(default!)); + return; + } + + if (!IsReadOnly) + { + var value = CreateTValue(3452); + AddValue(dictionary, default!, value); + Assert.Equal(value, dictionary.GetValues(default!).First()); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetValues_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetValues_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.Throws(() => dictionary.GetValues(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value, dictionary.GetValues(pair.Key)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetValues_ReturnsReadOnlyViewOrSnapshot(int count) + { + if (IsReadOnly) + return; + + var dict = IReadOnlyValueListDictionaryFactory(count); + var key = GetNewKey(dict); + var seed = 1234; + AddValue(dict, key, CreateTValue(seed++)); + + var values = dict.GetValues(key); + + var newValue = CreateTValue(seed); + while (values.Contains(newValue)) + newValue = CreateTValue(++seed); + + AddValue(dict, key, newValue); + + // View reflects live changes, snapshot doesn't + Assert.Equal(ValueList_IsReadOnlyView, values.Contains(newValue)); + + // After removal, neither view nor snapshot contains the value + RemoveValue(dict, key, newValue); + Assert.DoesNotContain(newValue, values); + + // Removing key doesn't clear underlying list + RemoveKey(dict, key); + Assert.NotEmpty(values); + + // Clearing dict doesn't clear underlying lists + if (count > 0) + { + var firstKey = dict.Keys.First(); + var firstValues = dict.GetValues(firstKey); + ClearDict(dict); + Assert.NotEmpty(firstValues); + } + } + + #endregion + + #region GetFirstValue + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetFirstValue_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.GetFirstValue(default!)); + return; + } + + if (!IsReadOnly) + { + var first = CreateTValue(3452); + var second = CreateTValue(4312); + AddValue(dictionary, default!, first); + AddValue(dictionary, default!, second); + Assert.Equal(first, dictionary.GetFirstValue(default!)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetFirstValue_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary.GetFirstValue(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetFirstValue_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.Throws(() => dictionary.GetFirstValue(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetFirstValue_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value.First(), dictionary.GetFirstValue(pair.Key)); + } + + #endregion + + #region GetLastValue + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetLastValue_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.GetLastValue(default!)); + return; + } + + if (!IsReadOnly) + { + var first = CreateTValue(3452); + var second = CreateTValue(4312); + AddValue(dictionary, default!, first); + AddValue(dictionary, default!, second); + Assert.Equal(second, dictionary.GetLastValue(default!)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetLastValue_MissingNonDefaultKey_ThrowsKeyNotFoundException(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.Throws(() => dictionary.GetLastValue(missingKey)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetLastValue_MissingDefaultKey_ThrowsKeyNotFoundException(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.Throws(() => dictionary.GetLastValue(missingKey)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void GetLastValue_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + Assert.Equal(pair.Value.Last(), dictionary.GetLastValue(pair.Key)); + } + + #endregion + + #region TryGetValues + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetValues_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.TryGetValues(default!, out _)); + return; + } + + if (!IsReadOnly) + { + var first = CreateTValue(3452); + var second = CreateTValue(5431); + AddValue(dictionary, default!, first); + AddValue(dictionary, default!, second); + Assert.True(dictionary.TryGetValues(default!, out var valueList)); + Assert.Equal([first, second], valueList); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetValues_MissingNonDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetValues_MissingDefaultKey_ReturnsFalseAndSetsDefault(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.False(dictionary.TryGetValues(missingKey, out var valueList)); + Assert.Equal([], valueList); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetValues_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + { + Assert.True(dictionary.TryGetValues(pair.Key, out var valueList)); + Assert.Equal(pair.Value, valueList); + } + } + + #endregion + + #region TryGetFirstValue + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetFirstValue_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.TryGetFirstValue(default!, out _)); + return; + } + + if (!IsReadOnly) + { + var first = CreateTValue(3452); + var second = CreateTValue(4312); + AddValue(dictionary, default!, first); + AddValue(dictionary, default!, second); + + Assert.True(dictionary.TryGetFirstValue(default!, out var value)); + Assert.Equal(first, value); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetFirstValue_MissingNonDefaultKey_ReturnsFalseAndSetsDefaultValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.TryGetFirstValue(missingKey, out var value)); + Assert.Equal(default, value); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetFirstValue_MissingDefaultKey_ReturnsFalseAndSetsDefaultValue(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.False(dictionary.TryGetFirstValue(missingKey, out var value)); + Assert.Equal(default, value); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetFirstValue_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + { + Assert.True(dictionary.TryGetFirstValue(pair.Key, out var value)); + Assert.Equal(pair.Value.First(), value); + } + } + + #endregion + + #region TryGetLastValue + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetLastValue_DefaultKey(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + if (!DefaultValueAllowed) + { + Assert.Throws(() => dictionary.TryGetLastValue(default!, out _)); + return; + } + + if (!IsReadOnly) + { + var first = CreateTValue(3452); + var second = CreateTValue(4312); + AddValue(dictionary, default!, first); + AddValue(dictionary, default!, second); + Assert.True(dictionary.TryGetLastValue(default!, out var value)); + Assert.Equal(second, value); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetLastValue_MissingNonDefaultKey_ReturnsFalseAndSetsDefaultValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.TryGetLastValue(missingKey, out var value)); + Assert.Equal(default, value); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetLastValue_MissingDefaultKey_ReturnsFalseAndSetsDefaultValue(int count) + { + if (DefaultValueAllowed && !IsReadOnly) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + RemoveKey(dictionary, missingKey); + Assert.False(dictionary.TryGetLastValue(missingKey, out var value)); + Assert.Equal(default, value); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void TryGetLastValue_PresentKeyReturnsCorrectValue(int count) + { + var dictionary = IReadOnlyValueListDictionaryFactory(count); + foreach (var pair in dictionary) + { + Assert.True(dictionary.TryGetLastValue(pair.Key, out var value)); + Assert.Equal(pair.Value.Last(), value); + } + } + + #endregion + + private void RemoveKey(IReadOnlyValueListDictionary dictionary, TKey key) + { + if (IsReadOnly) + throw new NotSupportedException("Test is read-only."); + if (dictionary is not IValueListDictionary mutable) + throw new InvalidOperationException("Could not cast to mutable version"); + mutable.Remove(key); + } + + private void AddValue(IReadOnlyValueListDictionary dictionary, TKey key, TValue value) + { + if (IsReadOnly) + throw new NotSupportedException("Test is read-only."); + if (dictionary is not IValueListDictionary mutable) + throw new InvalidOperationException("Could not cast to mutable version"); + + mutable.Add(key, value); + } + + private void ClearDict(IReadOnlyValueListDictionary dictionary) + { + if (IsReadOnly) + throw new NotSupportedException("Test is read-only."); + if (dictionary is not IValueListDictionary mutable) + throw new InvalidOperationException("Could not cast to mutable version"); + mutable.Clear(); + } + + private void RemoveValue(IReadOnlyValueListDictionary dictionary, TKey key, TValue value) + { + if (IsReadOnly) + throw new NotSupportedException("Test is read-only."); + if (dictionary is not IValueListDictionary mutable) + throw new InvalidOperationException("Could not cast to mutable version"); + mutable.Remove(key, value); + } + + // ReSharper disable once InconsistentNaming + public class KVPComparer : IEqualityComparer>> + { + public bool Equals(KeyValuePair> x, KeyValuePair> y) + { + if (!Equals(x.Key, y.Key)) + return false; + + if (x.Value.Count != y.Value.Count) + return false; + return !x.Value.Where((t, i) => !Equals(t, y.Value[i])).Any(); + } + + public int GetHashCode(KeyValuePair> obj) + { + var hashCode = new HashCode(); + + hashCode.Add(obj.Key); + foreach (var item in obj.Value) + hashCode.Add(item); + return hashCode.ToHashCode(); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/IValueListDictionaryTestBase.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/IValueListDictionaryTestBase.cs new file mode 100644 index 00000000..84a19006 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/IValueListDictionaryTestBase.cs @@ -0,0 +1,711 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AnakinRaW.CommonUtilities.Collections; +using AnakinRaW.CommonUtilities.Testing.EqualityComparers; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary; + +public abstract class IValueListDictionaryTestBase : IReadOnlyValueListDictionaryTestBase + where TKey : notnull +{ + protected override bool IsReadOnly => false; + + // ReSharper disable once InconsistentNaming + protected bool Keys_Values_Enumeration_ThrowsInvalidOperation_WhenParentModified => true; + + protected abstract IValueListDictionary IValueListDictionaryFactory(IEqualityComparer? comparer = null); + + protected virtual IValueListDictionary IValueListDictionaryFactory(int count) + { + var collection = IValueListDictionaryFactory(); + AddToCollection(collection, count); + return collection; + } + + protected override IReadOnlyValueListDictionary IReadOnlyValueListDictionaryFactory(int count) + { + return IValueListDictionaryFactory(count); + } + + #region Keys + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Keys_ModifyingTheDictionaryUpdatesTheCollection(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var keys = dictionary.Keys; + if (count > 0) + Assert.NotEmpty(keys); + dictionary.Clear(); + Assert.Empty(keys); + + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Keys_Enumeration_ParentDictionaryModifiedInvalidates(int count) + { + if (!IsReadOnly) + { + var dictionary = IValueListDictionaryFactory(count); + var keys = dictionary.Keys; + using var keysEnum = keys.GetEnumerator(); + dictionary.Add(GetNewKey(dictionary), CreateTValue(3432)); + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Keys_Values_Enumeration_ThrowsInvalidOperation_WhenParentModified) + { + Assert.Throws(() => keysEnum.MoveNext()); + Assert.Throws(() => keysEnum.Reset()); + } + else + { + if (keysEnum.MoveNext()) + { + _ = keysEnum.Current; + } + keysEnum.Reset(); + } + } + } + + #endregion + + #region Values + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Values_Enumeration_ParentDictionaryModifiedInvalidates(int count) + { + if (!IsReadOnly) + { + var dictionary = IValueListDictionaryFactory(count); + var values = dictionary.Values; + using var valuesEnum = values.GetEnumerator(); + dictionary.Add(GetNewKey(dictionary), CreateTValue(3432)); + if (count == 0 ? Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException : Keys_Values_Enumeration_ThrowsInvalidOperation_WhenParentModified) + { + Assert.Throws(() => valuesEnum.MoveNext()); + Assert.Throws(() => valuesEnum.Reset()); + } + else + { + if (valuesEnum.MoveNext()) + { + _ = valuesEnum.Current; + } + valuesEnum.Reset(); + } + } + } + + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Values_IncludeDuplicatesMultipleTimes(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var oldValueCount = dictionary.ValueCount; + var oldCount = dictionary.Count; + var seed = 431; + foreach (var pair in dictionary.ToList()) + { + var missingKey = CreateTKey(seed++); + while (dictionary.ContainsKey(missingKey)) + missingKey = CreateTKey(seed++); + dictionary.Add(missingKey, pair.Value.First()); + } + Assert.Equal(oldValueCount + oldCount, dictionary.Values.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Values_ModifyingTheDictionaryUpdatesTheCollection(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var values = dictionary.Values; + if (count > 0) + Assert.NotEmpty(values); + + dictionary.Clear(); + Assert.Empty(values); + } + + #endregion + + #region Add(TKey, TValue) + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_DefaultKey_DefaultValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var valueCoutBeforeAdd = dictionary.ValueCount; + var missingKey = default(TKey)!; + var value = default(TValue)!; + if (DefaultValueAllowed) + { + Assert.True(dictionary.Add(missingKey, value)); + Assert.Equal(valueCoutBeforeAdd + 1, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(value, dictionary[missingKey].First()); + Assert.Equal(value, dictionary[missingKey].Last()); + } + else + { + Assert.Throws(() => dictionary.Add(missingKey, value)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_DefaultKey_NonDefaultValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var valueCoutBeforeAdd = dictionary.ValueCount; + var missingKey = default(TKey)!; + var value = CreateTValue(1456); + if (DefaultValueAllowed) + { + Assert.True(dictionary.Add(missingKey, value)); + Assert.Equal(valueCoutBeforeAdd + 1, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(value, dictionary[missingKey].First()); + Assert.Equal(value, dictionary[missingKey].Last()); + } + else + { + Assert.Throws(() => dictionary.Add(missingKey, value)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_NonDefaultKey_DefaultValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var valueCoutBeforeAdd = dictionary.ValueCount; + var missingKey = GetNewKey(dictionary); + var value = default(TValue)!; + Assert.True(dictionary.Add(missingKey, value)); + Assert.Equal(valueCoutBeforeAdd + 1, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(value, dictionary[missingKey].First()); + Assert.Equal(value, dictionary[missingKey].Last()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_NonDefaultKey_NonDefaultValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var valueCoutBeforeAdd = dictionary.ValueCount; + var missingKey = GetNewKey(dictionary); + var value = CreateTValue(1342); + Assert.True(dictionary.Add(missingKey, value)); + Assert.Equal(valueCoutBeforeAdd + 1, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(value, dictionary[missingKey].First()); + Assert.Equal(value, dictionary[missingKey].Last()); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_DuplicateValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var seed = 321; + var duplicate = CreateTValue(seed++); + while (dictionary.Values.Contains(duplicate)) + duplicate = CreateTValue(seed++); + Assert.True(dictionary.Add(GetNewKey(dictionary), duplicate)); + Assert.True(dictionary.Add(GetNewKey(dictionary), duplicate)); + Assert.Equal(2, dictionary.Values.Count(value => value!.Equals(duplicate))); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_DuplicateKey_AddsToList(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var duplicateKey = GetNewKey(dictionary); + Assert.True(dictionary.Add(duplicateKey, CreateTValue(34251))); + Assert.Single(dictionary[duplicateKey]); + var valueCountBeforeSecondAdd = dictionary.ValueCount; + var countBeforeSecondAdd = dictionary.Count; + + Assert.False(dictionary.Add(duplicateKey, CreateTValue(134))); + Assert.Equal(2, dictionary[duplicateKey].Count); + Assert.Equal(countBeforeSecondAdd, dictionary.Count); + Assert.Equal(valueCountBeforeSecondAdd + 1, dictionary.ValueCount); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Add_DistinctValuesWithHashCollisions(int count) + { + var dictionary = IValueListDictionaryFactory(new ConstantHashCodeEqualityComparer(EqualityComparer.Default)); + AddToCollection(dictionary, count); + Assert.Equal(count, dictionary.Count); + } + + #endregion + + #region Remove(TKey) + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Remove_EveryKey(int count) + { + var dictionary = IValueListDictionaryFactory(count); + Assert.All(dictionary.Keys.ToList(), key => + { + Assert.True(dictionary.Remove(key)); + }); + Assert.Empty(dictionary); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Remove_ValidKeyNotContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.Remove(missingKey)); + Assert.Equal(count, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueList_Dictionary_Remove_ValidKeyContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + dictionary.Add(missingKey, CreateTValue(34251)); + Assert.True(dictionary.Remove(missingKey)); + Assert.Equal(count, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Remove_DefaultKeyNotContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + if (DefaultValueAllowed) + { + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + dictionary.Remove(missingKey); + Assert.False(dictionary.Remove(missingKey)); + } + else + { + Assert.Throws(() => dictionary.Remove(default!)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Remove_DefaultKeyContainedInDictionary(int count) + { + if (DefaultValueAllowed) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + dictionary.Add(missingKey, CreateTValue(5341)); + Assert.True(dictionary.Remove(missingKey)); + } + } + + #endregion + + #region Remove(TKey, TValue) + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_Everything(int count) + { + var dictionary = IValueListDictionaryFactory(count); + Assert.All(dictionary.Keys.ToList(), key => + { + foreach (var value in dictionary.GetValues(key).ToList()) + Assert.True(dictionary.Remove(key, value)); + }); + Assert.Empty(dictionary); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueList_Dictionary_RemoveKeyValue_ValidKeyNotContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + Assert.False(dictionary.Remove(missingKey, default!)); + Assert.Equal(count, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_ValidKeyContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + var value = CreateTValue(34251); + dictionary.Add(missingKey, value); + Assert.True(dictionary.Remove(missingKey, value)); + Assert.Equal(count, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_ValidKeyContainedInDictionary_ValueNotContained(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + var seed = 34251; + var value = CreateTValue(seed++)!; + + var missingValue = CreateTValue(seed++); + while (value.Equals(missingValue)) + missingValue = CreateTValue(seed++); + + dictionary.Add(missingKey, value); + Assert.False(dictionary.Remove(missingKey, missingValue)); + Assert.Equal(count + 1, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_ValidKeyContainedInDictionary_DuplicateValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = GetNewKey(dictionary); + var value = CreateTValue(34251); + + dictionary.Add(missingKey, value); + dictionary.Add(missingKey, value); + + Assert.True(dictionary.Remove(missingKey, value)); + Assert.Equal([value], dictionary.GetValues(missingKey)); + Assert.Equal(count + 1, dictionary.Count); + } + + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_DefaultKeyNotContainedInDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + if (DefaultValueAllowed) + { + var missingKey = default(TKey)!; + while (dictionary.ContainsKey(missingKey)) + dictionary.Remove(missingKey); + Assert.False(dictionary.Remove(missingKey, default!)); + } + else + { + Assert.Throws(() => dictionary.Remove(default!, default!)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveKeyValue_DefaultKeyContainedInDictionary(int count) + { + if (DefaultValueAllowed) + { + var dictionary = IValueListDictionaryFactory(count); + var missingKey = default(TKey)!; + var value = CreateTValue(5341); + dictionary.Add(missingKey, value); + Assert.True(dictionary.Remove(missingKey, value)); + } + } + + #endregion + + #region Clear + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Clear(int count) + { + var dictionary = IValueListDictionaryFactory(count); + dictionary.Clear(); + Assert.Equal(0, dictionary.ValueCount); + Assert.Equal(0, dictionary.Count); + Assert.Empty(dictionary.Keys); + Assert.Empty(dictionary.Values); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_Clear_Repeatedly(int count) + { + var dictionary = IValueListDictionaryFactory(count); + dictionary.Clear(); + dictionary.Clear(); + dictionary.Clear(); + Assert.Equal(0, dictionary.ValueCount); + Assert.Equal(0, dictionary.Count); + Assert.Empty(dictionary.Keys); + Assert.Empty(dictionary.Values); + } + + #endregion + + #region AddRange(TKey, IEnumerable) + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_NullKey_ThrowsArgumentNullException(int count) + { + if (!DefaultValueAllowed) + { + var dictionary = IValueListDictionaryFactory(count); + var values = new[] { CreateTValue(1), CreateTValue(2) }; + Assert.Throws(() => dictionary.AddRange(default!, values)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_NullValues_ThrowsArgumentNullException(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + Assert.Throws(() => dictionary.AddRange(key, null!)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_EmptyEnumerable_DoesNotModifyDictionary(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var valueCountBefore = dictionary.ValueCount; + var countBefore = dictionary.Count; + + Assert.False(dictionary.AddRange(key, [])); + + Assert.Equal(valueCountBefore, dictionary.ValueCount); + Assert.Equal(countBefore, dictionary.Count); + Assert.False(dictionary.ContainsKey(key)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_NewKey_MultipleValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var values = new[] { CreateTValue(1), CreateTValue(2), CreateTValue(3) }; + var valueCountBefore = dictionary.ValueCount; + + Assert.True(dictionary.AddRange(key, values)); + + Assert.Equal(valueCountBefore + 3, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(values, dictionary.GetValues(key)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_ExistingKey_MultipleValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var initialValue = CreateTValue(100); + dictionary.Add(key, initialValue); + + var valueCountBefore = dictionary.ValueCount; + var countBefore = dictionary.Count; + var values = new[] { CreateTValue(1), CreateTValue(2), CreateTValue(3) }; + + Assert.False(dictionary.AddRange(key, values)); + + Assert.Equal(valueCountBefore + 3, dictionary.ValueCount); + Assert.Equal(countBefore, dictionary.Count); + var expectedValues = new[] { initialValue }.Concat(values).ToArray(); + Assert.Equal(expectedValues, dictionary.GetValues(key)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_DefaultKey_MultipleValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = default(TKey)!; + var values = new[] { CreateTValue(1), CreateTValue(2) }; + + if (DefaultValueAllowed) + { + var valueCountBefore = dictionary.ValueCount; + Assert.True(dictionary.AddRange(key, values)); + Assert.Equal(valueCountBefore + 2, dictionary.ValueCount); + Assert.Equal(values, dictionary.GetValues(key)); + } + else + { + Assert.Throws(() => dictionary.AddRange(key, values)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_AddRange_SingleValue(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var values = new[] { CreateTValue(1) }; + var valueCountBefore = dictionary.ValueCount; + + Assert.True(dictionary.AddRange(key, values)); + + Assert.Equal(valueCountBefore + 1, dictionary.ValueCount); + Assert.Equal(count + 1, dictionary.Count); + Assert.Equal(values, dictionary.GetValues(key)); + } + + #endregion + + #region RemoveAll(TKey, Predicate) + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_NullKey_ThrowsArgumentNullException(int count) + { + if (!DefaultValueAllowed) + { + var dictionary = IValueListDictionaryFactory(count); + Assert.Throws(() => dictionary.RemoveAll(default!, _ => true)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_NullPredicate_ThrowsArgumentNullException(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + Assert.Throws(() => dictionary.RemoveAll(key, null!)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_KeyNotInDictionary_ReturnsZero(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var removed = dictionary.RemoveAll(key, _ => true); + Assert.Equal(0, removed); + Assert.Equal(count, dictionary.Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_RemoveSomeValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + dictionary.Add(key, CreateTValue(1)); + dictionary.Add(key, CreateTValue(2)); + dictionary.Add(key, CreateTValue(3)); + dictionary.Add(key, CreateTValue(4)); + + var valueCountBefore = dictionary.ValueCount; + var countBefore = dictionary.Count; + + var removed = dictionary.RemoveAll(key, v => v!.Equals(CreateTValue(2)) || v.Equals(CreateTValue(4))); + + Assert.Equal(2, removed); + Assert.Equal(valueCountBefore - 2, dictionary.ValueCount); + Assert.Equal(countBefore, dictionary.Count); + Assert.Equal(2, dictionary.GetValues(key).Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_RemoveAllValues_RemovesKey(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + dictionary.Add(key, CreateTValue(1)); + dictionary.Add(key, CreateTValue(2)); + dictionary.Add(key, CreateTValue(3)); + + var valueCountBefore = dictionary.ValueCount; + var countBefore = dictionary.Count; + + var removed = dictionary.RemoveAll(key, _ => true); + + Assert.Equal(3, removed); + Assert.Equal(valueCountBefore - 3, dictionary.ValueCount); + Assert.Equal(countBefore - 1, dictionary.Count); + Assert.False(dictionary.ContainsKey(key)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_PredicateMatchesNothing_ReturnsZero(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + dictionary.Add(key, CreateTValue(1)); + dictionary.Add(key, CreateTValue(2)); + + var valueCountBefore = dictionary.ValueCount; + var countBefore = dictionary.Count; + + var removed = dictionary.RemoveAll(key, _ => false); + + Assert.Equal(0, removed); + Assert.Equal(valueCountBefore, dictionary.ValueCount); + Assert.Equal(countBefore, dictionary.Count); + Assert.Equal(2, dictionary.GetValues(key).Count); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_DefaultKey(int count) + { + if (DefaultValueAllowed) + { + var dictionary = IValueListDictionaryFactory(count); + var key = default(TKey)!; + dictionary.Add(key, CreateTValue(1)); + dictionary.Add(key, CreateTValue(2)); + + var removed = dictionary.RemoveAll(key, v => v!.Equals(CreateTValue(1))); + + Assert.Equal(1, removed); + Assert.Single(dictionary.GetValues(key)); + } + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IValueListDictionary_RemoveAll_RemovesOnlyMatchingValues(int count) + { + var dictionary = IValueListDictionaryFactory(count); + var key = GetNewKey(dictionary); + var value1 = CreateTValue(1); + var value2 = CreateTValue(2); + var value3 = CreateTValue(3); + + dictionary.Add(key, value1); + dictionary.Add(key, value2); + dictionary.Add(key, value3); + dictionary.Add(key, value1); + + var removed = dictionary.RemoveAll(key, v => v!.Equals(value1)); + + Assert.Equal(2, removed); + Assert.Equal(2, dictionary.GetValues(key).Count); + Assert.All(dictionary.GetValues(key), v => Assert.NotEqual(value1, v)); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/ReadOnlyValueListDictionaryBaseTestSuite.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/ReadOnlyValueListDictionaryBaseTestSuite.cs new file mode 100644 index 00000000..981d5980 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/ReadOnlyValueListDictionaryBaseTestSuite.cs @@ -0,0 +1,180 @@ +using System; +using AnakinRaW.CommonUtilities.Collections; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary; + +public abstract class ReadOnlyValueListDictionaryBaseTestSuite : IReadOnlyValueListDictionaryTestBase + where TKey : notnull +{ + protected override bool DefaultValueAllowed => false; + + protected override bool IsReadOnly => true; + + protected virtual IValueListDictionary MutableValueListDictionaryFactory() + { + return new ValueListDictionary(); + } + + protected abstract ReadOnlyValueListDictionaryBase ReadOnlyValueListDictionaryFactory( + IReadOnlyValueListDictionary dictionary); + + protected ReadOnlyValueListDictionaryBase ReadOnlyValueListDictionaryFactory(int count) + { + var collection = MutableValueListDictionaryFactory(); + AddToCollection(collection, count); + return ReadOnlyValueListDictionaryFactory(collection); + } + + protected sealed override IReadOnlyValueListDictionary IReadOnlyValueListDictionaryFactory(int count) + { + return ReadOnlyValueListDictionaryFactory(count); + } + + protected override IEnumerable>> GenericIEnumerableFactory( + int count) + { + var collection = MutableValueListDictionaryFactory(); + AddToCollection(collection, count); + return ReadOnlyValueListDictionaryFactory(collection); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void CtorTests(int count) + { + var collection = MutableValueListDictionaryFactory(); + AddToCollection(collection, count); + var readOnlyDictionary = ReadOnlyValueListDictionaryFactory(collection); + + Assert.Equal(collection.Count, readOnlyDictionary.Count); + Assert.Equal(collection.ValueCount, readOnlyDictionary.ValueCount); + + VerifyReadOnlyValueListDictionary(readOnlyDictionary, collection); + VerifyReadOnlyValueListDictionary(ReadOnlyValueListDictionaryFactory(readOnlyDictionary), collection); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void SourceModificationsReflectedInReadOnlyDictionary(int count) + { + var collection = MutableValueListDictionaryFactory(); + AddToCollection(collection, count); + var readOnlyDictionary = ReadOnlyValueListDictionaryFactory(collection); + + Assert.Equal(collection.Count, readOnlyDictionary.Count); + Assert.Equal(collection.ValueCount, readOnlyDictionary.ValueCount); + + collection.Add(GetNewKey(collection), CreateTValue(4231)); + + Assert.Equal(collection.Count, readOnlyDictionary.Count); + Assert.Equal(collection.ValueCount, readOnlyDictionary.ValueCount); + } + + private static void VerifyReadOnlyValueListDictionary( + ReadOnlyValueListDictionaryBase readOnlyDictionary, + IValueListDictionary expectedDict) + { + Assert.Equal(expectedDict.ValueCount, readOnlyDictionary.ValueCount); + foreach (var key in expectedDict.Keys) + { + var expectedValue = expectedDict[key]; + Assert.Equal(expectedValue, readOnlyDictionary[key]); + } + VerifyGenericEnumerator(readOnlyDictionary, expectedDict); + + VerifyEnumerator(readOnlyDictionary, expectedDict); + } + + private static void VerifyGenericEnumerator( + ReadOnlyValueListDictionaryBase readOnlyDictionary, + IValueListDictionary expectedDict) + { + var enumerator = readOnlyDictionary.GetEnumerator(); + var iterations = 0; + var expectedCount = expectedDict.Count; + + var keys = expectedDict.Keys.ToList(); + + while (iterations < expectedCount && enumerator.MoveNext()) + { + var currentItem = enumerator.Current; + + // Verify we have not gotten more items then we expected + Assert.True(iterations < expectedCount, + "More items have been returned from the enumerator(" + iterations + " items) " + + "then are in the expectedElements(" + expectedCount + " items)"); + + var expectedKey = keys[iterations]; + + Assert.Equal(expectedKey, currentItem.Key); + Assert.Equal(expectedDict[expectedKey], currentItem.Value); + + // Verify Current always returns the same value every time it is called + for (var i = 0; i < 3; i++) + { + var tempItem = enumerator.Current; + Assert.Equal(currentItem, tempItem); + } + + iterations++; + } + + Assert.Equal(expectedCount, iterations); + + for (var i = 0; i < 3; i++) + { + Assert.False(enumerator.MoveNext(), + "Expected MoveNext to return false after" + iterations + " iterations"); + } + + enumerator.Dispose(); + } + + private static void VerifyEnumerator( + ReadOnlyValueListDictionaryBase readOnlyDictionary, + IValueListDictionary expectedDict) + { + IEnumerator enumerator = readOnlyDictionary.GetEnumerator(); + var iterations = 0; + var expectedCount = expectedDict.Count; + + var keys = expectedDict.Keys.ToList(); + + while ((iterations < expectedCount) && enumerator.MoveNext()) + { + var currentItem = (KeyValuePair>) enumerator.Current; + + // Verify we have not gotten more items then we expected + Assert.True(iterations < expectedCount, + "More items have been returned from the enumerator(" + iterations + " items) then are in the expectedElements(" + expectedCount + " items)"); + + var expectedKey = keys[iterations]; + + Assert.Equal(expectedKey, currentItem.Key); + Assert.Equal(expectedDict[expectedKey], currentItem.Value); + + // Verify Current always returns the same value every time it is called + for (var i = 0; i < 3; i++) + { + var tempItem = enumerator.Current; + Assert.Equal(currentItem, tempItem); + } + + iterations++; + } + + Assert.Equal(expectedCount, iterations); + + for (var i = 0; i < 3; i++) + { + Assert.False(enumerator.MoveNext(), "Expected MoveNext to return false after" + iterations + " iterations"); + } + + if (enumerator is IDisposable disposable) + disposable.Dispose(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryBaseTestSuite.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryBaseTestSuite.cs new file mode 100644 index 00000000..f16cc539 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryBaseTestSuite.cs @@ -0,0 +1,93 @@ +using AnakinRaW.CommonUtilities.Collections; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary; + +public abstract class ValueListDictionaryBaseTestSuite : IValueListDictionaryTestBase + where TKey : notnull + where TList : IList +{ + protected override IValueListDictionary IValueListDictionaryFactory(IEqualityComparer? comparer = null) + { + return ValueListDictionaryFactory(comparer); + } + + protected override IValueListDictionary IValueListDictionaryFactory(int count) + { + return ValueListDictionaryFactory(count); + } + + protected abstract ValueListDictionaryBase ValueListDictionaryFactory( + IEqualityComparer? comparer = null); + + protected virtual ValueListDictionaryBase ValueListDictionaryFactory(int count) + { + var collection = ValueListDictionaryFactory(); + AddToCollection(collection, count); + return collection; + } + + #region Clear + + [Fact] + public void Clear_OnEmptyCollection_DoesNotInvalidateEnumerator() + { + if (ModifyEnumeratorAllowed.HasFlag(ModifyOperation.Clear)) + { + var dictionary = IValueListDictionaryFactory(0); + IEnumerator valuesEnum = dictionary.GetEnumerator(); + + dictionary.Clear(); + Assert.Empty(dictionary); + Assert.False(valuesEnum.MoveNext()); + } + } + + #endregion + + #region Add + + [Fact] + public void Add_ItemAlreadyExists_DoesNotInvalidateEnumerator() + { + var dictionary = IValueListDictionaryFactory(0); + var key = CreateTKey(123); + var value = CreateTValue(123); + + dictionary.Add(key, value); + + IEnumerator valuesEnum = dictionary.GetEnumerator(); + dictionary.Add(key, value); + + Assert.True(valuesEnum.MoveNext()); + } + + #endregion + + #region IReadOnlyValueListDictionary.Keys & Values + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IReadOnlyValueListDictionary_Keys_ContainsAllCorrectKeys(int count) + { + var dictionary = ValueListDictionaryFactory(count); + var expected = dictionary.Select(pair => pair.Key); + IEnumerable keys = ((IReadOnlyValueListDictionary)dictionary).Keys; + Assert.True(expected.SequenceEqual(keys)); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void IReadOnlyValueListDictionary_Values_ContainsAllCorrectValues(int count) + { + var dictionary = ValueListDictionaryFactory(count); + var expected = dictionary.SelectMany(pair => pair.Value); + IEnumerable values = ((IReadOnlyValueListDictionary)dictionary).Values; + Assert.True(expected.SequenceEqual(values)); + } + + #endregion +} \ No newline at end of file diff --git a/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryTestSuite.Keys_Values.cs b/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryTestSuite.Keys_Values.cs new file mode 100644 index 00000000..53d46024 --- /dev/null +++ b/src/CommonUtilities/test/Collections/ValueListDictionary/ValueListDictionaryTestSuite.Keys_Values.cs @@ -0,0 +1,137 @@ +using AnakinRaW.CommonUtilities.Collections; +using AnakinRaW.CommonUtilities.Testing.Collections; +using System; +using System.Collections.Generic; +using Xunit; + +namespace AnakinRaW.CommonUtilities.Test.Collections.ValueListDictionary; + +public abstract class ValueListDictionary_Keys_Values_CollectionTestSuite : ICollectionTestSuite +{ + public enum CollectionSelector + { + Keys, + Values + } + + protected abstract CollectionSelector Selector { get; } + + protected sealed override bool IsReadOnly => true; + protected sealed override bool Enumerator_Empty_UsesSingletonInstance => true; + protected sealed override bool Enumerator_Empty_ModifiedDuringEnumeration_ThrowsInvalidOperationException => false; + protected sealed override bool Enumerator_Empty_Current_UndefinedOperation_Throws => true; + protected sealed override bool NonGenericEnumerator_Empty_Current_UndefinedOperation_Throw => true; + protected sealed override bool NonGenericEnumerator_Current_UndefinedOperation_Throws => true; + + protected sealed override IEnumerable GetModifyEnumerables(ModifyOperation operations) => new List(); + + protected sealed override ICollection GenericICollectionFactory() + { + var dictionary = ValueListDictionaryFactory(); + return Selector is CollectionSelector.Keys + ? dictionary.Keys + : dictionary.Values; + } + + protected sealed override ICollection GenericICollectionFactory(int count) + { + var mutableDictionary = MutableValueListDictionaryFactory(); + Populate(mutableDictionary, count); + + var dictionary = ValueListDictionaryFactory(mutableDictionary); + return Selector is CollectionSelector.Keys + ? dictionary.Keys + : dictionary.Values; + } + + protected sealed override string CreateT(int seed) + { + var stringLength = seed % 10 + 5; + var rand = new Random(seed); + var bytes = new byte[stringLength]; + rand.NextBytes(bytes); + return Convert.ToBase64String(bytes); + } + + protected abstract IReadOnlyValueListDictionary ValueListDictionaryFactory(); + + protected abstract IReadOnlyValueListDictionary ValueListDictionaryFactory( + IValueListDictionary dictionary); + + protected abstract void Populate(IValueListDictionary dictionary, int count); + + protected virtual IValueListDictionary MutableValueListDictionaryFactory() + { + return new ValueListDictionary(); + } + + [Theory] + [MemberData(nameof(ValidCollectionSizes))] + public void ValueListDictionary_KeyOrValueCollection_GetEnumerator(int count) + { + var mutable = MutableValueListDictionaryFactory(); + var seed = 13453; + while (mutable.ValueCount < count) + mutable.Add(CreateT(seed++), CreateT(seed++)); + var dictionary = ValueListDictionaryFactory(mutable); + if (Selector is CollectionSelector.Keys) + { + using var _ = dictionary.Keys.GetEnumerator(); + } + else + { + using var _ = dictionary.Values.GetEnumerator(); + } + } +} + +public abstract class ValueListDictionary_Keys_TestSuite : ValueListDictionary_Keys_Values_CollectionTestSuite +{ + protected sealed override CollectionSelector Selector => CollectionSelector.Keys; + protected sealed override bool DefaultValueAllowed => false; + protected sealed override bool DuplicateValuesAllowed => false; + + protected sealed override void Populate(IValueListDictionary dictionary, int count) + { + var seed = 13453; + var random = new Random(); + for (var i = 0; i < count; i++) + { + var key = CreateT(seed++); + dictionary.Add(key, CreateT(seed++)); + while (random.Next() % 2 == 0) + dictionary.Add(key, CreateT(seed++)); + } + } +} + +public abstract class ValueListDictionary_Values_TestSuite : ValueListDictionary_Keys_Values_CollectionTestSuite +{ + protected sealed override CollectionSelector Selector => CollectionSelector.Values; + + protected sealed override bool DefaultValueAllowed => true; + protected sealed override bool DuplicateValuesAllowed => true; + + protected override void Populate(IValueListDictionary dictionary, int count) + { + var seed = 13453; + var random = new Random(seed); + + var valuesAdded = 0; + while (valuesAdded < count) + { + var key = CreateT(seed++); + + // Add first value for this key + dictionary.Add(key, CreateT(seed++)); + valuesAdded++; + + // Randomly add more values for the same key, but don't exceed count + while (valuesAdded < count && random.Next() % 2 == 0) + { + dictionary.Add(key, CreateT(seed++)); + valuesAdded++; + } + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities/test/CommonUtilities.Test.csproj b/src/CommonUtilities/test/CommonUtilities.Test.csproj index 39482011..c8ad3ad9 100644 --- a/src/CommonUtilities/test/CommonUtilities.Test.csproj +++ b/src/CommonUtilities/test/CommonUtilities.Test.csproj @@ -5,6 +5,7 @@ $(TargetFrameworks);net481 false true + Exe @@ -14,15 +15,13 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -31,11 +30,19 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + - + + + + + \ No newline at end of file diff --git a/src/CommonUtilities/test/Extensions/EncodingExtensionsTest.cs b/src/CommonUtilities/test/Extensions/EncodingExtensionsTest.cs index dc35d933..edacfd13 100644 --- a/src/CommonUtilities/test/Extensions/EncodingExtensionsTest.cs +++ b/src/CommonUtilities/test/Extensions/EncodingExtensionsTest.cs @@ -145,8 +145,10 @@ public void EncodeString_NullArgs_Throws() ForEachEncoding(e => { + // ReSharper disable RedundantCast Assert.Throws(() => e.EncodeString((string)null!)); Assert.Throws(() => e.EncodeString((string)null!, 0)); + // ReSharper restore RedundantCast }); } diff --git a/src/CommonUtilities/test/Hashing/HashingServiceTest.cs b/src/CommonUtilities/test/Hashing/HashingServiceTest.cs index 8082c671..f28a631b 100644 --- a/src/CommonUtilities/test/Hashing/HashingServiceTest.cs +++ b/src/CommonUtilities/test/Hashing/HashingServiceTest.cs @@ -10,7 +10,7 @@ using Xunit; using System.Collections.Generic; using System.Globalization; -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER using System.Security.Cryptography; #endif @@ -81,10 +81,10 @@ public async Task GetHashAsync_ProviderNotFound_ThrowsHashProviderNotFoundExcept var someDestination = new byte[1]; var someStream = new MemoryStream(someSource); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), notExistingProvider)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), notExistingProvider, TestContext.Current.CancellationToken)); } @@ -115,8 +115,8 @@ public async Task GetHashAsync_AlwaysOneProvider_DestinationTooShort_ThrowsIndex var someDestination = Array.Empty(); var someStream = new MemoryStream(someSource); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), provider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), provider)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), provider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), provider, TestContext.Current.CancellationToken)); } [Theory] @@ -181,10 +181,10 @@ public async Task GetHashAsync_WrongOutputSizeProvider_HashWrongSize_ThrowsInval var someDestination = new byte[2]; var someStream = new MemoryStream(someSource); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), notExistingProvider)); - await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, notExistingProvider)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), someDestination.AsMemory(), notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, someDestination.AsMemory(), notExistingProvider, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await _hashingService.GetHashAsync(someStream, notExistingProvider, TestContext.Current.CancellationToken)); } @@ -240,14 +240,14 @@ public async Task GetHashAsync_AlwaysOneProvider() var expectedHashExact = new byte[] { 1 }; var expectedHashJoint = new byte[] { 1, 0 }; - Assert.Equal(expectedHashExact, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), provider)); + Assert.Equal(expectedHashExact, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), provider, TestContext.Current.CancellationToken)); - Assert.Equal(1, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), destination.AsMemory(), provider)); + Assert.Equal(1, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), destination.AsMemory(), provider, TestContext.Current.CancellationToken)); Assert.Equal(expectedHashJoint, destination); - Assert.Equal(expectedHashExact, await _hashingService.GetHashAsync(someStream, provider)); + Assert.Equal(expectedHashExact, await _hashingService.GetHashAsync(someStream, provider, TestContext.Current.CancellationToken)); - Assert.Equal(1, await _hashingService.GetHashAsync(someStream, destination.AsMemory(), provider)); + Assert.Equal(1, await _hashingService.GetHashAsync(someStream, destination.AsMemory(), provider, TestContext.Current.CancellationToken)); Assert.Equal(expectedHashJoint, destination); } @@ -258,7 +258,7 @@ public async Task GetHashAsync_AlwaysOneProvider() [MemberData(nameof(ProviderHashTestData_SHA256))] [MemberData(nameof(ProviderHashTestData_SHA384))] [MemberData(nameof(ProviderHashTestData_SHA512))] -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [MemberData(nameof(ProviderHashTestData_SHA3_256))] [MemberData(nameof(ProviderHashTestData_SHA3_384))] [MemberData(nameof(ProviderHashTestData_SHA3_512))] @@ -308,7 +308,7 @@ public void GetHash_DefaultProviders(HashTypeKey hashType, string input, string [MemberData(nameof(ProviderHashTestData_SHA256))] [MemberData(nameof(ProviderHashTestData_SHA384))] [MemberData(nameof(ProviderHashTestData_SHA512))] -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER [MemberData(nameof(ProviderHashTestData_SHA3_256))] [MemberData(nameof(ProviderHashTestData_SHA3_384))] [MemberData(nameof(ProviderHashTestData_SHA3_512))] @@ -324,16 +324,16 @@ public async Task GetHashAsync_DefaultProviders(HashTypeKey hashType, string inp var someStream = new MemoryStream(someSource); var destination = new byte[expectedSize]; - Assert.Equal(expectedHash, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), hashType)); + Assert.Equal(expectedHash, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), hashType, TestContext.Current.CancellationToken)); - Assert.Equal(expectedSize, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), destination.AsMemory(), hashType)); + Assert.Equal(expectedSize, await _hashingService.GetHashAsync(_fileSystem.FileInfo.New("test.txt"), destination.AsMemory(), hashType, TestContext.Current.CancellationToken)); Assert.Equal(expectedHash, destination); - Assert.Equal(expectedHash, await _hashingService.GetHashAsync(someStream, hashType)); + Assert.Equal(expectedHash, await _hashingService.GetHashAsync(someStream, hashType, TestContext.Current.CancellationToken)); someStream.Seek(0, SeekOrigin.Begin); - Assert.Equal(expectedSize, await _hashingService.GetHashAsync(someStream, destination.AsMemory(), hashType)); + Assert.Equal(expectedSize, await _hashingService.GetHashAsync(someStream, destination.AsMemory(), hashType, TestContext.Current.CancellationToken)); Assert.Equal(expectedHash, destination); } @@ -387,7 +387,7 @@ public static IEnumerable ProviderKnownHashTypes() yield return [HashTypeKey.SHA256]; yield return [HashTypeKey.SHA384]; yield return [HashTypeKey.SHA512]; -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER if (SHA3_256.IsSupported) yield return [HashTypeKey.SHA3_256]; if (SHA3_384.IsSupported) @@ -397,7 +397,7 @@ public static IEnumerable ProviderKnownHashTypes() #endif } -#if NET8_0_OR_GREATER +#if NET10_0_OR_GREATER public static IEnumerable ProviderHashTestData_SHA3_256() { diff --git a/src/CommonUtilities/test/ThrowHelperTest.cs b/src/CommonUtilities/test/ThrowHelperTest.cs index 177c5213..867a6ef3 100644 --- a/src/CommonUtilities/test/ThrowHelperTest.cs +++ b/src/CommonUtilities/test/ThrowHelperTest.cs @@ -1,7 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; -using AnakinRaW.CommonUtilities.Testing; +using AnakinRaW.CommonUtilities.Testing.Extensions; using Xunit; namespace AnakinRaW.CommonUtilities.Test; @@ -22,29 +22,31 @@ public static void ThrowIfNullOrEmpty_ThrowsForInvalidInput() ThrowHelper.ThrowIfNullOrEmpty("abc", "something"); } + // ReSharper disable AccessToModifiedClosure [Fact] public static void ThrowIfNullOrEmpty_UsesArgumentExpression_ParameterNameMatches() { - string someString = null; + string? someString = null; AssertExtensions.Throws(nameof(someString), () => ThrowHelper.ThrowIfNullOrEmpty(someString)); someString = ""; - AssertExtensions.Throws(nameof(someString), () => ThrowHelper.ThrowIfNullOrEmpty(someString)); + AssertExtensions.Throws(nameof(someString), () => ThrowHelper.ThrowIfNullOrEmpty(someString!)); someString = "abc"; ThrowHelper.ThrowIfNullOrEmpty(someString); } + // ReSharper restore AccessToModifiedClosure [Fact] public static void ThrowIfCollectionNullOrEmpty_ThrowsForInvalidInput() { AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty(null, null)); - AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null, null)); - AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null, null)); + AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null!, null)); + AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null!, null)); AssertExtensions.Throws("something", () => ThrowHelper.ThrowIfCollectionNullOrEmpty(null, "something")); - AssertExtensions.Throws("something", () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null, "something")); - AssertExtensions.Throws("something", () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null, "something")); + AssertExtensions.Throws("something", () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null!, "something")); + AssertExtensions.Throws("something", () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)null!, "something")); AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)new List(), null)); AssertExtensions.Throws(null, () => ThrowHelper.ThrowIfCollectionNullOrEmpty((IList)new List(), null)); diff --git a/version.json b/version.json index b5da4568..1d8d59f7 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.3", + "version": "13.0", "assemblyVersion": { "precision": "major" },