From c6407458c2e742f0ac4fb41df9b9807969a64464 Mon Sep 17 00:00:00 2001 From: Kori Francis Date: Mon, 2 Feb 2026 19:59:55 -0500 Subject: [PATCH] Add tests --- .gitignore | 5 +- CLAUDE.md | 60 +++ src/ConcurrentCollections.sln | 26 + .../CollectionInterfaceTests.cs | 205 ++++++++ .../ConcurrencyTests.cs | 358 +++++++++++++ .../ConcurrentHashSet.Tests.csproj | 18 + .../ConstructorTests.cs | 217 ++++++++ .../CoreOperationsTests.cs | 439 ++++++++++++++++ .../EdgeCaseAndGrowthTests.cs | 486 ++++++++++++++++++ .../EnumeratorTests.cs | 213 ++++++++ .../InternalsCoverageTests.cs | 175 +++++++ 11 files changed, 2201 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 src/ConcurrentHashSet.Tests/CollectionInterfaceTests.cs create mode 100644 src/ConcurrentHashSet.Tests/ConcurrencyTests.cs create mode 100644 src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj create mode 100644 src/ConcurrentHashSet.Tests/ConstructorTests.cs create mode 100644 src/ConcurrentHashSet.Tests/CoreOperationsTests.cs create mode 100644 src/ConcurrentHashSet.Tests/EdgeCaseAndGrowthTests.cs create mode 100644 src/ConcurrentHashSet.Tests/EnumeratorTests.cs create mode 100644 src/ConcurrentHashSet.Tests/InternalsCoverageTests.cs diff --git a/.gitignore b/.gitignore index b54a59f..1c6733d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ bin/ obj/ -ConcurrentCollections.xml \ No newline at end of file +ConcurrentCollections.xml +*.coverage +.claude/settings.local.json +coverage.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f256f32 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ConcurrentHashSet is a thread-safe hash-based set (`ConcurrentHashSet`) for .NET, modeled after `ConcurrentDictionary`. It lives in the `ConcurrentCollections` namespace with assembly name `ConcurrentCollections`. Published as the `ConcurrentHashSet` NuGet package. + +## Build Commands + +```bash +# Build (solution is under src/) +dotnet build src/ConcurrentCollections.sln -c Release + +# Build specific framework target +dotnet build src/ConcurrentHashSet/ConcurrentHashSet.csproj -f netstandard2.0 -c Release +``` + +NuGet package is auto-generated on build (`GeneratePackageOnBuild=True`) and output to `src/ConcurrentHashSet/bin/Release/`. + +## Test Commands + +Tests use TUnit framework (`src/ConcurrentHashSet.Tests/`). TUnit uses Microsoft.Testing.Platform (not VSTest), so on .NET 10+ use `dotnet run`: + +```bash +# Run all tests +dotnet run --project src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj -c Release + +# On .NET 9 SDK, dotnet test also works +dotnet test src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj -c Release +``` + +## Build Constraints + +Defined in `src/Directory.Build.props`: +- **TreatWarningsAsErrors** is enabled — all warnings are build errors +- **Nullable reference types** are enabled +- **Latest C# language version** is used + +## Target Frameworks + +Multi-targets: `netstandard1.0`, `netstandard2.0`, `net461`. Code uses conditional compilation (`#if`) for nullable attribute polyfills on older targets (see `NullableAttributes.cs`). + +## Architecture + +The entire implementation is in two files under `src/ConcurrentHashSet/`: + +- **ConcurrentHashSet.cs** (~900 lines) — The single public type `ConcurrentHashSet` implementing `IReadOnlyCollection` and `ICollection`. Uses lock-per-segment concurrency with linked-list buckets, mirroring `ConcurrentDictionary`'s internal design. Contains three nested types: + - `Tables` (private class) — holds bucket array, lock array, and per-lock counts + - `Node` (private class) — linked list node with cached hashcode + - `Enumerator` (public struct) — allocation-free enumerator using a state machine with goto-based transitions + +- **NullableAttributes.cs** — Polyfill for `MaybeNullWhenAttribute`, conditionally compiled only for targets lacking it. + +## Key Design Decisions + +- Concurrency model: lock-per-segment (up to 1024 locks), with `Volatile.Read/Write` for lock-free reads in hot paths like `Contains` and `TryGetValue` +- No set operations (union, intersect, etc.) — only per-item operations (`Add`, `TryRemove`, `Contains`, `TryGetValue`) +- Struct enumerator avoids heap allocation; does not snapshot — allows concurrent modification during iteration +- Default concurrency level is `Environment.ProcessorCount`; default capacity is 31 buckets diff --git a/src/ConcurrentCollections.sln b/src/ConcurrentCollections.sln index 295c310..3889c62 100644 --- a/src/ConcurrentCollections.sln +++ b/src/ConcurrentCollections.sln @@ -5,16 +5,42 @@ VisualStudioVersion = 15.0.26228.9 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConcurrentHashSet", "ConcurrentHashSet\ConcurrentHashSet.csproj", "{58063048-C7B0-4F4D-B27E-1ED5F9789AF7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConcurrentHashSet.Tests", "ConcurrentHashSet.Tests\ConcurrentHashSet.Tests.csproj", "{4820316A-E280-4829-A2EE-FA74C52DEB0B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|x64.ActiveCfg = Debug|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|x64.Build.0 = Debug|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|x86.ActiveCfg = Debug|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Debug|x86.Build.0 = Debug|Any CPU {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|Any CPU.ActiveCfg = Release|Any CPU {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|Any CPU.Build.0 = Release|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|x64.ActiveCfg = Release|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|x64.Build.0 = Release|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|x86.ActiveCfg = Release|Any CPU + {58063048-C7B0-4F4D-B27E-1ED5F9789AF7}.Release|x86.Build.0 = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|x64.ActiveCfg = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|x64.Build.0 = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|x86.ActiveCfg = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Debug|x86.Build.0 = Debug|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|Any CPU.Build.0 = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|x64.ActiveCfg = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|x64.Build.0 = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|x86.ActiveCfg = Release|Any CPU + {4820316A-E280-4829-A2EE-FA74C52DEB0B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ConcurrentHashSet.Tests/CollectionInterfaceTests.cs b/src/ConcurrentHashSet.Tests/CollectionInterfaceTests.cs new file mode 100644 index 0000000..b7bbc28 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/CollectionInterfaceTests.cs @@ -0,0 +1,205 @@ +using ConcurrentCollections; +using System.Collections; + +namespace ConcurrentHashSet.Tests; + +public class ICollectionAddTests +{ + [Test] + public async Task ICollection_Add_Adds_Item() + { + ICollection set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.Count).IsEqualTo(1); + await Assert.That(set.Contains(42)).IsTrue(); + } + + [Test] + public async Task ICollection_Add_Duplicate_Does_Not_Throw() + { + ICollection set = new ConcurrentHashSet(); + set.Add(42); + set.Add(42); // Should not throw, just silently ignores + + await Assert.That(set.Count).IsEqualTo(1); + } +} + +public class ICollectionRemoveTests +{ + [Test] + public async Task ICollection_Remove_Existing_Returns_True() + { + ICollection set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.Remove(42)).IsTrue(); + } + + [Test] + public async Task ICollection_Remove_NonExisting_Returns_False() + { + ICollection set = new ConcurrentHashSet(); + + await Assert.That(set.Remove(42)).IsFalse(); + } +} + +public class IsReadOnlyTests +{ + [Test] + public async Task IsReadOnly_Returns_False() + { + ICollection set = new ConcurrentHashSet(); + + await Assert.That(set.IsReadOnly).IsFalse(); + } +} + +public class CopyToTests +{ + [Test] + public async Task CopyTo_Copies_All_Items() + { + ICollection set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + set.Add(3); + + var array = new int[3]; + set.CopyTo(array, 0); + + // Items may not be in insertion order, but all must be present + var sorted = array.OrderBy(x => x).ToArray(); + await Assert.That(sorted[0]).IsEqualTo(1); + await Assert.That(sorted[1]).IsEqualTo(2); + await Assert.That(sorted[2]).IsEqualTo(3); + } + + [Test] + public async Task CopyTo_With_Offset() + { + ICollection set = new ConcurrentHashSet(); + set.Add(10); + set.Add(20); + + var array = new int[5]; + set.CopyTo(array, 2); + + // First two should be 0 (default), items at indices 2-3 + await Assert.That(array[0]).IsEqualTo(0); + await Assert.That(array[1]).IsEqualTo(0); + + var copied = array.Skip(2).Where(x => x != 0).OrderBy(x => x).ToArray(); + await Assert.That(copied).Contains(10); + await Assert.That(copied).Contains(20); + } + + [Test] + public async Task CopyTo_Null_Array_Throws() + { + ICollection set = new ConcurrentHashSet(); + + await Assert.That(() => set.CopyTo(null!, 0)) + .Throws(); + } + + [Test] + public async Task CopyTo_Negative_Index_Throws() + { + ICollection set = new ConcurrentHashSet(); + + await Assert.That(() => set.CopyTo(new int[5], -1)) + .Throws(); + } + + [Test] + public async Task CopyTo_Insufficient_Space_Throws() + { + ICollection set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + set.Add(3); + + await Assert.That(() => set.CopyTo(new int[2], 0)) + .Throws(); + } + + [Test] + public async Task CopyTo_Insufficient_Space_Due_To_Offset_Throws() + { + ICollection set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + + await Assert.That(() => set.CopyTo(new int[3], 2)) + .Throws(); + } + + [Test] + public async Task CopyTo_Empty_Set_Does_Nothing() + { + ICollection set = new ConcurrentHashSet(); + var array = new int[] { 99, 99, 99 }; + set.CopyTo(array, 0); + + await Assert.That(array[0]).IsEqualTo(99); + await Assert.That(array[1]).IsEqualTo(99); + await Assert.That(array[2]).IsEqualTo(99); + } + + [Test] + public async Task CopyTo_Exact_Size_Array() + { + ICollection set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + + var array = new int[2]; + set.CopyTo(array, 0); + + var sorted = array.OrderBy(x => x).ToArray(); + await Assert.That(sorted[0]).IsEqualTo(1); + await Assert.That(sorted[1]).IsEqualTo(2); + } +} + +public class IEnumerableInterfaceTests +{ + [Test] + public async Task IEnumerable_Generic_GetEnumerator_Works() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + IEnumerable enumerable = set; + + var items = new List(); + foreach (var item in enumerable) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(3); + await Assert.That(items).Contains(1); + await Assert.That(items).Contains(2); + await Assert.That(items).Contains(3); + } + + [Test] + public async Task IEnumerable_NonGeneric_GetEnumerator_Works() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + IEnumerable enumerable = set; + + var items = new List(); + foreach (int item in enumerable) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(3); + await Assert.That(items).Contains(1); + await Assert.That(items).Contains(2); + await Assert.That(items).Contains(3); + } +} diff --git a/src/ConcurrentHashSet.Tests/ConcurrencyTests.cs b/src/ConcurrentHashSet.Tests/ConcurrencyTests.cs new file mode 100644 index 0000000..283dcd0 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/ConcurrencyTests.cs @@ -0,0 +1,358 @@ +using ConcurrentCollections; + +namespace ConcurrentHashSet.Tests; + +public class ConcurrentAddTests +{ + [Test] + public async Task Concurrent_Adds_All_Unique_Items_Present() + { + var set = new ConcurrentHashSet(); + const int itemCount = 10000; + + var tasks = Enumerable.Range(0, itemCount) + .Select(i => Task.Run(() => set.Add(i))); + await Task.WhenAll(tasks); + + await Assert.That(set.Count).IsEqualTo(itemCount); + for (var i = 0; i < itemCount; i++) + { + await Assert.That(set.Contains(i)).IsTrue(); + } + } + + [Test] + public async Task Concurrent_Adds_Duplicates_Return_Correctly() + { + var set = new ConcurrentHashSet(); + const int threadCount = 100; + var results = new bool[threadCount]; + + // All threads try to add the same item + var tasks = Enumerable.Range(0, threadCount) + .Select(i => Task.Run(() => results[i] = set.Add(42))); + await Task.WhenAll(tasks); + + // Exactly one should return true + await Assert.That(results.Count(r => r)).IsEqualTo(1); + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task Concurrent_Adds_From_Multiple_Threads() + { + var set = new ConcurrentHashSet(); + const int itemsPerThread = 1000; + const int threadCount = 8; + + var tasks = Enumerable.Range(0, threadCount) + .Select(t => Task.Run(() => + { + for (var i = t * itemsPerThread; i < (t + 1) * itemsPerThread; i++) + { + set.Add(i); + } + })); + await Task.WhenAll(tasks); + + await Assert.That(set.Count).IsEqualTo(threadCount * itemsPerThread); + } +} + +public class ConcurrentRemoveTests +{ + [Test] + public async Task Concurrent_Removes_All_Succeed_For_Existing_Items() + { + const int itemCount = 10000; + var set = new ConcurrentHashSet(Enumerable.Range(0, itemCount)); + + var tasks = Enumerable.Range(0, itemCount) + .Select(i => Task.Run(() => set.TryRemove(i))); + var results = await Task.WhenAll(tasks); + + await Assert.That(results.All(r => r)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(0); + } + + [Test] + public async Task Concurrent_Remove_Same_Item_Only_One_Succeeds() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + const int threadCount = 100; + var results = new bool[threadCount]; + + var tasks = Enumerable.Range(0, threadCount) + .Select(i => Task.Run(() => results[i] = set.TryRemove(42))); + await Task.WhenAll(tasks); + + await Assert.That(results.Count(r => r)).IsEqualTo(1); + await Assert.That(set.IsEmpty).IsTrue(); + } +} + +public class ConcurrentMixedOperationsTests +{ + [Test] + public async Task Concurrent_Adds_And_Removes() + { + var set = new ConcurrentHashSet(); + const int iterations = 5000; + + var addTasks = Enumerable.Range(0, iterations) + .Select(i => Task.Run(() => set.Add(i))); + var removeTasks = Enumerable.Range(0, iterations / 2) + .Select(i => Task.Run(() => set.TryRemove(i))); + + await Task.WhenAll(addTasks.Concat(removeTasks)); + + // We can't know the exact count due to ordering, but it should be non-negative + await Assert.That(set.Count).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task Concurrent_Adds_And_Contains() + { + var set = new ConcurrentHashSet(); + const int itemCount = 5000; + var addsDone = new TaskCompletionSource(); + + var addTask = Task.Run(() => + { + for (var i = 0; i < itemCount; i++) + { + set.Add(i); + } + addsDone.SetResult(); + }); + + var containsTask = Task.Run(async () => + { + // Keep checking contains while adds are happening + while (!addsDone.Task.IsCompleted) + { + // Should not throw + set.Contains(0); + set.Contains(itemCount / 2); + set.Contains(itemCount - 1); + await Task.Yield(); + } + }); + + await Task.WhenAll(addTask, containsTask); + + await Assert.That(set.Count).IsEqualTo(itemCount); + } + + [Test] + public async Task Concurrent_Adds_Removes_Contains_Clear() + { + var set = new ConcurrentHashSet(); + const int iterations = 2000; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var tasks = new List + { + Task.Run(() => + { + for (var i = 0; i < iterations && !cts.Token.IsCancellationRequested; i++) + set.Add(i); + }), + Task.Run(() => + { + for (var i = 0; i < iterations && !cts.Token.IsCancellationRequested; i++) + set.TryRemove(i % 100); + }), + Task.Run(() => + { + for (var i = 0; i < iterations && !cts.Token.IsCancellationRequested; i++) + set.Contains(i % 500); + }), + Task.Run(() => + { + for (var i = 0; i < 5 && !cts.Token.IsCancellationRequested; i++) + { + Thread.Sleep(10); + set.Clear(); + } + }) + }; + + // Should complete without exceptions + await Task.WhenAll(tasks); + await Assert.That(set.Count).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task Concurrent_Count_During_Modifications() + { + var set = new ConcurrentHashSet(); + const int iterations = 2000; + + var modifyTask = Task.Run(() => + { + for (var i = 0; i < iterations; i++) + { + set.Add(i); + if (i % 3 == 0) set.TryRemove(i / 2); + } + }); + + var countTask = Task.Run(() => + { + for (var i = 0; i < iterations; i++) + { + var count = set.Count; + // Count should always be non-negative + if (count < 0) throw new Exception($"Count was negative: {count}"); + } + }); + + await Task.WhenAll(modifyTask, countTask); + await Assert.That(set.Count).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task Concurrent_IsEmpty_During_Modifications() + { + var set = new ConcurrentHashSet(); + const int iterations = 2000; + + var modifyTask = Task.Run(() => + { + for (var i = 0; i < iterations; i++) + { + set.Add(i % 10); + set.TryRemove(i % 10); + } + }); + + var isEmptyTask = Task.Run(() => + { + for (var i = 0; i < iterations; i++) + { + // Should not throw + _ = set.IsEmpty; + } + }); + + await Task.WhenAll(modifyTask, isEmptyTask); + // If we reached here, no exceptions occurred during concurrent IsEmpty checks + } +} + +public class ConcurrentEnumerationTests +{ + [Test] + public async Task Enumerate_During_Concurrent_Adds() + { + var set = new ConcurrentHashSet(Enumerable.Range(0, 100)); + var addsDone = new TaskCompletionSource(); + + var addTask = Task.Run(() => + { + for (var i = 100; i < 1000; i++) + { + set.Add(i); + } + addsDone.SetResult(); + }); + + var enumerateTask = Task.Run(async () => + { + while (!addsDone.Task.IsCompleted) + { + // Enumeration should not throw during concurrent modification + foreach (var _ in set) { } + await Task.Yield(); + } + }); + + await Task.WhenAll(addTask, enumerateTask); + await Assert.That(set.Count).IsGreaterThan(0); + } + + [Test] + public async Task Enumerate_During_Concurrent_Removes() + { + var set = new ConcurrentHashSet(Enumerable.Range(0, 1000)); + var removesDone = new TaskCompletionSource(); + + var removeTask = Task.Run(() => + { + for (var i = 0; i < 1000; i++) + { + set.TryRemove(i); + } + removesDone.SetResult(); + }); + + var enumerateTask = Task.Run(async () => + { + while (!removesDone.Task.IsCompleted) + { + foreach (var _ in set) { } + await Task.Yield(); + } + }); + + await Task.WhenAll(removeTask, enumerateTask); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Multiple_Concurrent_Enumerations() + { + var set = new ConcurrentHashSet(Enumerable.Range(0, 100)); + + var tasks = Enumerable.Range(0, 10) + .Select(_ => Task.Run(() => + { + var items = new List(); + foreach (var item in set) + { + items.Add(item); + } + return items.Count; + })); + + var counts = await Task.WhenAll(tasks); + + // All enumerations should see 100 items (no concurrent modification) + foreach (var count in counts) + { + await Assert.That(count).IsEqualTo(100); + } + } +} + +public class ConcurrentTryGetValueTests +{ + [Test] + public async Task Concurrent_TryGetValue_During_Adds() + { + var set = new ConcurrentHashSet(); + const int itemCount = 5000; + + var addTask = Task.Run(() => + { + for (var i = 0; i < itemCount; i++) + { + set.Add(i); + } + }); + + var getTask = Task.Run(() => + { + for (var i = 0; i < itemCount; i++) + { + set.TryGetValue(i, out _); // Should never throw + } + }); + + await Task.WhenAll(addTask, getTask); + await Assert.That(set.Count).IsEqualTo(itemCount); + } +} diff --git a/src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj b/src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj new file mode 100644 index 0000000..e0548a0 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/ConcurrentHashSet.Tests.csproj @@ -0,0 +1,18 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/ConcurrentHashSet.Tests/ConstructorTests.cs b/src/ConcurrentHashSet.Tests/ConstructorTests.cs new file mode 100644 index 0000000..d4e2995 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/ConstructorTests.cs @@ -0,0 +1,217 @@ +using ConcurrentCollections; + +namespace ConcurrentHashSet.Tests; + +public class ConstructorTests +{ + [Test] + public async Task Default_Constructor_Creates_Empty_Set() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Default_Constructor_Uses_Default_Comparer() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Comparer).IsEqualTo(EqualityComparer.Default); + } + + [Test] + public async Task ConcurrencyLevel_And_Capacity_Constructor_Creates_Empty_Set() + { + var set = new ConcurrentHashSet(4, 100); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task ConcurrencyLevel_And_Capacity_Constructor_Uses_Default_Comparer() + { + var set = new ConcurrentHashSet(4, 100); + + await Assert.That(set.Comparer).IsEqualTo(EqualityComparer.Default); + } + + [Test] + public async Task ConcurrencyLevel_Less_Than_One_Throws() + { + await Assert.That(() => new ConcurrentHashSet(0, 10)) + .Throws(); + } + + [Test] + public async Task Negative_Capacity_Throws() + { + await Assert.That(() => new ConcurrentHashSet(1, -1)) + .Throws(); + } + + [Test] + public async Task Collection_Constructor_Copies_Elements() + { + var source = new[] { 1, 2, 3, 4, 5 }; + var set = new ConcurrentHashSet(source); + + await Assert.That(set.Count).IsEqualTo(5); + foreach (var item in source) + { + await Assert.That(set.Contains(item)).IsTrue(); + } + } + + [Test] + public async Task Collection_Constructor_Deduplicates() + { + var source = new[] { 1, 2, 2, 3, 3, 3 }; + var set = new ConcurrentHashSet(source); + + await Assert.That(set.Count).IsEqualTo(3); + } + + [Test] + public async Task Collection_Constructor_Null_Collection_Throws() + { + await Assert.That(() => new ConcurrentHashSet((IEnumerable)null!)) + .Throws(); + } + + [Test] + public async Task Comparer_Constructor_Uses_Provided_Comparer() + { + var comparer = StringComparer.OrdinalIgnoreCase; + var set = new ConcurrentHashSet(comparer); + + await Assert.That(set.Comparer).IsEqualTo(comparer); + } + + [Test] + public async Task Comparer_Constructor_Null_Comparer_Uses_Default() + { + var set = new ConcurrentHashSet((IEqualityComparer?)null); + + await Assert.That(set.Comparer).IsEqualTo(EqualityComparer.Default); + } + + [Test] + public async Task Collection_And_Comparer_Constructor_Works() + { + var source = new[] { "foo", "FOO", "bar" }; + var set = new ConcurrentHashSet(source, StringComparer.OrdinalIgnoreCase); + + await Assert.That(set.Count).IsEqualTo(2); + await Assert.That(set.Comparer).IsEqualTo(StringComparer.OrdinalIgnoreCase); + } + + [Test] + public async Task Collection_And_Comparer_Constructor_Null_Collection_Throws() + { + await Assert.That(() => new ConcurrentHashSet(null!, StringComparer.OrdinalIgnoreCase)) + .Throws(); + } + + [Test] + public async Task ConcurrencyLevel_Collection_Comparer_Constructor_Works() + { + var source = new[] { "a", "A", "b", "B" }; + var set = new ConcurrentHashSet(4, source, StringComparer.OrdinalIgnoreCase); + + await Assert.That(set.Count).IsEqualTo(2); + await Assert.That(set.Comparer).IsEqualTo(StringComparer.OrdinalIgnoreCase); + } + + [Test] + public async Task ConcurrencyLevel_Collection_Comparer_Constructor_Null_Collection_Throws() + { + await Assert.That(() => new ConcurrentHashSet(4, null!, StringComparer.OrdinalIgnoreCase)) + .Throws(); + } + + [Test] + public async Task ConcurrencyLevel_Collection_Comparer_Constructor_Invalid_ConcurrencyLevel_Throws() + { + await Assert.That(() => new ConcurrentHashSet(0, new[] { "a" }, StringComparer.Ordinal)) + .Throws(); + } + + [Test] + public async Task ConcurrencyLevel_Capacity_Comparer_Constructor_Works() + { + var comparer = StringComparer.OrdinalIgnoreCase; + var set = new ConcurrentHashSet(4, 50, comparer); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.Comparer).IsEqualTo(comparer); + } + + [Test] + public async Task ConcurrencyLevel_Capacity_Comparer_Constructor_Invalid_ConcurrencyLevel_Throws() + { + await Assert.That(() => new ConcurrentHashSet(0, 10, null)) + .Throws(); + } + + [Test] + public async Task ConcurrencyLevel_Capacity_Comparer_Constructor_Negative_Capacity_Throws() + { + await Assert.That(() => new ConcurrentHashSet(1, -1, null)) + .Throws(); + } + + [Test] + public async Task ConcurrencyLevel_Capacity_Comparer_Constructor_Null_Comparer_Uses_Default() + { + var set = new ConcurrentHashSet(4, 50, null); + + await Assert.That(set.Comparer).IsEqualTo(EqualityComparer.Default); + } + + [Test] + public async Task Capacity_Less_Than_ConcurrencyLevel_Adjusts_Upward() + { + // capacity < concurrencyLevel should not throw; capacity is adjusted + var set = new ConcurrentHashSet(8, 2); + + await Assert.That(set.Count).IsEqualTo(0); + // Should still work properly + set.Add(1); + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task Constructor_With_Empty_Collection() + { + var set = new ConcurrentHashSet(Array.Empty()); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Constructor_With_Large_Collection() + { + var source = Enumerable.Range(0, 10000).ToList(); + var set = new ConcurrentHashSet(source); + + await Assert.That(set.Count).IsEqualTo(10000); + foreach (var item in source) + { + await Assert.That(set.Contains(item)).IsTrue(); + } + } + + [Test] + public async Task Constructor_ConcurrencyLevel_One_Works() + { + var set = new ConcurrentHashSet(1, 10); + set.Add(1); + set.Add(2); + + await Assert.That(set.Count).IsEqualTo(2); + } +} diff --git a/src/ConcurrentHashSet.Tests/CoreOperationsTests.cs b/src/ConcurrentHashSet.Tests/CoreOperationsTests.cs new file mode 100644 index 0000000..c596135 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/CoreOperationsTests.cs @@ -0,0 +1,439 @@ +using ConcurrentCollections; + +namespace ConcurrentHashSet.Tests; + +public class AddTests +{ + [Test] + public async Task Add_New_Item_Returns_True() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Add(42)).IsTrue(); + } + + [Test] + public async Task Add_Duplicate_Item_Returns_False() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.Add(42)).IsFalse(); + } + + [Test] + public async Task Add_Increments_Count() + { + var set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + set.Add(3); + + await Assert.That(set.Count).IsEqualTo(3); + } + + [Test] + public async Task Add_Duplicate_Does_Not_Increment_Count() + { + var set = new ConcurrentHashSet(); + set.Add(1); + set.Add(1); + + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task Add_Multiple_Distinct_Items() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Add("a")).IsTrue(); + await Assert.That(set.Add("b")).IsTrue(); + await Assert.That(set.Add("c")).IsTrue(); + await Assert.That(set.Count).IsEqualTo(3); + } + + [Test] + public async Task Add_With_Custom_Comparer_Respects_Equality() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + + await Assert.That(set.Add("Hello")).IsTrue(); + await Assert.That(set.Add("HELLO")).IsFalse(); + await Assert.That(set.Add("hello")).IsFalse(); + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task Add_Null_Item_For_Reference_Type() + { + var set = new ConcurrentHashSet(new NullSafeComparer()); + await Assert.That(set.Add(null)).IsTrue(); + await Assert.That(set.Add(null)).IsFalse(); + await Assert.That(set.Count).IsEqualTo(1); + } +} + +public class TryRemoveTests +{ + [Test] + public async Task TryRemove_Existing_Item_Returns_True() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.TryRemove(42)).IsTrue(); + } + + [Test] + public async Task TryRemove_NonExisting_Item_Returns_False() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.TryRemove(42)).IsFalse(); + } + + [Test] + public async Task TryRemove_Decrements_Count() + { + var set = new ConcurrentHashSet(); + set.Add(1); + set.Add(2); + set.TryRemove(1); + + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task TryRemove_Item_No_Longer_Contained() + { + var set = new ConcurrentHashSet(); + set.Add(42); + set.TryRemove(42); + + await Assert.That(set.Contains(42)).IsFalse(); + } + + [Test] + public async Task TryRemove_Same_Item_Twice_Returns_False_Second_Time() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.TryRemove(42)).IsTrue(); + await Assert.That(set.TryRemove(42)).IsFalse(); + } + + [Test] + public async Task TryRemove_From_Empty_Set() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.TryRemove(1)).IsFalse(); + } + + [Test] + public async Task TryRemove_With_Custom_Comparer() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + set.Add("Hello"); + + await Assert.That(set.TryRemove("HELLO")).IsTrue(); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task TryRemove_First_Node_In_Bucket() + { + // Use a small capacity to increase collision likelihood + var set = new ConcurrentHashSet(1, 1); + set.Add(1); + set.Add(2); + + await Assert.That(set.TryRemove(1)).IsTrue(); + await Assert.That(set.Contains(2)).IsTrue(); + } + + [Test] + public async Task TryRemove_Middle_Node_In_Chain() + { + // Small capacity forces collisions in same bucket + var set = new ConcurrentHashSet(1, 1); + set.Add(1); + set.Add(2); + set.Add(3); + + await Assert.That(set.TryRemove(2)).IsTrue(); + await Assert.That(set.Contains(1)).IsTrue(); + await Assert.That(set.Contains(3)).IsTrue(); + } +} + +public class ContainsTests +{ + [Test] + public async Task Contains_Existing_Item_Returns_True() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + await Assert.That(set.Contains(42)).IsTrue(); + } + + [Test] + public async Task Contains_NonExisting_Item_Returns_False() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Contains(42)).IsFalse(); + } + + [Test] + public async Task Contains_After_Remove_Returns_False() + { + var set = new ConcurrentHashSet(); + set.Add(42); + set.TryRemove(42); + + await Assert.That(set.Contains(42)).IsFalse(); + } + + [Test] + public async Task Contains_With_Custom_Comparer() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + set.Add("Hello"); + + await Assert.That(set.Contains("HELLO")).IsTrue(); + await Assert.That(set.Contains("hello")).IsTrue(); + await Assert.That(set.Contains("World")).IsFalse(); + } + + [Test] + public async Task Contains_On_Empty_Set() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Contains("anything")).IsFalse(); + } +} + +public class TryGetValueTests +{ + [Test] + public async Task TryGetValue_Existing_Item_Returns_True_And_Value() + { + var set = new ConcurrentHashSet(); + set.Add("hello"); + + var found = set.TryGetValue("hello", out var actual); + + await Assert.That(found).IsTrue(); + await Assert.That(actual).IsEqualTo("hello"); + } + + [Test] + public async Task TryGetValue_NonExisting_Returns_False_And_Default() + { + var set = new ConcurrentHashSet(); + + var found = set.TryGetValue("missing", out var actual); + + await Assert.That(found).IsFalse(); + await Assert.That(actual).IsNull(); + } + + [Test] + public async Task TryGetValue_Returns_Stored_Reference() + { + // Case-insensitive comparer: lookup with different casing returns the originally stored string + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + set.Add("Hello"); + + var found = set.TryGetValue("HELLO", out var actual); + + await Assert.That(found).IsTrue(); + await Assert.That(actual).IsEqualTo("Hello"); + await Assert.That(ReferenceEquals(actual, "Hello")).IsTrue(); + } + + [Test] + public async Task TryGetValue_On_Empty_Set() + { + var set = new ConcurrentHashSet(); + + var found = set.TryGetValue(42, out var actual); + + await Assert.That(found).IsFalse(); + await Assert.That(actual).IsEqualTo(default(int)); + } +} + +public class ClearTests +{ + [Test] + public async Task Clear_Removes_All_Items() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3, 4, 5 }); + set.Clear(); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Clear_On_Empty_Set_Is_Noop() + { + var set = new ConcurrentHashSet(); + set.Clear(); + + await Assert.That(set.Count).IsEqualTo(0); + } + + [Test] + public async Task Clear_Allows_ReAdding_Items() + { + var set = new ConcurrentHashSet(); + set.Add(1); + set.Clear(); + + await Assert.That(set.Add(1)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task Clear_Multiple_Times() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + set.Clear(); + set.Clear(); + + await Assert.That(set.Count).IsEqualTo(0); + } + + [Test] + public async Task Clear_Then_Add_Many_Items() + { + var set = new ConcurrentHashSet(Enumerable.Range(0, 1000)); + + await Assert.That(set.Count).IsEqualTo(1000); + + set.Clear(); + + await Assert.That(set.Count).IsEqualTo(0); + + foreach (var i in Enumerable.Range(0, 500)) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(500); + } +} + +public class CountTests +{ + [Test] + public async Task Count_Empty_Set_Returns_Zero() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Count).IsEqualTo(0); + } + + [Test] + public async Task Count_After_Adds() + { + var set = new ConcurrentHashSet(); + for (var i = 0; i < 100; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(100); + } + + [Test] + public async Task Count_After_Adds_And_Removes() + { + var set = new ConcurrentHashSet(); + for (var i = 0; i < 100; i++) + { + set.Add(i); + } + for (var i = 0; i < 50; i++) + { + set.TryRemove(i); + } + + await Assert.That(set.Count).IsEqualTo(50); + } +} + +public class IsEmptyTests +{ + [Test] + public async Task IsEmpty_New_Set_Returns_True() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task IsEmpty_After_Add_Returns_False() + { + var set = new ConcurrentHashSet(); + set.Add(1); + + await Assert.That(set.IsEmpty).IsFalse(); + } + + [Test] + public async Task IsEmpty_After_Add_And_Remove_Returns_True() + { + var set = new ConcurrentHashSet(); + set.Add(1); + set.TryRemove(1); + + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task IsEmpty_After_Clear_Returns_True() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + set.Clear(); + + await Assert.That(set.IsEmpty).IsTrue(); + } +} + +public class ComparerPropertyTests +{ + [Test] + public async Task Comparer_Returns_Default_When_None_Specified() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Comparer).IsEqualTo(EqualityComparer.Default); + } + + [Test] + public async Task Comparer_Returns_Specified_Comparer() + { + var comparer = StringComparer.OrdinalIgnoreCase; + var set = new ConcurrentHashSet(comparer); + + await Assert.That(set.Comparer).IsEqualTo(comparer); + } +} + +/// +/// A comparer that handles null values safely for testing purposes. +/// +public class NullSafeComparer : IEqualityComparer +{ + public bool Equals(string? x, string? y) => string.Equals(x, y); + public int GetHashCode(string? obj) => obj?.GetHashCode() ?? 0; +} diff --git a/src/ConcurrentHashSet.Tests/EdgeCaseAndGrowthTests.cs b/src/ConcurrentHashSet.Tests/EdgeCaseAndGrowthTests.cs new file mode 100644 index 0000000..31a3468 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/EdgeCaseAndGrowthTests.cs @@ -0,0 +1,486 @@ +using ConcurrentCollections; + +namespace ConcurrentHashSet.Tests; + +public class TableGrowthTests +{ + [Test] + public async Task Adding_Many_Items_Triggers_Table_Growth() + { + // Start with minimal capacity + var set = new ConcurrentHashSet(1, 1); + + for (var i = 0; i < 10000; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(10000); + + // Verify all items are still present after growth + for (var i = 0; i < 10000; i++) + { + await Assert.That(set.Contains(i)).IsTrue(); + } + } + + [Test] + public async Task Growth_With_Low_ConcurrencyLevel() + { + var set = new ConcurrentHashSet(1, 1); + + for (var i = 0; i < 1000; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(1000); + } + + [Test] + public async Task Growth_With_High_ConcurrencyLevel() + { + var set = new ConcurrentHashSet(32, 1); + + for (var i = 0; i < 1000; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(1000); + } + + [Test] + public async Task Concurrent_Growth() + { + // Minimal capacity forces frequent resizing + var set = new ConcurrentHashSet(2, 2); + const int itemsPerThread = 2000; + const int threadCount = 4; + + var tasks = Enumerable.Range(0, threadCount) + .Select(t => Task.Run(() => + { + for (var i = t * itemsPerThread; i < (t + 1) * itemsPerThread; i++) + { + set.Add(i); + } + })); + await Task.WhenAll(tasks); + + await Assert.That(set.Count).IsEqualTo(threadCount * itemsPerThread); + } + + [Test] + public async Task Clear_After_Growth_Resets_Capacity() + { + var set = new ConcurrentHashSet(); + + // Add enough items to trigger growth + for (var i = 0; i < 1000; i++) + { + set.Add(i); + } + + set.Clear(); + + await Assert.That(set.Count).IsEqualTo(0); + await Assert.That(set.IsEmpty).IsTrue(); + + // Should work normally after clear + growth reset + for (var i = 0; i < 100; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(100); + } +} + +public class CustomComparerTests +{ + [Test] + public async Task CaseInsensitive_String_Comparer() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + + set.Add("Hello"); + + await Assert.That(set.Contains("hello")).IsTrue(); + await Assert.That(set.Contains("HELLO")).IsTrue(); + await Assert.That(set.Contains("Hello")).IsTrue(); + await Assert.That(set.Add("HELLO")).IsFalse(); + } + + [Test] + public async Task CaseInsensitive_TryGetValue_Returns_Original() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + set.Add("OriginalCasing"); + + set.TryGetValue("ORIGINALCASING", out var actual); + + await Assert.That(actual).IsEqualTo("OriginalCasing"); + } + + [Test] + public async Task CaseInsensitive_Remove() + { + var set = new ConcurrentHashSet(StringComparer.OrdinalIgnoreCase); + set.Add("Hello"); + + await Assert.That(set.TryRemove("HELLO")).IsTrue(); + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Custom_Modulo_Comparer_Groups_Values() + { + // Comparer that considers ints equal if they have the same value mod 10 + var set = new ConcurrentHashSet(new ModuloComparer(10)); + + await Assert.That(set.Add(5)).IsTrue(); + await Assert.That(set.Add(15)).IsFalse(); // Same as 5 mod 10 + await Assert.That(set.Add(25)).IsFalse(); + await Assert.That(set.Add(6)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(2); + } + + [Test] + public async Task Comparer_From_Constructor_Is_Used_For_Collection() + { + var source = new[] { "a", "A", "b", "B", "c", "C" }; + var set = new ConcurrentHashSet(source, StringComparer.OrdinalIgnoreCase); + + await Assert.That(set.Count).IsEqualTo(3); + } +} + +public class HashCollisionTests +{ + [Test] + public async Task Items_With_Same_HashCode_Are_Distinct() + { + var set = new ConcurrentHashSet(); + var item1 = new CollidingItem(1, 42); + var item2 = new CollidingItem(2, 42); + var item3 = new CollidingItem(3, 42); + + set.Add(item1); + set.Add(item2); + set.Add(item3); + + await Assert.That(set.Count).IsEqualTo(3); + await Assert.That(set.Contains(item1)).IsTrue(); + await Assert.That(set.Contains(item2)).IsTrue(); + await Assert.That(set.Contains(item3)).IsTrue(); + } + + [Test] + public async Task Remove_From_Collision_Chain() + { + var set = new ConcurrentHashSet(); + var item1 = new CollidingItem(1, 42); + var item2 = new CollidingItem(2, 42); + var item3 = new CollidingItem(3, 42); + + set.Add(item1); + set.Add(item2); + set.Add(item3); + + // Remove middle item in chain + await Assert.That(set.TryRemove(item2)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(2); + await Assert.That(set.Contains(item1)).IsTrue(); + await Assert.That(set.Contains(item3)).IsTrue(); + } + + [Test] + public async Task Remove_Head_Of_Collision_Chain() + { + var set = new ConcurrentHashSet(); + var item1 = new CollidingItem(1, 42); + var item2 = new CollidingItem(2, 42); + + set.Add(item1); + set.Add(item2); + + // The last added item is at the head of the bucket chain + await Assert.That(set.TryRemove(item2)).IsTrue(); + await Assert.That(set.Contains(item1)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(1); + } + + [Test] + public async Task TryGetValue_With_Collisions() + { + var set = new ConcurrentHashSet(); + var item1 = new CollidingItem(1, 42); + var item2 = new CollidingItem(2, 42); + + set.Add(item1); + set.Add(item2); + + var found = set.TryGetValue(new CollidingItem(2, 42), out var actual); + + await Assert.That(found).IsTrue(); + await Assert.That(actual!.Id).IsEqualTo(2); + } + + [Test] + public async Task Many_Collisions_Still_Work() + { + var set = new ConcurrentHashSet(); + const int count = 100; + + for (var i = 0; i < count; i++) + { + set.Add(new CollidingItem(i, 1)); // All hash to same value + } + + await Assert.That(set.Count).IsEqualTo(count); + + for (var i = 0; i < count; i++) + { + await Assert.That(set.Contains(new CollidingItem(i, 1))).IsTrue(); + } + } +} + +public class EdgeCaseTests +{ + [Test] + public async Task Add_And_Remove_Same_Item_Repeatedly() + { + var set = new ConcurrentHashSet(); + + for (var i = 0; i < 1000; i++) + { + await Assert.That(set.Add(42)).IsTrue(); + await Assert.That(set.TryRemove(42)).IsTrue(); + } + + await Assert.That(set.IsEmpty).IsTrue(); + } + + [Test] + public async Task Operations_With_Default_Value_Type() + { + var set = new ConcurrentHashSet(); + + await Assert.That(set.Add(0)).IsTrue(); + await Assert.That(set.Contains(0)).IsTrue(); + await Assert.That(set.TryRemove(0)).IsTrue(); + await Assert.That(set.Contains(0)).IsFalse(); + } + + [Test] + public async Task Large_Number_Of_Items() + { + var set = new ConcurrentHashSet(); + const int count = 100000; + + for (var i = 0; i < count; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(count); + } + + [Test] + public async Task String_Items_Various_Lengths() + { + var set = new ConcurrentHashSet(); + var strings = new[] + { + "", + "a", + "ab", + "abc", + new string('x', 1000), + new string('y', 10000) + }; + + foreach (var s in strings) + { + set.Add(s); + } + + await Assert.That(set.Count).IsEqualTo(strings.Length); + foreach (var s in strings) + { + await Assert.That(set.Contains(s)).IsTrue(); + } + } + + [Test] + public async Task Negative_HashCode_Values() + { + // int.MinValue has a special hashcode behavior + var set = new ConcurrentHashSet(); + set.Add(int.MinValue); + set.Add(int.MaxValue); + set.Add(0); + set.Add(-1); + + await Assert.That(set.Count).IsEqualTo(4); + await Assert.That(set.Contains(int.MinValue)).IsTrue(); + await Assert.That(set.Contains(int.MaxValue)).IsTrue(); + } + + [Test] + public async Task Concurrent_Clear_And_Add() + { + var set = new ConcurrentHashSet(); + + var tasks = new List(); + for (var t = 0; t < 4; t++) + { + tasks.Add(Task.Run(() => + { + for (var i = 0; i < 500; i++) + { + set.Add(i); + } + })); + } + tasks.Add(Task.Run(() => + { + for (var i = 0; i < 10; i++) + { + Thread.Sleep(1); + set.Clear(); + } + })); + + await Task.WhenAll(tasks); + + // After everything completes, count should be non-negative + await Assert.That(set.Count).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task ICollection_CopyTo_After_Growth() + { + ICollection set = new ConcurrentHashSet(); + for (var i = 0; i < 500; i++) + { + set.Add(i); + } + + var array = new int[500]; + set.CopyTo(array, 0); + + var sorted = array.OrderBy(x => x).ToArray(); + for (var i = 0; i < 500; i++) + { + await Assert.That(sorted[i]).IsEqualTo(i); + } + } + + [Test] + public async Task Enumeration_Sees_All_Items_After_Growth() + { + var set = new ConcurrentHashSet(); + for (var i = 0; i < 1000; i++) + { + set.Add(i); + } + + var items = new HashSet(); + foreach (var item in set) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(1000); + } + + [Test] + public async Task TryGetValue_Default_Int_On_Miss() + { + var set = new ConcurrentHashSet(); + set.TryGetValue(999, out var actual); + + await Assert.That(actual).IsEqualTo(0); + } + + [Test] + public async Task TryGetValue_Default_String_On_Miss() + { + var set = new ConcurrentHashSet(); + set.TryGetValue("missing", out var actual); + + await Assert.That(actual).IsNull(); + } + + [Test] + [Arguments(0)] + [Arguments(1)] + [Arguments(10)] + [Arguments(100)] + [Arguments(1000)] + public async Task Various_Capacities_Work(int capacity) + { + var set = new ConcurrentHashSet(1, capacity); + + for (var i = 0; i < 50; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(50); + } + + [Test] + [Arguments(1)] + [Arguments(2)] + [Arguments(4)] + [Arguments(8)] + [Arguments(16)] + [Arguments(32)] + public async Task Various_ConcurrencyLevels_Work(int concurrencyLevel) + { + var set = new ConcurrentHashSet(concurrencyLevel, 31); + + for (var i = 0; i < 100; i++) + { + set.Add(i); + } + + await Assert.That(set.Count).IsEqualTo(100); + } +} + +/// +/// An item that allows controlling the hash code for collision testing. +/// +public class CollidingItem : IEquatable +{ + public int Id { get; } + private readonly int _hashCode; + + public CollidingItem(int id, int hashCode) + { + Id = id; + _hashCode = hashCode; + } + + public bool Equals(CollidingItem? other) => other != null && Id == other.Id; + public override bool Equals(object? obj) => Equals(obj as CollidingItem); + public override int GetHashCode() => _hashCode; +} + +/// +/// An equality comparer that considers ints equal if they have the same remainder when divided by a modulus. +/// +public class ModuloComparer : IEqualityComparer +{ + private readonly int _modulus; + + public ModuloComparer(int modulus) => _modulus = modulus; + + public bool Equals(int x, int y) => x % _modulus == y % _modulus; + public int GetHashCode(int obj) => (obj % _modulus).GetHashCode(); +} diff --git a/src/ConcurrentHashSet.Tests/EnumeratorTests.cs b/src/ConcurrentHashSet.Tests/EnumeratorTests.cs new file mode 100644 index 0000000..f0c3400 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/EnumeratorTests.cs @@ -0,0 +1,213 @@ +using ConcurrentCollections; +using System.Collections; + +namespace ConcurrentHashSet.Tests; + +public class StructEnumeratorTests +{ + [Test] + public async Task GetEnumerator_Returns_Struct_Enumerator() + { + var set = new ConcurrentHashSet(); + + // GetEnumerator() returns the struct Enumerator directly + var enumerator = set.GetEnumerator(); + + await Assert.That(enumerator).IsTypeOf.Enumerator>(); + } + + [Test] + public async Task Enumerator_Empty_Set_MoveNext_Returns_False() + { + var set = new ConcurrentHashSet(); + var enumerator = set.GetEnumerator(); + + await Assert.That(enumerator.MoveNext()).IsFalse(); + } + + [Test] + public async Task Enumerator_Iterates_All_Items() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3, 4, 5 }); + var items = new List(); + + var enumerator = set.GetEnumerator(); + while (enumerator.MoveNext()) + { + items.Add(enumerator.Current); + } + + await Assert.That(items.Count).IsEqualTo(5); + await Assert.That(items).Contains(1); + await Assert.That(items).Contains(2); + await Assert.That(items).Contains(3); + await Assert.That(items).Contains(4); + await Assert.That(items).Contains(5); + } + + [Test] + public async Task Enumerator_Current_Returns_Correct_Value() + { + var set = new ConcurrentHashSet(); + set.Add("only"); + + var enumerator = set.GetEnumerator(); + enumerator.MoveNext(); + + await Assert.That(enumerator.Current).IsEqualTo("only"); + } + + [Test] + public async Task Enumerator_MoveNext_Returns_False_After_Last_Item() + { + var set = new ConcurrentHashSet(); + set.Add(1); + + var enumerator = set.GetEnumerator(); + enumerator.MoveNext(); // first item + + await Assert.That(enumerator.MoveNext()).IsFalse(); + } + + [Test] + public async Task Enumerator_MoveNext_Returns_False_Repeatedly_After_Exhaustion() + { + var set = new ConcurrentHashSet(); + set.Add(1); + + var enumerator = set.GetEnumerator(); + enumerator.MoveNext(); + enumerator.MoveNext(); // past end + + await Assert.That(enumerator.MoveNext()).IsFalse(); + await Assert.That(enumerator.MoveNext()).IsFalse(); + } + + [Test] + public async Task Enumerator_Reset_Allows_ReEnumeration() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + + var enumerator = set.GetEnumerator(); + + // First pass + var firstPass = new List(); + while (enumerator.MoveNext()) + { + firstPass.Add(enumerator.Current); + } + + enumerator.Reset(); + + // Second pass + var secondPass = new List(); + while (enumerator.MoveNext()) + { + secondPass.Add(enumerator.Current); + } + + await Assert.That(firstPass.Count).IsEqualTo(3); + await Assert.That(secondPass.Count).IsEqualTo(3); + await Assert.That(firstPass.OrderBy(x => x)).IsEquivalentTo(secondPass.OrderBy(x => x)); + } + + [Test] + public async Task Enumerator_Dispose_Is_Safe() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + var enumerator = set.GetEnumerator(); + enumerator.MoveNext(); + enumerator.Dispose(); // Should not throw + // If we reached here, Dispose did not throw + } + + [Test] + public async Task Enumerator_IEnumerator_Current_Returns_Object() + { + var set = new ConcurrentHashSet(); + set.Add(42); + + IEnumerator enumerator = ((IEnumerable)set).GetEnumerator(); + enumerator.MoveNext(); + + await Assert.That(enumerator.Current).IsEqualTo((object)42); + } + + [Test] + public async Task Foreach_Works_With_Struct_Enumerator() + { + var set = new ConcurrentHashSet(new[] { 10, 20, 30 }); + var items = new List(); + + foreach (var item in set) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(3); + await Assert.That(items).Contains(10); + await Assert.That(items).Contains(20); + await Assert.That(items).Contains(30); + } + + [Test] + public async Task Enumerator_Single_Item() + { + var set = new ConcurrentHashSet(); + set.Add("only"); + + var items = new List(); + foreach (var item in set) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(1); + await Assert.That(items[0]).IsEqualTo("only"); + } + + [Test] + public async Task Enumerator_Large_Set() + { + var set = new ConcurrentHashSet(Enumerable.Range(0, 5000)); + var items = new List(); + + foreach (var item in set) + { + items.Add(item); + } + + await Assert.That(items.Count).IsEqualTo(5000); + await Assert.That(items.Distinct().Count()).IsEqualTo(5000); + } + + [Test] + public async Task LINQ_ToList_Works() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + // This uses IEnumerable.GetEnumerator() + var list = set.ToList(); + + await Assert.That(list.Count).IsEqualTo(3); + await Assert.That(list).Contains(1); + await Assert.That(list).Contains(2); + await Assert.That(list).Contains(3); + } + + [Test] + public async Task LINQ_Count_Works() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3, 4, 5 }); + + await Assert.That(set.Count()).IsEqualTo(5); + } + + [Test] + public async Task LINQ_Any_Works() + { + var set = new ConcurrentHashSet(new[] { 1, 2, 3 }); + + await Assert.That(set.Any(x => x == 2)).IsTrue(); + await Assert.That(set.Any(x => x == 99)).IsFalse(); + } +} diff --git a/src/ConcurrentHashSet.Tests/InternalsCoverageTests.cs b/src/ConcurrentHashSet.Tests/InternalsCoverageTests.cs new file mode 100644 index 0000000..f4ec954 --- /dev/null +++ b/src/ConcurrentHashSet.Tests/InternalsCoverageTests.cs @@ -0,0 +1,175 @@ +using System.Reflection; +using ConcurrentCollections; + +namespace ConcurrentHashSet.Tests; + +/// +/// These tests use reflection to exercise internal code paths that are unreachable +/// through the public API under normal conditions. They target defensive guards +/// ported from ConcurrentDictionary that protect against extreme states. +/// +public class NullableAttributesCoverageTests +{ + [Test] + public async Task MaybeNullWhenAttribute_Constructor_Sets_ReturnValue_True() + { + var assembly = typeof(ConcurrentHashSet<>).Assembly; + var attrType = assembly.GetType("System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute"); + + // On runtimes where the BCL already provides this attribute (netstandard2.1+), + // the polyfill is not compiled into the assembly. + if (attrType == null) + return; + + var ctor = attrType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, [typeof(bool)], null)!; + + var instance = ctor.Invoke([true]); + var returnValue = (bool)attrType.GetProperty("ReturnValue")!.GetValue(instance)!; + + await Assert.That(returnValue).IsTrue(); + } + + [Test] + public async Task MaybeNullWhenAttribute_Constructor_Sets_ReturnValue_False() + { + var assembly = typeof(ConcurrentHashSet<>).Assembly; + var attrType = assembly.GetType("System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute"); + + if (attrType == null) + return; + + var ctor = attrType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, [typeof(bool)], null)!; + + var instance = ctor.Invoke([false]); + var returnValue = (bool)attrType.GetProperty("ReturnValue")!.GetValue(instance)!; + + await Assert.That(returnValue).IsFalse(); + } +} + +public class InitializeFromCollectionCoverageTests +{ + [Test] + public async Task InitializeFromCollection_Recalculates_Budget_When_Zero() + { + // The private constructor always sets _budget >= 1 (since capacity >= concurrencyLevel >= 1). + // The _budget == 0 guard in InitializeFromCollection is defensive dead code ported from + // ConcurrentDictionary. We exercise it via reflection. + var set = new ConcurrentHashSet(); + + var budgetField = typeof(ConcurrentHashSet) + .GetField("_budget", BindingFlags.Instance | BindingFlags.NonPublic)!; + var initMethod = typeof(ConcurrentHashSet) + .GetMethod("InitializeFromCollection", BindingFlags.Instance | BindingFlags.NonPublic)!; + + // Force _budget to 0, then call InitializeFromCollection with an empty collection. + // The foreach loop does nothing, then the _budget == 0 guard triggers. + budgetField.SetValue(set, 0); + initMethod.Invoke(set, [Array.Empty()]); + + var newBudget = (int)budgetField.GetValue(set)!; + await Assert.That(newBudget).IsGreaterThan(0); + } + + [Test] + public async Task InitializeFromCollection_Recalculates_Budget_When_Zero_With_Items() + { + var set = new ConcurrentHashSet(); + + var budgetField = typeof(ConcurrentHashSet) + .GetField("_budget", BindingFlags.Instance | BindingFlags.NonPublic)!; + var initMethod = typeof(ConcurrentHashSet) + .GetMethod("InitializeFromCollection", BindingFlags.Instance | BindingFlags.NonPublic)!; + + // Set _budget to 0, add items, then verify budget is recalculated + budgetField.SetValue(set, 0); + initMethod.Invoke(set, [new[] { 10, 20, 30 }]); + + var newBudget = (int)budgetField.GetValue(set)!; + await Assert.That(newBudget).IsGreaterThan(0); + await Assert.That(set.Count).IsEqualTo(3); + } +} + +public class GrowTableCoverageTests +{ + [Test] + public async Task GrowTable_Budget_Overflow_To_Negative_Sets_IntMaxValue() + { + // When the bucket array is sparsely populated, GrowTable doubles the budget instead + // of resizing. If _budget is near int.MaxValue/2, doubling overflows to negative. + // The guard sets _budget = int.MaxValue. This state is unreachable through normal + // API usage because not enough consecutive doublings can occur, but we exercise it + // via reflection for coverage. + var set = new ConcurrentHashSet(128, 128); + + // Add a few items to make the set non-empty but very sparse + // (approxCount will be << buckets.Length / 4, triggering budget doubling) + set.Add(1); + set.Add(2); + + var budgetField = typeof(ConcurrentHashSet) + .GetField("_budget", BindingFlags.Instance | BindingFlags.NonPublic)!; + var tablesField = typeof(ConcurrentHashSet) + .GetField("_tables", BindingFlags.Instance | BindingFlags.NonPublic)!; + var growMethod = typeof(ConcurrentHashSet) + .GetMethod("GrowTable", BindingFlags.Instance | BindingFlags.NonPublic)!; + + // Set budget to just above int.MaxValue/2 so that doubling wraps negative + budgetField.SetValue(set, int.MaxValue / 2 + 1); + + var tables = tablesField.GetValue(set)!; + growMethod.Invoke(set, [tables]); + + // After overflow, the guard should have set _budget to int.MaxValue + var newBudget = (int)budgetField.GetValue(set)!; + await Assert.That(newBudget).IsEqualTo(int.MaxValue); + } + + [Test] + public async Task GrowTable_Budget_Doubling_Path_Preserves_Items() + { + // Verify that after the budget-doubling early return in GrowTable, + // the set still functions correctly + var set = new ConcurrentHashSet(128, 128); + set.Add(1); + set.Add(2); + + var budgetField = typeof(ConcurrentHashSet) + .GetField("_budget", BindingFlags.Instance | BindingFlags.NonPublic)!; + var tablesField = typeof(ConcurrentHashSet) + .GetField("_tables", BindingFlags.Instance | BindingFlags.NonPublic)!; + var growMethod = typeof(ConcurrentHashSet) + .GetMethod("GrowTable", BindingFlags.Instance | BindingFlags.NonPublic)!; + + budgetField.SetValue(set, int.MaxValue / 2 + 1); + var tables = tablesField.GetValue(set)!; + growMethod.Invoke(set, [tables]); + + // Set should still be usable after the overflow guard fires + await Assert.That(set.Contains(1)).IsTrue(); + await Assert.That(set.Contains(2)).IsTrue(); + await Assert.That(set.Count).IsEqualTo(2); + + // Can still add and remove items (budget is int.MaxValue, so no more growth) + set.Add(3); + await Assert.That(set.Count).IsEqualTo(3); + set.TryRemove(1); + await Assert.That(set.Count).IsEqualTo(2); + } + + // UNCOVERABLE: GrowTable lines 746-766 (6 lines, 2 branches) + // + // The maximizeTableSize guard in GrowTable activates when either: + // 1. newLength (= Buckets.Length * 2 + 1) exceeds maxArrayLength (0x7FEFFFFF), or + // 2. The checked multiplication Buckets.Length * 2 + 1 throws OverflowException + // + // Both require Buckets.Length > 1,073,217,535, meaning a Node?[] array of >1 billion + // references (~8GB on 64-bit). This cannot be allocated in a test environment. + // These guards are defensive code ported directly from .NET's ConcurrentDictionary + // and protect against theoretical multi-billion-element hash tables. +}