From 1999a9c8becd25d43b9836b58b5a4e2c48bb0647 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 28 Jul 2026 00:30:57 +0100 Subject: [PATCH 1/2] fix: resolve solution analyzer diagnostics Source changes: - resolve analyzer findings throughout ReactiveList, reactive views, extensions, benchmarks, and the sample application - introduce composed state ownership, sender-preserving notification relays, generic editable-list pool access, and cross-target compatibility helpers - preserve runtime behavior while replacing optional-parameter and construction-time escape patterns with explicit APIs Test and tooling changes: - update analyzer configuration and centralized analyzer packages - migrate rewritten test assertions to TUnit and replace reflection/timing-sensitive coverage with public behavioral synchronization - keep multi-target test compatibility across .NET Framework and modern Windows TFMs Validation: - Release and Debug solution builds complete with 0 warnings and 0 errors - Microsoft Testing Platform runs 2,966 tests with 0 failures and 0 skips - staged diff passes whitespace integrity checks --- .editorconfig | 2096 +++++++++++------ Directory.Build.props | 10 +- Directory.Packages.props | 6 +- .../BenchmarkObservableExtensions.cs | 2 +- .../DynamicDataParityBenchmarks.cs | 60 +- src/ReactiveList.Benchmarks/ListBenchmarks.cs | 230 +- .../MicroParityBenchmarks.cs | 245 +- .../ObservableIsolationBenchmarks.cs | 103 +- src/ReactiveList.Benchmarks/Program.cs | 21 +- .../QuaternaryDictionaryBenchmarks.cs | 196 +- .../QuaternaryListBenchmarks.cs | 387 +-- src/ReactiveList.Test/Assert.cs | 83 +- src/ReactiveList.Test/AssertionExtensions.cs | 281 ++- src/ReactiveList.Test/CacheActionTests.cs | 59 +- src/ReactiveList.Test/CacheNotifyTests.cs | 70 +- src/ReactiveList.Test/CoreCoverageTests.cs | 631 +++-- src/ReactiveList.Test/DynamicSearchTests.cs | 113 +- .../EditableListWrapperPoolTests.cs | 74 +- .../EditableListWrapperTests.cs | 112 +- .../ExtensionCoverageTests.cs | 305 ++- src/ReactiveList.Test/GlobalUsings.cs | 8 - .../ModuleInitializerAttribute.cs | 9 + src/ReactiveList.Test/PooledBatchTests.cs | 48 +- .../QuadCollectionCoverageTests.cs | 230 +- .../QuaternaryCollectionCoverageTests.cs | 547 +++-- .../QuaternaryDictionaryExtensionsTests.cs | 49 +- .../QuaternaryDictionaryTests.cs | 203 +- .../QuaternaryExtensionsAdditionalTests.cs | 251 +- .../QuaternaryExtensionsTests.cs | 40 +- src/ReactiveList.Test/QuaternaryListTests.cs | 107 +- src/ReactiveList.Test/Reactive2DListTests.cs | 169 +- src/ReactiveList.Test/ReactiveListAddTests.cs | 484 ++-- .../ReactiveListConnectTests.cs | 156 +- .../ReactiveListCoverageTests.cs | 268 ++- .../ReactiveListEditTests.cs | 128 +- .../ReactiveListExtensionsAdditionalTests.cs | 265 ++- .../ReactiveListExtensionsTests.cs | 135 +- .../ReactiveListMoveTests.cs | 78 +- ...ReactiveListNotificationComplianceTests.cs | 99 +- .../ReactiveListRemoveTests.cs | 163 +- .../ReactiveListSerializationTests.cs | 50 +- .../ReactiveListVersionTests.cs | 43 +- src/ReactiveList.Test/ReactiveViewTests.cs | 195 +- src/ReactiveList.Test/SecondaryIndexTests.cs | 114 +- src/ReactiveList.Test/TUnitAssemblyInfo.cs | 1 + src/ReactiveList.Test/TestData.cs | 8 +- src/ReactiveList.Test/ViewCoverageTests.cs | 735 +++--- src/ReactiveList.Test/ViewToPropertyTests.cs | 196 +- src/ReactiveList/Collections/IQuad.cs | 4 + .../Collections/IQuaternaryDictionary.cs | 10 + .../Collections/IQuaternaryList.cs | 10 + src/ReactiveList/Collections/IReactiveList.cs | 15 + .../Collections/IReactiveSource.cs | 4 + .../Collections/QuadDictionary.cs | 44 +- src/ReactiveList/Collections/QuadList.cs | 23 +- .../Collections/QuaternaryBase.cs | 79 +- .../Collections/QuaternaryDictionary.cs | 100 +- .../Collections/QuaternaryList.cs | 81 +- .../Collections/Reactive2DList.cs | 40 +- src/ReactiveList/Collections/ReactiveList.cs | 395 ++-- .../Core/CacheNotifyExtensions.cs | 65 +- src/ReactiveList/Core/Change.cs | 70 +- src/ReactiveList/Core/ChangeSet.cs | 48 +- .../Core/EditableListWrapperPool.cs | 109 +- .../Core/EditableListWrapperPool{T}.cs | 94 + src/ReactiveList/Core/GroupedObservable.cs | 13 +- src/ReactiveList/Core/PooledBatch.cs | 1 + .../Core/PooledEditableListWrapper.cs | 3 + src/ReactiveList/Core/ReactiveGroup.cs | 143 +- src/ReactiveList/Core/SecondaryIndex.cs | 16 +- .../Internal/ArrayPoolClearHelper.cs | 51 +- .../Internal/BatchChangeTracker.cs | 77 +- .../Internal/BitOperationsCompat.cs | 16 +- .../CallerArgumentExpressionAttribute.cs | 20 +- src/ReactiveList/Internal/ChangeToken.cs | 8 +- .../Internal/EnumerableExtensions.cs | 20 +- src/ReactiveList/Internal/EventPattern.cs | 6 +- src/ReactiveList/Internal/IsExternalInit.cs | 20 +- .../Internal/MaybeNullWhenAttribute.cs | 20 +- .../Internal/NotificationRelay.cs | 94 + src/ReactiveList/Internal/Observable.cs | 12 +- src/ReactiveList/Internal/ObservableMixins.cs | 4 +- src/ReactiveList/Internal/PooledBuffer.cs | 28 +- .../Internal/ReactiveListScheduler.cs | 14 +- src/ReactiveList/Internal/ShardHash.cs | 5 +- .../Internal/SkipLocalsInitAttribute.cs | 18 + src/ReactiveList/Internal/ThrowHelper.cs | 10 +- src/ReactiveList/Internal/ValueBuffer.cs | 14 +- src/ReactiveList/QuaternaryExtensions.cs | 224 +- src/ReactiveList/ReactiveListExtensions.cs | 407 +++- .../Views/DynamicFilteredReactiveView.cs | 481 ++-- src/ReactiveList/Views/DynamicReactiveView.cs | 20 +- ...micSecondaryIndexDictionaryReactiveView.cs | 104 +- .../DynamicSecondaryIndexReactiveView.cs | 553 +++-- .../Views/FilteredReactiveView.cs | 378 ++- src/ReactiveList/Views/GroupedReactiveView.cs | 542 +++-- src/ReactiveList/Views/ReactiveView.cs | 425 ++-- .../Views/SecondaryIndexReactiveView.cs | 58 +- src/ReactiveList/Views/SortedReactiveView.cs | 434 ++-- .../LiveDataEngineTests.cs | 32 +- .../Infrastructure/DelegateCommand.cs | 2 +- src/ReactiveListTestApp/MainWindow.xaml.cs | 78 +- .../MainWindowViewModel.cs | 264 ++- .../Models/InstrumentSnapshot.cs | 2 +- .../Services/LiveDataEngine.cs | 176 +- 105 files changed, 9931 insertions(+), 5916 deletions(-) delete mode 100644 src/ReactiveList.Test/GlobalUsings.cs create mode 100644 src/ReactiveList/Core/EditableListWrapperPool{T}.cs create mode 100644 src/ReactiveList/Internal/NotificationRelay.cs diff --git a/.editorconfig b/.editorconfig index 7b391d6..dc763ca 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,7 @@ root = true # Default settings ############################################# [*] +end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 @@ -182,170 +183,161 @@ csharp_space_between_square_brackets = false dotnet_diagnostic.AvoidAsyncVoid.severity = suggestion ################### -# Microsoft .NET Analyzers (CA) - Design Rules +# Microsoft.CodeAnalysis.NetAnalyzers (CA) ################### +# Design dotnet_diagnostic.CA1000.severity = none # Do not declare static members on generic types — common factory pattern dotnet_diagnostic.CA1001.severity = error # Types that own disposable fields should be disposable dotnet_diagnostic.CA1002.severity = none # Do not expose generic lists — we deliberately expose List; interface-based collections are an older convention we don't follow -dotnet_diagnostic.CA1003.severity = error # Use generic event handler instances -dotnet_diagnostic.CA1005.severity = error # Avoid excessive parameters on generic types +dotnet_diagnostic.CA1003.severity = none # Use generic event handler instances — covered by SST2304 +dotnet_diagnostic.CA1005.severity = none # Avoid excessive parameters on generic types — we deliberately expose 3+ type-parameter types (tuple-style handles, raw engine signals); the ergonomic guidance conflicts with that design dotnet_diagnostic.CA1008.severity = error # Enums should have zero value dotnet_diagnostic.CA1010.severity = none # Collections should implement generic interface — we deliberately expose concrete collection types; interface-based collections are an older convention we don't follow -dotnet_diagnostic.CA1012.severity = error # Abstract types should not have public constructors +dotnet_diagnostic.CA1012.severity = none # Abstract types should not have public constructors — covered by SST1428 dotnet_diagnostic.CA1014.severity = none # Mark assemblies with CLSCompliantAttribute — we don't ship CLS-compliant assemblies dotnet_diagnostic.CA1016.severity = error # Mark assemblies with AssemblyVersionAttribute dotnet_diagnostic.CA1017.severity = none # Mark assemblies with ComVisibleAttribute — we don't ship COM-visible assemblies dotnet_diagnostic.CA1018.severity = error # Mark attributes with AttributeUsageAttribute -dotnet_diagnostic.CA1019.severity = error # Define accessors for attribute arguments +dotnet_diagnostic.CA1019.severity = none # Define accessors for attribute arguments — conflicts with SST2324, which caps an internal attribute's accessor at internal dotnet_diagnostic.CA1021.severity = none # Avoid out parameters - disabled - needed for the zero-allocation idiom in Try/Find APIs and other performance-critical paths dotnet_diagnostic.CA1024.severity = error # Use properties where appropriate dotnet_diagnostic.CA1027.severity = error # Mark enums with FlagsAttribute -dotnet_diagnostic.CA1028.severity = error # Enum storage should be Int32 +dotnet_diagnostic.CA1028.severity = none # Enum storage should be Int32 — covered by SST2313 dotnet_diagnostic.CA1030.severity = none # Use events where appropriate — we use Rx observables instead of CLR events dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types — required at logging/dispose/IO boundaries -dotnet_diagnostic.CA1032.severity = error # Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none # Implement standard exception constructors — covered by SST1488 dotnet_diagnostic.CA1033.severity = none # Interface methods should be callable by child types — explicit interface implementations are a deliberate design choice dotnet_diagnostic.CA1034.severity = none # Nested types should not be visible — public nested types are sometimes the cleanest API (e.g. interface-scoped exception helpers) dotnet_diagnostic.CA1036.severity = none # Override methods on comparable types — relational operators rarely meaningful for our types -dotnet_diagnostic.CA1040.severity = error # Avoid empty interfaces -dotnet_diagnostic.CA1041.severity = error # Provide ObsoleteAttribute message +dotnet_diagnostic.CA1040.severity = none # Avoid empty interfaces — duplicate of SST1437 (canonical); marker interfaces (IActivatableView etc.) are deliberate public API +dotnet_diagnostic.CA1041.severity = none # Provide ObsoleteAttribute message — covered by SST2308 dotnet_diagnostic.CA1043.severity = error # Use integral or string argument for indexers -dotnet_diagnostic.CA1044.severity = error # Properties should not be write only -dotnet_diagnostic.CA1045.severity = error # Do not pass types by reference +dotnet_diagnostic.CA1044.severity = none # Properties should not be write only — covered by SST1421 +dotnet_diagnostic.CA1045.severity = none # Do not pass types by reference — we deliberately use ref-passing static helpers so they carry only the data they touch; data-oriented layout is the default here dotnet_diagnostic.CA1046.severity = error # Do not overload operator equals on reference types -dotnet_diagnostic.CA1047.severity = error # Do not declare protected member in sealed type -dotnet_diagnostic.CA1048.severity = error # Do not declare virtual members in sealed types -dotnet_diagnostic.CA1050.severity = error # Declare types in namespaces -dotnet_diagnostic.CA1051.severity = error # Do not declare visible instance fields -dotnet_diagnostic.CA1052.severity = error # Static holder types should be sealed -dotnet_diagnostic.CA1053.severity = error # Static holder types should not have constructors +dotnet_diagnostic.CA1047.severity = none # Do not declare protected member in sealed type — covered by SST1427 +dotnet_diagnostic.CA1048.severity = none # Do not declare virtual members in sealed types — covered by SST1491 +dotnet_diagnostic.CA1050.severity = none # Declare types in namespaces — covered by SST2312 +dotnet_diagnostic.CA1051.severity = none # Duplicate of SST1401 (canonical) — do not declare visible instance fields +dotnet_diagnostic.CA1052.severity = none # Static holder types should be sealed — covered by SST1432 +dotnet_diagnostic.CA1053.severity = none # Static holder types should not have constructors — covered by SST1432 dotnet_diagnostic.CA1054.severity = suggestion # URI parameters should not be strings dotnet_diagnostic.CA1055.severity = suggestion # URI return values should not be strings dotnet_diagnostic.CA1056.severity = suggestion # URI properties should not be strings dotnet_diagnostic.CA1058.severity = error # Types should not extend certain base types dotnet_diagnostic.CA1059.severity = error # Members should not expose certain concrete types dotnet_diagnostic.CA1060.severity = error # Move P/Invokes to NativeMethods class -dotnet_diagnostic.CA1061.severity = error # Do not hide base class methods +dotnet_diagnostic.CA1061.severity = none # Do not hide base class methods — covered by SST2427 dotnet_diagnostic.CA1062.severity = none # Validate arguments of public methods - Nullable=enable + we own every consumer, so the compiler already guarantees non-null params -dotnet_diagnostic.CA1063.severity = error # Implement IDisposable correctly +dotnet_diagnostic.CA1063.severity = none # Implement IDisposable correctly — covered by SST2300 dotnet_diagnostic.CA1064.severity = error # Exceptions should be public -dotnet_diagnostic.CA1065.severity = error # Do not raise exceptions in unexpected locations +dotnet_diagnostic.CA1065.severity = none # Do not raise exceptions in unexpected locations — covered by SST1485 dotnet_diagnostic.CA1066.severity = error # Implement IEquatable when overriding Equals dotnet_diagnostic.CA1067.severity = error # Override Equals when implementing IEquatable dotnet_diagnostic.CA1068.severity = error # CancellationToken parameters must come last dotnet_diagnostic.CA1069.severity = error # Enums should not have duplicate values dotnet_diagnostic.CA1070.severity = error # Do not declare event fields as virtual -################### -# Microsoft .NET Analyzers (CA) - Globalization Rules -################### +# Globalization dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters — we don't ship localized resources +dotnet_diagnostic.CA1307.severity = none # Covered by PSH1207 (canonical) dotnet_diagnostic.CA1308.severity = none # Normalize strings to uppercase — ToLowerInvariant is correct for filesystem path / cache key normalization +dotnet_diagnostic.CA1310.severity = none # Covered by PSH1207 (canonical) -################### -# Microsoft .NET Analyzers (CA) - Interoperability Rules -################### +# Interoperability dotnet_diagnostic.CA1401.severity = error # P/Invokes should not be visible -################### -# Microsoft .NET Analyzers (CA) - Maintainability Rules -################### -dotnet_diagnostic.CA1500.severity = error # Variable names should not match field names -dotnet_diagnostic.CA1501.severity = none # Avoid excessive inheritance — disabled because the analyzer noticeably slows down the build -dotnet_diagnostic.CA1502.severity = none # Avoid excessive complexity — disabled because the analyzer noticeably slows down the build +# Maintainability +dotnet_diagnostic.CA1500.severity = none # Variable names should not match field names — covered by SST1484 +dotnet_diagnostic.CA1501.severity = none # Covered by SST1446 (canonical) +dotnet_diagnostic.CA1502.severity = none # Covered by SST1442 (canonical) dotnet_diagnostic.CA1505.severity = error # Avoid unmaintainable code dotnet_diagnostic.CA1506.severity = none # Avoid excessive class coupling — adds little signal here, mostly trips on legitimate orchestration code -dotnet_diagnostic.CA1507.severity = error # Use nameof in place of string +dotnet_diagnostic.CA1507.severity = none # Use nameof in place of string — covered by SST1463 dotnet_diagnostic.CA1508.severity = error # Avoid dead conditional code dotnet_diagnostic.CA1509.severity = error # Invalid entry in code metrics configuration file -dotnet_diagnostic.CA1510.severity = none # Use ArgumentNullException throw helper — disabled because we target older TFMs and use ArgumentExceptionHelper for cross-platform parity -dotnet_diagnostic.CA1511.severity = none # Use ArgumentException throw helper — disabled because we target older TFMs and use ArgumentExceptionHelper for cross-platform parity -dotnet_diagnostic.CA1512.severity = none # Use ArgumentOutOfRangeException throw helper — disabled because we target older TFMs and use ArgumentExceptionHelper for cross-platform parity -dotnet_diagnostic.CA1513.severity = none # Use ObjectDisposedException throw helper — disabled because we target older TFMs and use ArgumentExceptionHelper for cross-platform parity -dotnet_diagnostic.CA1514.severity = error # Avoid redundant length argument +dotnet_diagnostic.CA1510.severity = none # Covered by PSH1409 (canonical) +dotnet_diagnostic.CA1511.severity = none # Covered by PSH1409 (canonical) +dotnet_diagnostic.CA1512.severity = none # Covered by PSH1409 (canonical) +dotnet_diagnostic.CA1513.severity = none # Covered by PSH1409 (canonical) +dotnet_diagnostic.CA1514.severity = none # Avoid redundant length argument — covered by PSH1220 dotnet_diagnostic.CA1515.severity = none # Consider making public types internal — interferes with tests and reflection-discovered types (BenchmarkDotNet, TUnit, etc.) dotnet_diagnostic.CA1516.severity = error # Use cross-platform intrinsics -################### -# Microsoft .NET Analyzers (CA) - Naming Rules -################### +# Naming dotnet_diagnostic.CA1710.severity = suggestion # Identifiers should have correct suffix dotnet_diagnostic.CA1724.severity = none # Type Names Should Not Match Namespaces — namespace/type name overlap is intentional API surface -################### -# Microsoft .NET Analyzers (CA) - Performance Rules -################### -dotnet_diagnostic.CA1802.severity = error # Use literals where appropriate -dotnet_diagnostic.CA1805.severity = error # Do not initialize unnecessarily +# Performance +dotnet_diagnostic.CA1802.severity = none # Use literals where appropriate — covered by PSH1402 +dotnet_diagnostic.CA1805.severity = none # Do not initialize unnecessarily — covered by PSH1403 dotnet_diagnostic.CA1806.severity = error # Do not ignore method results dotnet_diagnostic.CA1810.severity = none # Initialize reference type static fields inline — explicit static constructors are deliberate in some types dotnet_diagnostic.CA1812.severity = error # Avoid uninstantiated internal classes -dotnet_diagnostic.CA1813.severity = error # Avoid unsealed attributes -dotnet_diagnostic.CA1814.severity = error # Prefer jagged arrays over multidimensional -dotnet_diagnostic.CA1815.severity = error # Override equals and operator equals on value types +dotnet_diagnostic.CA1813.severity = none # Avoid unsealed attributes — covered by PSH1401 +dotnet_diagnostic.CA1814.severity = none # Prefer jagged arrays over multidimensional — covered by PSH1020 +dotnet_diagnostic.CA1815.severity = none # Override equals and operator equals on value types — covered by PSH1005 dotnet_diagnostic.CA1819.severity = none # Properties should not return arrays — incompatible with the RxUI/sqlite-net mapping style we use throughout the codebase -dotnet_diagnostic.CA1820.severity = error # Test for empty strings using string length -dotnet_diagnostic.CA1821.severity = error # Remove empty finalizers -dotnet_diagnostic.CA1822.severity = error # Mark members as static -dotnet_diagnostic.CA1823.severity = error # Avoid unused private fields +dotnet_diagnostic.CA1820.severity = none # Test for empty strings using string length — covered by PSH1204 +dotnet_diagnostic.CA1821.severity = none # Remove empty finalizers — covered by PSH1002 +dotnet_diagnostic.CA1822.severity = none # Mark members as static — covered by PSH1414 +dotnet_diagnostic.CA1823.severity = none # Avoid unused private fields — covered by SST1441 dotnet_diagnostic.CA1824.severity = error # Mark assemblies with NeutralResourcesLanguageAttribute -dotnet_diagnostic.CA1825.severity = error # Avoid zero-length array allocations -dotnet_diagnostic.CA1826.severity = error # Use property instead of Linq Enumerable method -dotnet_diagnostic.CA1827.severity = error # Do not use Count/LongCount when Any can be used -dotnet_diagnostic.CA1828.severity = error # Do not use CountAsync/LongCountAsync when AnyAsync can be used -dotnet_diagnostic.CA1829.severity = error # Use Length/Count property instead of Enumerable.Count method -dotnet_diagnostic.CA1830.severity = error # Prefer strongly-typed Append and Insert method overloads on StringBuilder -dotnet_diagnostic.CA1831.severity = error # Use AsSpan instead of Range-based indexers for string when appropriate -dotnet_diagnostic.CA1832.severity = error # Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array -dotnet_diagnostic.CA1833.severity = error # Use AsSpan or AsMemory instead of Range-based indexers for getting Span or Memory portion of an array -dotnet_diagnostic.CA1834.severity = error # Use StringBuilder.Append(char) for single character strings -dotnet_diagnostic.CA1835.severity = error # Prefer the memory-based overloads of ReadAsync/WriteAsync methods in stream-based classes -dotnet_diagnostic.CA1836.severity = error # Prefer IsEmpty over Count when available -dotnet_diagnostic.CA1837.severity = error # Use Environment.ProcessId instead of Process.GetCurrentProcess().Id +dotnet_diagnostic.CA1825.severity = none # Avoid zero-length array allocations — covered by PSH1001 +dotnet_diagnostic.CA1826.severity = none # Use property instead of Linq Enumerable method — covered by PSH1103 +dotnet_diagnostic.CA1827.severity = none # Do not use Count/LongCount when Any can be used — covered by PSH1119 +dotnet_diagnostic.CA1828.severity = none # Do not use CountAsync/LongCountAsync when AnyAsync can be used — covered by PSH1126 +dotnet_diagnostic.CA1829.severity = none # Use Length/Count property instead of Enumerable.Count — covered by PSH1103 +dotnet_diagnostic.CA1830.severity = none # Prefer strongly-typed Append/Insert overloads on StringBuilder — covered by PSH1202 +dotnet_diagnostic.CA1831.severity = none # Use AsSpan instead of Range-based indexers for string — covered by PSH1212 +dotnet_diagnostic.CA1832.severity = none # Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array — covered by PSH1019 +dotnet_diagnostic.CA1833.severity = none # Use AsSpan or AsMemory instead of Range-based indexers for getting Span or Memory portion of an array — conflicts with PSH1019, which owns the array range-indexer rewrite and refuses it for mutable Span/Memory targets +dotnet_diagnostic.CA1834.severity = none # Use StringBuilder.Append(char) for single character strings — covered by PSH1202 +dotnet_diagnostic.CA1835.severity = none # Prefer the memory-based overloads of ReadAsync/WriteAsync methods in stream-based classes — covered by PSH1314 +dotnet_diagnostic.CA1836.severity = none # Prefer IsEmpty over Count when available — covered by PSH1117 +dotnet_diagnostic.CA1837.severity = none # Use Environment.ProcessId — covered by PSH1405 dotnet_diagnostic.CA1838.severity = error # Avoid StringBuilder parameters for P/Invokes -dotnet_diagnostic.CA1839.severity = error # Use Environment.ProcessPath instead of Process.GetCurrentProcess().MainModule.FileName -dotnet_diagnostic.CA1840.severity = error # Use Environment.CurrentManagedThreadId instead of Thread.CurrentThread.ManagedThreadId -dotnet_diagnostic.CA1841.severity = error # Prefer Dictionary.Contains methods -dotnet_diagnostic.CA1842.severity = error # Do not use 'WhenAll' with a single task -dotnet_diagnostic.CA1843.severity = error # Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1839.severity = none # Use Environment.ProcessPath — covered by PSH1405 +dotnet_diagnostic.CA1840.severity = none # Use Environment.CurrentManagedThreadId — covered by PSH1405 +dotnet_diagnostic.CA1841.severity = none # Prefer Dictionary.Contains methods — covered by PSH1407 +dotnet_diagnostic.CA1842.severity = none # Do not use 'WhenAll' with a single task — covered by PSH1301 +dotnet_diagnostic.CA1843.severity = none # Do not use 'WaitAll' with a single task — covered by PSH1301 dotnet_diagnostic.CA1844.severity = error # Provide memory-based overrides of async methods when subclassing 'Stream' -dotnet_diagnostic.CA1845.severity = error # Use span-based 'string.Concat' -dotnet_diagnostic.CA1846.severity = error # Prefer AsSpan over Substring -dotnet_diagnostic.CA1847.severity = none # Use char literal for a single character lookup — disabled because the string.Contains(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both +dotnet_diagnostic.CA1845.severity = none # Use span-based 'string.Concat' — covered by PSH1222 +dotnet_diagnostic.CA1846.severity = none # Prefer AsSpan over Substring — covered by PSH1212 +dotnet_diagnostic.CA1847.severity = none # Covered by PSH1201 (canonical) dotnet_diagnostic.CA1848.severity = error # Use the LoggerMessage delegates -dotnet_diagnostic.CA1849.severity = error # Call async methods when in an async method -dotnet_diagnostic.CA1850.severity = error # Prefer static HashData method over ComputeHash +dotnet_diagnostic.CA1849.severity = none # Call async methods when in an async method — covered by PSH1313 +dotnet_diagnostic.CA1850.severity = none # Prefer static HashData method over ComputeHash — covered by PSH1400 dotnet_diagnostic.CA1851.severity = error # Possible multiple enumerations of IEnumerable collection -dotnet_diagnostic.CA1852.severity = error # Seal internal types +dotnet_diagnostic.CA1852.severity = none # Seal internal types — covered by PSH1411 dotnet_code_quality.CA1852.api_surface = private, internal # only flag non-public classes; public classes stay open for inheritance -dotnet_diagnostic.CA1853.severity = error # Unnecessary call to 'Dictionary.ContainsKey(key)' -dotnet_diagnostic.CA1854.severity = error # Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1853.severity = none # Unnecessary call to 'Dictionary.ContainsKey(key)' — covered by PSH1105 +dotnet_diagnostic.CA1854.severity = none # Prefer the IDictionary.TryGetValue method — covered by PSH1104 dotnet_diagnostic.CA1855.severity = error # Prefer 'Clear' over 'Fill' dotnet_diagnostic.CA1856.severity = error # Incorrect usage of ConstantExpected attribute dotnet_diagnostic.CA1857.severity = error # A constant is expected for the parameter -dotnet_diagnostic.CA1858.severity = error # Use 'StartsWith' instead of 'IndexOf' +dotnet_diagnostic.CA1858.severity = none # Use 'StartsWith' instead of 'IndexOf' — covered by PSH1221 dotnet_diagnostic.CA1859.severity = error # Use concrete types when possible for improved performance -dotnet_diagnostic.CA1860.severity = error # Avoid using 'Enumerable.Any()' extension method -dotnet_diagnostic.CA1861.severity = error # Avoid constant arrays as arguments -dotnet_diagnostic.CA1862.severity = error # Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -dotnet_diagnostic.CA1863.severity = error # Use 'CompositeFormat' -dotnet_diagnostic.CA1864.severity = error # Prefer the 'IDictionary.TryAdd(TKey, TValue)' method -dotnet_diagnostic.CA1865.severity = none # Use char overload (string.StartsWith) — disabled because the string.StartsWith(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both -dotnet_diagnostic.CA1866.severity = none # Use char overload (string.EndsWith) — disabled because the string.EndsWith(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both -dotnet_diagnostic.CA1867.severity = none # Use char overload (string.IndexOf / string.LastIndexOf) — disabled because the char overloads don't exist on .NET Framework / netstandard2.0 and we target both -dotnet_diagnostic.CA1868.severity = error # Unnecessary call to 'Contains' for sets -dotnet_diagnostic.CA1869.severity = error # Cache and reuse 'JsonSerializerOptions' instances -dotnet_diagnostic.CA1870.severity = error # Use a cached 'SearchValues' instance +dotnet_diagnostic.CA1860.severity = none # Avoid using 'Enumerable.Any()' extension method — covered by PSH1103 +dotnet_diagnostic.CA1861.severity = none # Avoid constant arrays as arguments — covered by PSH1004 +dotnet_diagnostic.CA1862.severity = none # Use the 'StringComparison' overloads for case-insensitive comparisons — covered by PSH1200 +dotnet_diagnostic.CA1863.severity = none # Use 'CompositeFormat' — covered by PSH1223 +dotnet_diagnostic.CA1864.severity = none # Prefer the 'IDictionary.TryAdd' method — covered by PSH1115 +dotnet_diagnostic.CA1865.severity = none # Covered by PSH1201 (canonical) +dotnet_diagnostic.CA1866.severity = none # Covered by PSH1201 (canonical) +dotnet_diagnostic.CA1867.severity = none # Covered by PSH1201 (canonical) +dotnet_diagnostic.CA1868.severity = none # Unnecessary call to 'Contains' for sets — covered by PSH1105 +dotnet_diagnostic.CA1869.severity = none # Cache and reuse 'JsonSerializerOptions' instances — covered by PSH1416 +dotnet_diagnostic.CA1870.severity = none # Use a cached 'SearchValues' instance — covered by PSH1213 dotnet_diagnostic.CA1871.severity = error # Do not pass a nullable struct to 'ArgumentNullException.ThrowIfNull' -dotnet_diagnostic.CA1872.severity = error # Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' -dotnet_diagnostic.CA1873.severity = error # Avoid potentially expensive evaluation of arguments to 'Debug.Assert' -dotnet_diagnostic.CA1874.severity = error # Use 'Regex.IsMatch' -dotnet_diagnostic.CA1875.severity = error # Use 'Regex.Count' -dotnet_diagnostic.CA1877.severity = error # Use 'Encoding.GetString' instead of 'Encoding.GetChars' +dotnet_diagnostic.CA1872.severity = none # Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' — covered by PSH1224 +dotnet_diagnostic.CA1873.severity = none # Avoid potentially expensive evaluation of arguments to 'Debug.Assert' — covered by PSH1417 +dotnet_diagnostic.CA1874.severity = none # Use 'Regex.IsMatch' — covered by PSH1406 +dotnet_diagnostic.CA1875.severity = none # Use 'Regex.Count' — covered by PSH1406 +dotnet_diagnostic.CA1877.severity = none # Use 'Encoding.GetString' instead of 'Encoding.GetChars' — covered by PSH1225 -################### -# Microsoft .NET Analyzers (CA) - Reliability Rules -################### +# Reliability dotnet_diagnostic.CA2000.severity = suggestion # Dispose objects before losing scope dotnet_diagnostic.CA2002.severity = error # Do not lock on objects with weak identity dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task — Rx and library callers drive synchronization context themselves @@ -368,41 +360,39 @@ dotnet_diagnostic.CA2024.severity = error # Do not use 'StreamReader.EndOfStream dotnet_diagnostic.CA2025.severity = error # Do not pass 'IDisposable' instances into unawaited tasks dotnet_diagnostic.CA2026.severity = error # Do not use methods or types annotated with [RequiresDynamicCode] in code that uses [RequiresDynamicCode] -################### -# Microsoft .NET Analyzers (CA) - Usage Rules -################### +# Usage dotnet_diagnostic.CA1801.severity = error # Review unused parameters dotnet_code_quality.CA1801.api_surface = private, internal # only flag non-public APIs so we don't break public signatures dotnet_diagnostic.CA1816.severity = error # Call GC.SuppressFinalize correctly -dotnet_diagnostic.CA2200.severity = error # Rethrow to preserve stack details +dotnet_diagnostic.CA2200.severity = none # Rethrow to preserve stack details — covered by SST1430 dotnet_diagnostic.CA2201.severity = error # Do not raise reserved exception types dotnet_diagnostic.CA2207.severity = error # Initialize value type static fields inline dotnet_diagnostic.CA2208.severity = none # Instantiate argument exceptions correctly — too sensitive: flags valid context-forwarding nameof(arg.Property) patterns -dotnet_diagnostic.CA2211.severity = error # Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = none # Non-constant fields should not be visible — covered by SST1499 dotnet_diagnostic.CA2213.severity = error # Disposable fields should be disposed -dotnet_diagnostic.CA2214.severity = error # Do not call overridable methods in constructors +dotnet_diagnostic.CA2214.severity = none # Do not call overridable methods in constructors — covered by SST1483 dotnet_diagnostic.CA2215.severity = error # Dispose methods should call base class dispose -dotnet_diagnostic.CA2216.severity = error # Disposable types should declare finalizer -dotnet_diagnostic.CA2217.severity = error # Do not mark enums with FlagsAttribute +dotnet_diagnostic.CA2216.severity = none # Disposable types should declare finalizer — conflicts with SST2317, which owns the owned-native-handle shape and prescribes a SafeHandle instead of a finalizer +dotnet_diagnostic.CA2217.severity = none # Do not mark enums with FlagsAttribute — covered by SST2303 dotnet_diagnostic.CA2218.severity = error # Override GetHashCode on overriding Equals dotnet_diagnostic.CA2219.severity = error # Do not raise exceptions in finally clauses -dotnet_diagnostic.CA2224.severity = error # Override Equals on overloading operator equals +dotnet_diagnostic.CA2224.severity = none # Override Equals on overloading operator equals — covered by SST2302 dotnet_diagnostic.CA2225.severity = error # Operator overloads have named alternates dotnet_diagnostic.CA2226.severity = error # Operators should have symmetrical overloads dotnet_diagnostic.CA2227.severity = none # Collection properties should be read only — settable collection properties are common in our DTOs and config types dotnet_diagnostic.CA2231.severity = error # Overload operator equals on overriding ValueType.Equals dotnet_diagnostic.CA2234.severity = error # Pass System.Uri objects instead of strings dotnet_diagnostic.CA2241.severity = error # Provide correct arguments to formatting methods -dotnet_diagnostic.CA2242.severity = error # Test for NaN correctly +dotnet_diagnostic.CA2242.severity = none # Test for NaN correctly — covered by SST1473 dotnet_diagnostic.CA2243.severity = error # Attribute string literals should parse correctly dotnet_diagnostic.CA2244.severity = error # Do not duplicate indexed element initializations -dotnet_diagnostic.CA2245.severity = error # Do not assign a property to itself +dotnet_diagnostic.CA2245.severity = none # Do not assign a property to itself — covered by SST1189 dotnet_diagnostic.CA2246.severity = error # Do not assign a symbol and its member in the same statement dotnet_diagnostic.CA2247.severity = error # Argument passed to TaskCompletionSource constructor should be TaskCreationOptions enum dotnet_diagnostic.CA2248.severity = error # Provide correct enum argument to Enum.HasFlag dotnet_diagnostic.CA2249.severity = error # Use String.Contains instead of String.IndexOf for substring checks dotnet_diagnostic.CA2250.severity = error # Use ThrowIfCancellationRequested -dotnet_diagnostic.CA2251.severity = error # Use String.Equals over String.Compare +dotnet_diagnostic.CA2251.severity = none # Covered by PSH1216 (canonical) dotnet_diagnostic.CA2252.severity = error # Opt in to preview features before using them dotnet_diagnostic.CA2253.severity = error # Named placeholders should not be numeric values dotnet_diagnostic.CA2254.severity = error # Template should be a static expression @@ -423,9 +413,7 @@ dotnet_diagnostic.CA2268.severity = error # Use 'string.Equals(string, string, S # Skipped (deprecated ISerializable formatter): CA2229 Implement serialization constructors, # CA2235 Mark all non-serializable fields, CA2237 Mark ISerializable types with SerializableAttribute. -################### -# Microsoft .NET Analyzers (CA) - Security Rules -################### +# Security # SQL Injection & Command Injection dotnet_diagnostic.CA2100.severity = error # Review SQL queries for security vulnerabilities @@ -548,15 +536,9 @@ dotnet_diagnostic.CA2153.severity = error # Do not catch corrupted state excepti dotnet_diagnostic.CA5367.severity = error # Do not serialize types with pointer fields ################### -# SonarAnalyzer (Sxxxx) — global suppressions -################### -dotnet_diagnostic.S1075.severity = none # Hardcoded URI — canonical SourceLink hosts are the point -dotnet_diagnostic.S2436.severity = none # Too many generic parameters — needed for the projector overload -dotnet_diagnostic.S4036.severity = none # PATH-relative process spawn — benchmark only, trusted env - -################### -# Microsoft .NET Runtime Obsoletions (SYSLIB0xxx) +# Microsoft .NET SDK Diagnostics (SYSLIB) ################### +# Runtime obsoletions dotnet_diagnostic.SYSLIB0001.severity = error # The UTF-7 encoding is insecure and should not be used dotnet_diagnostic.SYSLIB0002.severity = error # PrincipalPermissionAttribute is not honored by the runtime and must not be used dotnet_diagnostic.SYSLIB0003.severity = error # Code Access Security (CAS) is not supported or honored by the runtime @@ -619,9 +601,7 @@ dotnet_diagnostic.SYSLIB0059.severity = error # SystemEvents.EventsThreadShutdow dotnet_diagnostic.SYSLIB0060.severity = error # Constructors of DirectoryServices.ActiveDirectory.ConfigurationContext are obsolete dotnet_diagnostic.SYSLIB0061.severity = error # CryptographyConfig.AddOID and AddAlgorithm methods are obsolete -################### -# Microsoft .NET Source Generator Diagnostics (SYSLIB1xxx) -################### +# Source generators # LoggerMessage source generator dotnet_diagnostic.SYSLIB1001.severity = error # Logging method names cannot start with _ dotnet_diagnostic.SYSLIB1002.severity = error # Don't include log level parameters as templates in the logging message @@ -709,8 +689,9 @@ dotnet_diagnostic.SYSLIB1103.severity = error # Configuration binding source gen dotnet_diagnostic.SYSLIB1104.severity = error # Configuration binding source generator: language version is too low ################### -# Microsoft .NET Style Rules (IDExxxx) - Language Rules +# Microsoft .NET Code Style Analyzers (IDE) ################### +# Language rules # EnforceCodeStyleInBuild=true (set in Directory.Build.props) promotes these # IDE rules into `dotnet build` so they fire at compile time, not just in the # IDE. We enable the ones that are unambiguous wins and skip the rules that @@ -718,124 +699,128 @@ dotnet_diagnostic.SYSLIB1104.severity = error # Configuration binding source gen # etc.) so we don't double-report. # Bug catchers — always error. -dotnet_diagnostic.IDE0035.severity = error # Remove unreachable code -dotnet_diagnostic.IDE0043.severity = error # Format string contains invalid placeholder -dotnet_diagnostic.IDE0051.severity = error # Remove unused private member -dotnet_diagnostic.IDE0052.severity = error # Remove unread private member -dotnet_diagnostic.IDE0076.severity = error # Remove invalid global SuppressMessage attribute -dotnet_diagnostic.IDE0077.severity = error # Avoid legacy format target in global SuppressMessage attribute -dotnet_diagnostic.IDE0080.severity = error # Remove unnecessary suppression operator +dotnet_diagnostic.IDE0035.severity = none # Remove unreachable code — covered by SST1453 +dotnet_diagnostic.IDE0043.severity = none # Format string contains invalid placeholder — covered by SST1454 +dotnet_diagnostic.IDE0052.severity = none # Remove unread private member — covered by SST1441 # Simplification / cleanup. -dotnet_diagnostic.IDE0001.severity = error # Simplify name -dotnet_diagnostic.IDE0002.severity = error # Simplify member access -dotnet_diagnostic.IDE0004.severity = none # Remove unnecessary cast — disabled because we deliberately keep explicit casts in Apple platform-specific paths and other reflection/marshalling sites where the analyzer's "redundant" judgement is wrong -dotnet_diagnostic.IDE0005.severity = none # Remove unnecessary import - DUPLICATE S1128 (slower: 2.364s vs 551ms) -dotnet_diagnostic.IDE0016.severity = error # Use throw expression -dotnet_diagnostic.IDE0017.severity = error # Use object initializers -dotnet_diagnostic.IDE0018.severity = error # Inline variable declaration -dotnet_diagnostic.IDE0019.severity = error # Use pattern matching to avoid 'as' followed by a 'null' check -dotnet_diagnostic.IDE0020.severity = error # Use pattern matching to avoid 'is' check followed by a cast -dotnet_diagnostic.IDE0028.severity = error # Use collection initializers -dotnet_diagnostic.IDE0029.severity = none # Use coalesce expression — handled by RCS1128 -dotnet_diagnostic.IDE0030.severity = error # Use coalesce expression (nullable types) -dotnet_diagnostic.IDE0031.severity = none # Use null propagation — handled by RCS1146 -dotnet_diagnostic.IDE0032.severity = none # Use auto property — handled by RCS1085 -dotnet_diagnostic.IDE0033.severity = error # Use explicitly provided tuple name -dotnet_diagnostic.IDE0034.severity = error # Simplify default expression -dotnet_diagnostic.IDE0037.severity = error # Use inferred member name -dotnet_diagnostic.IDE0038.severity = error # Use pattern matching ('is' check without a cast) -dotnet_diagnostic.IDE0041.severity = error # Use 'is null' check -dotnet_diagnostic.IDE0042.severity = error # Deconstruct variable declaration -dotnet_diagnostic.IDE0044.severity = error # Add readonly modifier -dotnet_diagnostic.IDE0046.severity = suggestion # Use conditional expression for return — downgraded because nested coalescing/ternary chains it produces hurt readability -dotnet_diagnostic.IDE0047.severity = error # Remove unnecessary parentheses -dotnet_diagnostic.IDE0049.severity = error # Use language keywords instead of framework type names for type references -dotnet_diagnostic.IDE0054.severity = error # Use compound assignment -dotnet_diagnostic.IDE0056.severity = suggestion # Use index operator -dotnet_diagnostic.IDE0057.severity = suggestion # Use range operator -dotnet_diagnostic.IDE0059.severity = error # Remove unnecessary value assignment -dotnet_diagnostic.IDE0062.severity = error # Make local function static -dotnet_diagnostic.IDE0063.severity = error # Use simple 'using' statement -dotnet_diagnostic.IDE0064.severity = error # Make struct fields writable — flag readonly-but-mutable struct fields where intent diverges from declaration -dotnet_diagnostic.IDE0066.severity = error # Use switch expression -dotnet_diagnostic.IDE0070.severity = error # Use 'System.HashCode.Combine' -dotnet_diagnostic.IDE0071.severity = error # Simplify interpolation -dotnet_diagnostic.IDE0072.severity = suggestion # Add missing cases to switch expression - sometimes we miss switch cases dilberately. -dotnet_diagnostic.IDE0074.severity = error # Use compound assignment -dotnet_diagnostic.IDE0075.severity = error # Simplify conditional expression -dotnet_diagnostic.IDE0078.severity = error # Use pattern matching -dotnet_diagnostic.IDE0082.severity = error # Convert typeof to nameof -dotnet_diagnostic.IDE0083.severity = error # Use pattern matching ('not' operator) -dotnet_diagnostic.IDE0084.severity = error # Use pattern matching ('IsNot' operator) -dotnet_diagnostic.IDE0090.severity = error # Simplify 'new' expression -dotnet_diagnostic.IDE0100.severity = error # Remove unnecessary equality operator -dotnet_diagnostic.IDE0110.severity = error # Remove unnecessary discard -dotnet_diagnostic.IDE0120.severity = error # Simplify LINQ expression -dotnet_diagnostic.IDE0150.severity = error # Prefer 'null' check over type check -dotnet_diagnostic.IDE0161.severity = error # Use file-scoped namespace declaration -dotnet_diagnostic.IDE0170.severity = error # Simplify property pattern -dotnet_diagnostic.IDE0180.severity = error # Use tuple to swap values -dotnet_diagnostic.IDE0200.severity = error # Remove unnecessary lambda expression -dotnet_diagnostic.IDE0220.severity = error # Add explicit cast in foreach loop -dotnet_diagnostic.IDE0240.severity = error # Remove redundant nullable directive -dotnet_diagnostic.IDE0241.severity = error # Remove unnecessary nullable directive -dotnet_diagnostic.IDE0250.severity = error # Make struct 'readonly' -dotnet_diagnostic.IDE0251.severity = error # Make member 'readonly' -dotnet_diagnostic.IDE0260.severity = error # Use pattern matching -dotnet_diagnostic.IDE0270.severity = error # Use coalesce expression +dotnet_diagnostic.IDE0002.severity = none # Simplify member access — covered by SST1117 +dotnet_diagnostic.IDE0004.severity = none # Remove unnecessary cast — covered by SST1175 +dotnet_diagnostic.IDE0016.severity = none # Use throw expression — covered by SST2207 +dotnet_diagnostic.IDE0019.severity = none # Use pattern matching to avoid 'as' followed by a 'null' check — covered by SST2005/SST2274 +dotnet_diagnostic.IDE0028.severity = none # Use collection initializers — covered by SST1194 +dotnet_diagnostic.IDE0031.severity = none # Use null propagation — covered by SST1196 +dotnet_diagnostic.IDE0038.severity = none # Use pattern matching ('is' check without a cast) — covered by SST2007 +dotnet_diagnostic.IDE0041.severity = none # Use 'is null' check — covered by SST1149/SST2231/SST2282 +dotnet_diagnostic.IDE0042.severity = none # Deconstruct variable declaration — covered by SST2214 +dotnet_diagnostic.IDE0044.severity = none # Add readonly modifier — covered by SST1424 +dotnet_diagnostic.IDE0047.severity = none # Remove unnecessary parentheses — covered by SST1459 +dotnet_diagnostic.IDE0049.severity = none # Use language keywords instead of framework type names for type references — covered by SST1121 +dotnet_diagnostic.IDE0057.severity = none # Use range operator — covered by SST2204 +dotnet_diagnostic.IDE0059.severity = none # Remove unnecessary value assignment — covered by SST2222 +dotnet_diagnostic.IDE0064.severity = none # Make struct fields writable — flag readonly-but-mutable struct fields where intent diverges from declaration +dotnet_diagnostic.IDE0066.severity = none # Use switch expression — covered by SST2201 +dotnet_diagnostic.IDE0075.severity = none # Simplify conditional expression — covered by SST1182 +dotnet_diagnostic.IDE0078.severity = none # Use pattern matching — covered by SST2006/SST2231 +dotnet_diagnostic.IDE0084.severity = none # Use pattern matching ('IsNot' operator) +dotnet_diagnostic.IDE0120.severity = none # Simplify LINQ expression — covered by PSH1100/PSH1101 +dotnet_diagnostic.IDE0221.severity = none # Add explicit cast — covered by SST2226 +dotnet_diagnostic.IDE0250.severity = none # Make struct 'readonly' — covered by PSH1014 +dotnet_diagnostic.IDE0260.severity = none # Use pattern matching — covered by SST2006/SST2231 # Disabled — conflict with an existing SA/CA rule or project convention. -dotnet_diagnostic.IDE0003.severity = none # Remove 'this' qualifier — we don't follow the this. prefix style (SA1101 = none) dotnet_diagnostic.IDE0007.severity = none # Use var — we don't force var in either direction dotnet_diagnostic.IDE0008.severity = none # Use explicit type — we don't force var in either direction dotnet_diagnostic.IDE0009.severity = none # Member access should be qualified — SA1101 = none -dotnet_diagnostic.IDE0010.severity = none # Add missing cases to switch statement — too noisy for default/fallthrough patterns -dotnet_diagnostic.IDE0011.severity = none # Add braces — RCS1001 / RCS1007 already handle this -dotnet_diagnostic.IDE0021.severity = none # Use expression body for constructors — preference not enforced -dotnet_diagnostic.IDE0022.severity = none # Use expression body for methods — preference not enforced -dotnet_diagnostic.IDE0023.severity = none # Use expression body for conversion operators — preference not enforced -dotnet_diagnostic.IDE0024.severity = none # Use expression body for operators — preference not enforced -dotnet_diagnostic.IDE0025.severity = none # Use expression body for properties — preference not enforced -dotnet_diagnostic.IDE0026.severity = none # Use expression body for indexers — preference not enforced -dotnet_diagnostic.IDE0027.severity = none # Use expression body for accessors — preference not enforced -dotnet_diagnostic.IDE0036.severity = none # Order modifiers — SA1206 handles -dotnet_diagnostic.IDE0039.severity = none # Use local function — anonymous method preference not enforced -dotnet_diagnostic.IDE0040.severity = none # Add accessibility modifiers — SA1400 handles -dotnet_diagnostic.IDE0045.severity = none # Use conditional expression for assignment — preference not enforced +dotnet_diagnostic.IDE0021.severity = none # Use expression body for constructors — covered by SST2276 (opt-in) +dotnet_diagnostic.IDE0022.severity = none # Use expression body for methods — covered by SST2275 +dotnet_diagnostic.IDE0023.severity = none # Use expression body for conversion operators — covered by SST2278 (opt-in) +dotnet_diagnostic.IDE0024.severity = none # Use expression body for operators — covered by SST2277 (opt-in) +dotnet_diagnostic.IDE0025.severity = none # Use expression body for properties — covered by SST2279 +dotnet_diagnostic.IDE0026.severity = none # Use expression body for indexers — covered by SST2280 dotnet_diagnostic.IDE0048.severity = none # Add parentheses for clarity — preference not enforced -dotnet_diagnostic.IDE0053.severity = none # Use expression body for lambdas — preference not enforced dotnet_diagnostic.IDE0055.severity = none # Formatting — StyleCop SA rules own formatting -dotnet_diagnostic.IDE0058.severity = none # Remove unnecessary expression value — too noisy (ignores fluent APIs) +dotnet_diagnostic.IDE0058.severity = none # Remove unnecessary expression value — covered by SST2221 dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter — CA1801 handles (with api_surface config) -dotnet_diagnostic.IDE0061.severity = none # Use expression body for local function — preference not enforced -dotnet_diagnostic.IDE0065.severity = none # 'using' directive placement — SA1200 = none (we put usings outside file-scoped namespaces) -dotnet_diagnostic.IDE0073.severity = none # Require file header — SA1633 handles +dotnet_diagnostic.IDE0061.severity = none # Use expression body for local function — covered by SST2281 dotnet_diagnostic.IDE0079.severity = none # Remove unnecessary suppression — can misfire on multi-TFM suppressions dotnet_diagnostic.IDE0130.severity = none # Namespace does not match folder structure — we don't enforce strict mirror dotnet_diagnostic.IDE0160.severity = none # Use block-scoped namespace — we use file-scoped (IDE0161) dotnet_diagnostic.IDE0210.severity = none # Convert to top-level statements — we use Main style dotnet_diagnostic.IDE0211.severity = none # Convert to 'Program.Main' style — we use Main style -dotnet_diagnostic.IDE0280.severity = none # Use nameof — CA1507 / RCS1015 handle this -dotnet_diagnostic.IDE0290.severity = none # Use primary constructor — subjective, not enforced -dotnet_diagnostic.IDE0300.severity = error # Use collection expression for array -dotnet_diagnostic.IDE0301.severity = error # Use collection expression for empty -dotnet_diagnostic.IDE0302.severity = error # Use collection expression for stackalloc -dotnet_diagnostic.IDE0303.severity = error # Use collection expression for Create -dotnet_diagnostic.IDE0304.severity = error # Use collection expression for builder -dotnet_diagnostic.IDE0305.severity = error # Use collection expression for fluent -dotnet_diagnostic.IDE0306.severity = error # Use collection expression for new -dotnet_diagnostic.IDE0320.severity = error # Make anonymous function static — eliminates the closure-capture display class allocation -dotnet_diagnostic.IDE0050.severity = error # Convert anonymous type to tuple -dotnet_diagnostic.IDE0230.severity = error # Use UTF-8 string literal — better hot-path encoding for byte-array constants +dotnet_diagnostic.IDE0300.severity = none # Use collection expression for array — covered by SST2101 +dotnet_diagnostic.IDE0306.severity = none # Use collection expression for new — covered by SST2101 +dotnet_diagnostic.IDE0320.severity = none # Make anonymous function static — covered by PSH1000 +dotnet_diagnostic.IDE0050.severity = none # Convert anonymous type to tuple — covered by SST2224 dotnet_diagnostic.IDE0310.severity = none # Convert lambda expression to method group — handled by RCS1207 +dotnet_diagnostic.IDE0360.severity = none # Simplify property accessor — covered by SST2219 dotnet_diagnostic.IDE0370.severity = none # Remove unnecessary suppression (null-forgiving operator) — disabled for the same reason as RCS1249: multi-TFM nullability annotations can differ per platform -dotnet_diagnostic.IDE0380.severity = error # Remove unnecessary unsafe modifier -dotnet_diagnostic.IDE1005.severity = error # Use conditional delegate call -################### -# Microsoft .NET Style Rules (IDE1xxx / IDE3xxx) - Naming & Miscellaneous -################### +# Language and unnecessary code rules. +dotnet_diagnostic.IDE0001.severity = none # Simplify name +dotnet_diagnostic.IDE0003.severity = none # Name can be simplified +dotnet_diagnostic.IDE0005.severity = none # Remove unnecessary import +dotnet_diagnostic.IDE0010.severity = none # Add missing cases to switch statement +dotnet_diagnostic.IDE0011.severity = none # Add braces +dotnet_diagnostic.IDE0017.severity = none # Use object initializers +dotnet_diagnostic.IDE0018.severity = none # Inline variable declaration +dotnet_diagnostic.IDE0020.severity = none # Use pattern matching to avoid is check followed by a cast (with variable) +dotnet_diagnostic.IDE0027.severity = none # Use expression body for accessors +dotnet_diagnostic.IDE0029.severity = none # Null check can be simplified +dotnet_diagnostic.IDE0030.severity = none # Null check can be simplified +dotnet_diagnostic.IDE0032.severity = none # Use auto property +dotnet_diagnostic.IDE0033.severity = none # Use explicitly provided tuple name +dotnet_diagnostic.IDE0034.severity = none # Simplify default expression +dotnet_diagnostic.IDE0036.severity = none # Order modifiers +dotnet_diagnostic.IDE0037.severity = none # Use inferred member name +dotnet_diagnostic.IDE0039.severity = none # Use local function instead of lambda +dotnet_diagnostic.IDE0040.severity = none # Add accessibility modifiers +dotnet_diagnostic.IDE0045.severity = none # Use conditional expression for assignment +dotnet_diagnostic.IDE0046.severity = none # Use conditional expression for return +dotnet_diagnostic.IDE0051.severity = none # Remove unused private member +dotnet_diagnostic.IDE0053.severity = none # Use expression body for lambdas +dotnet_diagnostic.IDE0054.severity = none # Use compound assignment +dotnet_diagnostic.IDE0056.severity = none # Use index operator +dotnet_diagnostic.IDE0062.severity = none # Make local function static +dotnet_diagnostic.IDE0063.severity = none # Use simple using statement +dotnet_diagnostic.IDE0065.severity = none # Using directive placement +dotnet_diagnostic.IDE0070.severity = none # Use System.HashCode.Combine +dotnet_diagnostic.IDE0071.severity = none # Simplify interpolation +dotnet_diagnostic.IDE0072.severity = none # Add missing cases to switch expression +dotnet_diagnostic.IDE0073.severity = none # Use file header +dotnet_diagnostic.IDE0074.severity = none # Use coalesce compound assignment +dotnet_diagnostic.IDE0076.severity = none # Remove invalid global SuppressMessageAttribute +dotnet_diagnostic.IDE0077.severity = none # Avoid legacy format target in global SuppressMessageAttribute +dotnet_diagnostic.IDE0080.severity = none # Remove unnecessary suppression operator +dotnet_diagnostic.IDE0082.severity = none # Convert typeof to nameof +dotnet_diagnostic.IDE0083.severity = none # Use pattern matching (not operator) +dotnet_diagnostic.IDE0090.severity = none # Simplify new expression +dotnet_diagnostic.IDE0100.severity = none # Remove unnecessary equality operator +dotnet_diagnostic.IDE0110.severity = none # Remove unnecessary discard +dotnet_diagnostic.IDE0150.severity = none # Prefer null check over type check +dotnet_diagnostic.IDE0161.severity = none # Use file-scoped namespace +dotnet_diagnostic.IDE0170.severity = none # Simplify property pattern +dotnet_diagnostic.IDE0180.severity = none # Use tuple to swap values +dotnet_diagnostic.IDE0200.severity = none # Remove unnecessary lambda expression +dotnet_diagnostic.IDE0220.severity = none # Add explicit cast in foreach loop +dotnet_diagnostic.IDE0230.severity = none # Use UTF-8 string literal +dotnet_diagnostic.IDE0240.severity = none # Nullable directive is redundant +dotnet_diagnostic.IDE0241.severity = none # Nullable directive is unnecessary +dotnet_diagnostic.IDE0251.severity = none # Member can be made readonly +dotnet_diagnostic.IDE0270.severity = none # Null check can be simplified +dotnet_diagnostic.IDE0280.severity = none # Use nameof +dotnet_diagnostic.IDE0290.severity = none # Use primary constructor +dotnet_diagnostic.IDE0301.severity = none # Use collection expression for empty +dotnet_diagnostic.IDE0302.severity = none # Use collection expression for stackalloc +dotnet_diagnostic.IDE0303.severity = none # Use collection expression for Create() +dotnet_diagnostic.IDE0304.severity = none # Use collection expression for builder +dotnet_diagnostic.IDE0305.severity = none # Use collection expression for fluent +dotnet_diagnostic.IDE0340.severity = none # Use unbound generic type +dotnet_diagnostic.IDE0350.severity = none # Use implicitly typed lambda +dotnet_diagnostic.IDE0380.severity = none # Remove unnecessary unsafe modifier +dotnet_diagnostic.IDE1005.severity = none # Use conditional delegate call + +# Naming and miscellaneous # Naming conventions — SA1300 family already enforces PascalCase / interface # prefix / field casing (with our _underscore convention on SA1306/1309/1311 # deliberately disabled). Leaving IDE1006 off because configuring @@ -845,37 +830,41 @@ dotnet_diagnostic.IDE1006.severity = none # Naming rule violation — SA1300 fam dotnet_diagnostic.IDE3000.severity = none # Disabled per project convention — not enforced ################### -# Roslynator Analyzers (RCS1xxx) - Code Simplification +# Roslynator.CSharp.Analyzers (RCS) ################### -dotnet_diagnostic.RCS1001.severity = error # Add braces (when expression spans over multiple lines) -dotnet_diagnostic.RCS1003.severity = error # Add braces to if-else (when expression spans over multiple lines) +# Code simplification +dotnet_diagnostic.RCS1001.severity = none # Add braces (when expression spans over multiple lines) — covered by SST1519 +dotnet_diagnostic.RCS1003.severity = none # Add braces to if-else (when expression spans over multiple lines) — covered by SST1519 dotnet_diagnostic.RCS1005.severity = error # Simplify nested using statement -dotnet_diagnostic.RCS1006.severity = error # Merge 'else' with nested 'if' +dotnet_diagnostic.RCS1006.severity = none # Merge 'else' with nested 'if' — covered by SST1465 dotnet_diagnostic.RCS1007.severity = error # Add braces dotnet_diagnostic.RCS1031.severity = none # Remove unnecessary braces in switch section -- we don't mind braces in switch statements dotnet_diagnostic.RCS1032.severity = error # Remove redundant parentheses -dotnet_diagnostic.RCS1033.severity = error # Remove redundant boolean literal -dotnet_diagnostic.RCS1039.severity = error # Remove argument list from attribute +dotnet_diagnostic.RCS1033.severity = none # Remove redundant boolean literal — covered by SST1143 +dotnet_diagnostic.RCS1039.severity = none # Remove argument list from attribute — covered by SST1411 +dotnet_diagnostic.RCS1040.severity = none # Remove empty statement — covered by SST1180 dotnet_diagnostic.RCS1042.severity = none # Remove enum default underlying type — covered by SST1177 dotnet_diagnostic.RCS1043.severity = error # Remove 'partial' modifier from type with a single part -dotnet_diagnostic.RCS1049.severity = error # Simplify boolean comparison +dotnet_diagnostic.RCS1049.severity = none # Simplify boolean comparison — covered by SST1143 dotnet_diagnostic.RCS1058.severity = none # Use compound assignment — covered by SST1185 -dotnet_diagnostic.RCS1061.severity = error # Merge 'if' with nested 'if' +dotnet_diagnostic.RCS1061.severity = none # Merge 'if' with nested 'if' — covered by SST2013 dotnet_diagnostic.RCS1068.severity = none # Simplify logical negation — covered by SST1172/SST2006 -dotnet_diagnostic.RCS1069.severity = error # Remove unnecessary case label +dotnet_diagnostic.RCS1069.severity = none # Remove unnecessary case label — covered by SST1466 dotnet_diagnostic.RCS1070.severity = none # Remove redundant default switch section — covered by SST1179 dotnet_diagnostic.RCS1071.severity = none # Remove redundant base constructor call — covered by SST1178 -dotnet_diagnostic.RCS1073.severity = error # Convert 'if' to 'return' statement +dotnet_diagnostic.RCS1072.severity = none # Remove empty namespace declaration — covered by SST1435 +dotnet_diagnostic.RCS1073.severity = none # Convert 'if' to 'return' statement — covered by SST1197 dotnet_diagnostic.RCS1074.severity = none # Remove redundant constructor — covered by SST1433 -dotnet_diagnostic.RCS1078.severity = error # Use "" or 'string.Empty' -dotnet_diagnostic.RCS1084.severity = error # Use coalesce expression instead of conditional expression -dotnet_diagnostic.RCS1085.severity = error # Use auto-implemented property +dotnet_diagnostic.RCS1078.severity = none # Use "" or 'string.Empty' — conflicts with SST1122, which owns the string.Empty direction +dotnet_diagnostic.RCS1084.severity = none # Use coalesce expression instead of conditional expression — covered by SST1195 +dotnet_diagnostic.RCS1085.severity = none # Use auto-implemented property — covered by SST1420 dotnet_diagnostic.RCS1089.severity = error # Use --/++ operator instead of assignment dotnet_diagnostic.RCS1097.severity = error # Remove redundant 'ToString' call dotnet_diagnostic.RCS1103.severity = error # Convert 'if' to assignment dotnet_diagnostic.RCS1104.severity = none # Simplify conditional expression — covered by SST1182 dotnet_diagnostic.RCS1105.severity = error # Unnecessary interpolation -dotnet_diagnostic.RCS1107.severity = error # Remove redundant 'ToCharArray' call +dotnet_diagnostic.RCS1106.severity = none # Remove empty destructor — covered by PSH1002 +dotnet_diagnostic.RCS1107.severity = none # Remove redundant 'ToCharArray' call — covered by PSH1217 dotnet_diagnostic.RCS1114.severity = error # Remove redundant delegate creation dotnet_diagnostic.RCS1124.severity = error # Inline local variable dotnet_diagnostic.RCS1126.severity = error # Add braces to if-else @@ -883,144 +872,138 @@ dotnet_diagnostic.RCS1128.severity = error # Use coalesce expression dotnet_diagnostic.RCS1129.severity = none # Remove redundant field initialization — covered by SST1176 dotnet_diagnostic.RCS1132.severity = none # Remove redundant overriding member — covered by SST1181 dotnet_diagnostic.RCS1133.severity = error # Remove redundant Dispose/Close call -dotnet_diagnostic.RCS1134.severity = error # Remove redundant statement +dotnet_diagnostic.RCS1134.severity = none # Remove redundant statement — covered by SST1174 dotnet_diagnostic.RCS1143.severity = error # Simplify coalesce expression dotnet_diagnostic.RCS1145.severity = error # Remove redundant 'as' operator dotnet_diagnostic.RCS1146.severity = error # Use conditional access dotnet_diagnostic.RCS1151.severity = none # Remove redundant cast — covered by SST1175 dotnet_diagnostic.RCS1171.severity = error # Simplify lazy initialization dotnet_diagnostic.RCS1173.severity = error # Use coalesce expression instead of 'if' -dotnet_diagnostic.RCS1174.severity = error # Remove redundant async/await +dotnet_diagnostic.RCS1174.severity = none # Remove redundant async/await — covered by PSH1311 dotnet_diagnostic.RCS1179.severity = error # Unnecessary assignment dotnet_diagnostic.RCS1180.severity = error # Inline lazy initialization dotnet_diagnostic.RCS1188.severity = none # Remove redundant auto-property initialization — covered by SST1176 dotnet_diagnostic.RCS1192.severity = none # Unnecessary usage of verbatim string literal — covered by SST1184 dotnet_diagnostic.RCS1199.severity = error # Unnecessary null check dotnet_diagnostic.RCS1206.severity = error # Use conditional access instead of conditional expression -dotnet_diagnostic.RCS1207.severity = error # Use anonymous function or method group -dotnet_diagnostic.RCS1211.severity = error # Remove unnecessary 'else' +dotnet_diagnostic.RCS1207.severity = none # Use anonymous function or method group — conflicts with SST2239, which owns the method-group direction +dotnet_diagnostic.RCS1211.severity = none # Remove unnecessary 'else' — covered by SST1464 dotnet_diagnostic.RCS1212.severity = error # Remove redundant assignment dotnet_diagnostic.RCS1214.severity = none # Unnecessary interpolated string — covered by SST1183 dotnet_diagnostic.RCS1216.severity = error # Unnecessary unsafe context -dotnet_diagnostic.RCS1217.severity = error # Convert interpolated string to concatenation +dotnet_diagnostic.RCS1217.severity = none # Convert interpolated string to concatenation — reverses SST2249, which owns the concatenation-to-interpolation direction dotnet_diagnostic.RCS1218.severity = error # Simplify code branching -dotnet_diagnostic.RCS1220.severity = error # Use pattern matching instead of combination of 'is' and cast +dotnet_diagnostic.RCS1220.severity = none # Use pattern matching instead of combination of 'is' and cast — covered by SST2007 dotnet_diagnostic.RCS1221.severity = error # Use pattern matching instead of combination of 'as' and null check -dotnet_diagnostic.RCS1238.severity = error # Avoid nested ?: operators -dotnet_diagnostic.RCS1244.severity = error # Simplify 'default' expression +dotnet_diagnostic.RCS1238.severity = none # Avoid nested ?: operators — covered by SST1147 +dotnet_diagnostic.RCS1244.severity = none # Simplify 'default' expression — covered by SST1188 dotnet_diagnostic.RCS1249.severity = none # Unnecessary null-forgiving operator — disabled because multi-TFM nullability annotations can differ per platform, leading to false positives dotnet_diagnostic.RCS1251.severity = error # Remove unnecessary braces from record declaration dotnet_diagnostic.RCS1259.severity = error # Remove empty syntax (replaces RCS1066) dotnet_diagnostic.RCS1262.severity = error # Unnecessary raw string literal -dotnet_diagnostic.RCS1265.severity = error # Remove redundant catch block +dotnet_diagnostic.RCS1265.severity = none # Remove redundant catch block — covered by SST1470 dotnet_diagnostic.RCS1268.severity = error # Simplify numeric comparison -################### -# Roslynator Analyzers (RCS1xxx) - Code Quality -################### -dotnet_diagnostic.RCS1013.severity = error # Use predefined type +# Code quality +dotnet_diagnostic.RCS1013.severity = none # Use predefined type — covered by SST1121 dotnet_diagnostic.RCS1014.severity = error # Use explicitly/implicitly typed array dotnet_diagnostic.RCS1015.severity = error # Use nameof operator -dotnet_diagnostic.RCS1016.severity = error # Use block body or expression body -dotnet_diagnostic.RCS1020.severity = error # Simplify Nullable to T? +dotnet_diagnostic.RCS1016.severity = none # Use block body or expression body — conflicts with SST2219, which owns the expression-bodied accessor direction +dotnet_diagnostic.RCS1020.severity = none # Covered by SST2234 (canonical) dotnet_diagnostic.RCS1021.severity = error # Convert lambda expression body to expression body dotnet_diagnostic.RCS1044.severity = none # Remove original exception from throw statement — covered by SST1430 dotnet_diagnostic.RCS1046.severity = none # Asynchronous method name should end with 'Async' — TUnit test method naming convention doesn't follow the Async suffix — covered by SST1317 dotnet_diagnostic.RCS1047.severity = error # Non-asynchronous method name should not end with 'Async' -dotnet_diagnostic.RCS1048.severity = error # Use lambda expression instead of anonymous method +dotnet_diagnostic.RCS1048.severity = none # Use lambda expression instead of anonymous method — covered by SST1130 dotnet_diagnostic.RCS1050.severity = error # Include/omit parentheses when creating new object -dotnet_diagnostic.RCS1051.severity = error # Add/remove parentheses from condition in conditional operator +dotnet_diagnostic.RCS1051.severity = none # Add/remove parentheses from condition in conditional operator — conflicts with SST1459, which owns the non-grouping-parenthesis removal direction dotnet_diagnostic.RCS1056.severity = none # Avoid usage of using alias directive - used to avoid conflicts -dotnet_diagnostic.RCS1059.severity = error # Avoid locking on publicly accessible instance +dotnet_diagnostic.RCS1059.severity = none # Avoid locking on publicly accessible instance — covered by SST1901 dotnet_diagnostic.RCS1075.severity = none # Avoid empty catch clause that catches System.Exception — covered by SST1429 dotnet_diagnostic.RCS1079.severity = error # Throwing of new NotImplementedException dotnet_diagnostic.RCS1081.severity = error # Split variable declaration dotnet_diagnostic.RCS1093.severity = error # File contains no code -dotnet_diagnostic.RCS1094.severity = error # Declare using directive on top level -dotnet_diagnostic.RCS1096.severity = error # Use 'HasFlag' method or bitwise operator +dotnet_diagnostic.RCS1094.severity = none # Declare using directive on top level — covered by SST1200 +dotnet_diagnostic.RCS1096.severity = none # Use 'HasFlag' method or bitwise operator — covered by PSH1016 dotnet_diagnostic.RCS1098.severity = none # Constant values should be placed on right side of comparisons — covered by SST1186 -dotnet_diagnostic.RCS1099.severity = error # Default label should be the last label in a switch section +dotnet_diagnostic.RCS1099.severity = none # Default label should be the last label in a switch section — covered by SST1466 dotnet_diagnostic.RCS1102.severity = none # Make class static — covered by SST1432 dotnet_diagnostic.RCS1108.severity = error # Add 'static' modifier to all partial class declarations dotnet_diagnostic.RCS1111.severity = error # Add braces to switch section with multiple statements dotnet_diagnostic.RCS1113.severity = error # Use 'string.IsNullOrEmpty' method -dotnet_diagnostic.RCS1118.severity = error # Mark local variable as const -dotnet_diagnostic.RCS1123.severity = error # Add parentheses when necessary -dotnet_diagnostic.RCS1130.severity = error # Bitwise operation on enum without Flags attribute +dotnet_diagnostic.RCS1118.severity = none # Mark local variable as const — covered by PSH1402 +dotnet_diagnostic.RCS1123.severity = none # Add parentheses when necessary — covered by SST1407 +dotnet_diagnostic.RCS1130.severity = none # Bitwise operation on enum without Flags attribute — covered by SST2458 dotnet_diagnostic.RCS1135.severity = error # Declare enum member with zero value (when enum has FlagsAttribute) -dotnet_diagnostic.RCS1136.severity = error # Merge switch sections with equivalent content +dotnet_diagnostic.RCS1136.severity = none # Merge switch sections with equivalent content — covered by SST2414 dotnet_diagnostic.RCS1154.severity = error # Sort enum members -dotnet_diagnostic.RCS1155.severity = error # Use StringComparison when comparing strings -dotnet_diagnostic.RCS1156.severity = error # Use string.Length instead of comparison with empty string -dotnet_diagnostic.RCS1157.severity = error # Composite enum value contains undefined flag +dotnet_diagnostic.RCS1155.severity = none # Use StringComparison when comparing strings — covered by PSH1207 +dotnet_diagnostic.RCS1156.severity = none # Use string.Length instead of comparison with empty string — covered by PSH1204 +dotnet_diagnostic.RCS1157.severity = none # Composite enum value contains undefined flag — covered by SST2303 dotnet_diagnostic.RCS1159.severity = error # Use EventHandler dotnet_diagnostic.RCS1160.severity = none # Abstract type should not have public constructors — covered by SST1428 dotnet_diagnostic.RCS1161.severity = none # Enum should declare explicit values - do not need explicit values dotnet_diagnostic.RCS1162.severity = none # Avoid chain of assignments — covered by SST1187 -dotnet_diagnostic.RCS1166.severity = error # Value type object is never equal to null +dotnet_diagnostic.RCS1166.severity = none # Value type object is never equal to null — covered by SST1469 dotnet_diagnostic.RCS1168.severity = none # Parameter name differs from base name — covered by SST1318 dotnet_diagnostic.RCS1169.severity = error # Make field read-only dotnet_diagnostic.RCS1170.severity = error # Use read-only auto-implemented property dotnet_diagnostic.RCS1172.severity = none # Use 'is' operator instead of 'as' operator — covered by SST2005 -dotnet_diagnostic.RCS1187.severity = error # Use constant instead of field +dotnet_diagnostic.RCS1187.severity = none # Use constant instead of field — covered by PSH1402 dotnet_diagnostic.RCS1191.severity = error # Declare enum value as combination of names -dotnet_diagnostic.RCS1193.severity = error # Overriding member should not change 'params' modifier +dotnet_diagnostic.RCS1193.severity = none # Overriding member should not change 'params' modifier — covered by SST2426 dotnet_diagnostic.RCS1196.severity = error # Call extension method as instance method -dotnet_diagnostic.RCS1200.severity = error # Call 'Enumerable.ThenBy' instead of 'Enumerable.OrderBy' +dotnet_diagnostic.RCS1200.severity = none # Call 'Enumerable.ThenBy' instead of 'Enumerable.OrderBy' — covered by PSH1108 dotnet_diagnostic.RCS1201.severity = error # Use method chaining dotnet_diagnostic.RCS1202.severity = error # Avoid NullReferenceException dotnet_diagnostic.RCS1204.severity = error # Use EventArgs.Empty dotnet_diagnostic.RCS1205.severity = error # Order named arguments according to the order of parameters dotnet_diagnostic.RCS1208.severity = error # Reduce 'if' nesting dotnet_diagnostic.RCS1209.severity = error # Order type parameter constraints -dotnet_diagnostic.RCS1210.severity = error # Return completed task instead of returning null +dotnet_diagnostic.RCS1210.severity = none # Return completed task instead of returning null — covered by PSH1312 dotnet_diagnostic.RCS1215.severity = error # Expression is always equal to true/false dotnet_diagnostic.RCS1222.severity = error # Merge preprocessor directives dotnet_diagnostic.RCS1223.severity = suggestion # Mark publicly visible type with DebuggerDisplay attribute — only data types benefit; the rule is too broad to be an error dotnet_diagnostic.RCS1224.severity = error # Make method an extension method dotnet_diagnostic.RCS1225.severity = error # Make class sealed -dotnet_diagnostic.RCS1227.severity = error # Validate arguments correctly +dotnet_diagnostic.RCS1227.severity = none # Validate arguments correctly — covered by SST2404 dotnet_diagnostic.RCS1229.severity = error # Use async/await when necessary -dotnet_diagnostic.RCS1231.severity = suggestion # Make parameter ref read-only -dotnet_diagnostic.RCS1233.severity = error # Use short-circuiting operator +dotnet_diagnostic.RCS1231.severity = none # Make parameter ref read-only — covered by PSH1007 +dotnet_diagnostic.RCS1233.severity = none # Use short-circuiting operator — covered by SST1468 dotnet_diagnostic.RCS1234.severity = error # Duplicate enum value dotnet_diagnostic.RCS1239.severity = error # Use 'for' statement instead of 'while' statement dotnet_diagnostic.RCS1240.severity = error # Operator is unnecessary -dotnet_diagnostic.RCS1242.severity = error # Do not pass non-read-only struct by read-only reference -dotnet_diagnostic.RCS1243.severity = error # Duplicate word in a comment +dotnet_diagnostic.RCS1242.severity = none # Do not pass non-read-only struct by read-only reference — covered by PSH1003 +dotnet_diagnostic.RCS1243.severity = none # Duplicate word in a comment — covered by SST1658 (documentation comments) dotnet_diagnostic.RCS1247.severity = error # Fix documentation comment tag -dotnet_diagnostic.RCS1248.severity = error # Normalize null check -dotnet_diagnostic.RCS1250.severity = error # Use implicit/explicit object creation +dotnet_diagnostic.RCS1248.severity = none # Normalize null check — conflicts with SST1149, which owns the 'is null' pattern direction +dotnet_diagnostic.RCS1250.severity = none # Use implicit/explicit object creation — conflicts with SST2202, which owns the implicit-target-type direction dotnet_diagnostic.RCS1252.severity = error # Normalize usage of infinite loop dotnet_diagnostic.RCS1254.severity = error # Normalize format of enum flag value dotnet_diagnostic.RCS1255.severity = none # Simplify argument null check — conflicts with our ArgumentExceptionHelper helper pattern dotnet_diagnostic.RCS1257.severity = error # Use enum field explicitly dotnet_diagnostic.RCS1258.severity = error # Unnecessary enum flag -dotnet_diagnostic.RCS1260.severity = error # Add/remove trailing comma -dotnet_diagnostic.RCS1261.severity = error # Resource can be disposed asynchronously +dotnet_diagnostic.RCS1260.severity = none # Add/remove trailing comma — conflicts with SST1413, which owns the trailing-comma direction +dotnet_diagnostic.RCS1261.severity = none # Resource can be disposed asynchronously — covered by PSH1310 dotnet_diagnostic.RCS1264.severity = error # Use 'var' or explicit type (replaces RCS1010, RCS1176, RCS1177) -dotnet_diagnostic.RCS1266.severity = error # Use raw string literal +dotnet_diagnostic.RCS1266.severity = none # Use raw string literal — covered by SST2243 dotnet_diagnostic.RCS1267.severity = error # Use string interpolation instead of 'string.Concat' -################### -# Roslynator Analyzers (RCS1xxx) - Performance -################### -dotnet_diagnostic.RCS1077.severity = error # Optimize LINQ method call -dotnet_diagnostic.RCS1080.severity = error # Use 'Count/Length' property instead of 'Any' method -dotnet_diagnostic.RCS1112.severity = error # Combine 'Enumerable.Where' method chain +# Performance +dotnet_diagnostic.RCS1077.severity = none # Covered by the PSH1101-PSH1111 family (canonical) +dotnet_diagnostic.RCS1080.severity = none # Covered by PSH1106 (canonical) +dotnet_diagnostic.RCS1112.severity = none # Combine 'Enumerable.Where' method chain — covered by PSH1109 dotnet_diagnostic.RCS1186.severity = error # Use Regex instance instead of static method -dotnet_diagnostic.RCS1190.severity = error # Join string expressions +dotnet_diagnostic.RCS1190.severity = none # Join string expressions — conflicts with SST2470, which reports the fused-literal seam this would create dotnet_diagnostic.RCS1195.severity = error # Use ^ operator -dotnet_diagnostic.RCS1197.severity = error # Optimize StringBuilder.Append/AppendLine call +dotnet_diagnostic.RCS1197.severity = none # Optimize StringBuilder.Append/AppendLine call — covered by PSH1203/PSH1214 dotnet_diagnostic.RCS1198.severity = none # Avoid unnecessary boxing of value type — boxing is unavoidable bridging Rx and IEnumerable -dotnet_diagnostic.RCS1230.severity = error # Unnecessary explicit use of enumerator +dotnet_diagnostic.RCS1230.severity = none # Unnecessary explicit use of enumerator — covered by SST1467 dotnet_diagnostic.RCS1235.severity = error # Optimize method call -dotnet_diagnostic.RCS1236.severity = error # Use exception filter -dotnet_diagnostic.RCS1246.severity = error # Use element access +dotnet_diagnostic.RCS1236.severity = none # Use exception filter — covered by SST2009 +dotnet_diagnostic.RCS1246.severity = none # Use element access — covered by PSH1106 -################### -# Roslynator Analyzers (RCS1xxx) - Maintainability -################### +# Maintainability dotnet_diagnostic.RCS1158.severity = none # Static member in generic type should use a type parameter — common factory pattern — covered by SST1431 dotnet_diagnostic.RCS1163.severity = none # Unused parameter — interface implementations and Rx selectors often have unused parameters dotnet_diagnostic.RCS1164.severity = none # Unused type parameter - DUPLICATE IDE0060 (UnusedParameter analyzer 210ms; IDE0060 bundled at lower cost) @@ -1030,9 +1013,7 @@ dotnet_diagnostic.RCS1213.severity = none # Remove unused member declaration - D dotnet_diagnostic.RCS1241.severity = error # Implement non-generic counterpart dotnet_diagnostic.RCS1256.severity = none # Invalid argument null check — conflicts with our ArgumentExceptionHelper helper pattern -################### -# Roslynator Analyzers (RCS1xxx) - Documentation -################### +# Documentation dotnet_diagnostic.RCS1181.severity = error # Convert comment to documentation comment dotnet_diagnostic.RCS1189.severity = error # Add or remove region name dotnet_diagnostic.RCS1226.severity = none # Add paragraph to documentation comment — <para> wrapping is subjective and adds noise @@ -1041,9 +1022,7 @@ dotnet_diagnostic.RCS1232.severity = error # Order elements in documentation com dotnet_diagnostic.RCS1253.severity = error # Format documentation comment summary dotnet_diagnostic.RCS1263.severity = none # Invalid reference in a documentation comment -################### -# Roslynator Analyzers (RCS1xxx) - Disabled (covered by CA/SA equivalent) -################### +# Disabled dotnet_diagnostic.RCS1018.severity = none # Add/remove accessibility modifiers — covered by SA1400 dotnet_diagnostic.RCS1019.severity = none # Order modifiers — covered by SA1206 / SA1208 dotnet_diagnostic.RCS1037.severity = none # Remove trailing white-space — covered by SA1028 @@ -1061,9 +1040,7 @@ dotnet_diagnostic.RCS1175.severity = none # Unused 'this' parameter — covered dotnet_diagnostic.RCS1194.severity = none # Implement exception constructors — covered by CA1032 dotnet_diagnostic.RCS1203.severity = none # Use AttributeUsageAttribute — covered by CA1018 -################### -# Roslynator Formatting Analyzers (RCS0xxx) - covered by StyleCop SA equivalents -################### +# Formatting dotnet_diagnostic.RCS0001.severity = none # Add blank line after embedded statement — covered by StyleCop layout rules dotnet_diagnostic.RCS0002.severity = none # Add blank line after #region — covered by StyleCop SA1517 family dotnet_diagnostic.RCS0003.severity = none # Add blank line after using directive list — covered by SA1516 @@ -1121,14 +1098,32 @@ dotnet_diagnostic.RCS0063.severity = none # Remove unnecessary blank line — co file_header_template = Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved.\nChris Pulman and Contributors licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for full license information. stylesharp.summary_single_line_max_length = 120 +# SonarAnalyzer's S4022 only ever flagged storage narrower than int; uint, long and ulong +# passed silently. Match that, so retiring S4022 for SST2313 does not fail a build on an +# enum that was deliberately widened. +stylesharp.SST2313.allowed_enum_storage = int, uint, long, ulong + # Documentation coverage scope (SST1600/SST1601/SST1602/SST1654). # Require documentation on every element, including private members. stylesharp.document_exposed_elements = true stylesharp.document_internal_elements = true stylesharp.document_private_elements = true +stylesharp.document_private_fields = true stylesharp.document_interfaces = all stylesharp.SST1305.allowed_hungarian_prefixes = rx - +stylesharp.instance_member_qualification = omit_this +stylesharp.max_cyclomatic_complexity = 10 +stylesharp.max_cognitive_complexity = 15 +stylesharp.max_property_cognitive_complexity = 3 +# SST1484 also reports a field that shadows one inherited from a base type. +stylesharp.SST1484.check_base_types = true +stylesharp.max_line_length = 200 # SST1521 (characters; keeps the limit this repo has always enforced) +stylesharp.max_file_lines = 1000 # SST1522 (code lines; blank lines and comments do not count) +# stylesharp.max_member_lines = 60 # SST1523 (code lines) +# stylesharp.max_switch_section_lines = 20 # SST1524 (code lines) +# stylesharp.include_internal = true # SST1499 (set false to report only fields visible outside the assembly) +# stylesharp.require_parameterless = true # SST1488 (set false where every exception must carry a message) +# stylesharp.include_non_public_types = true # SST1488 (set false to check only externally visible exceptions) # Spacing dotnet_diagnostic.SST1000.severity = error # A control-flow keyword is not followed by a space dotnet_diagnostic.SST1001.severity = error # A comma is spaced incorrectly @@ -1162,7 +1157,7 @@ dotnet_diagnostic.SST1028.severity = error # A line ends with trailing whitespac # Readability and maintainability dotnet_diagnostic.SST1100.severity = error # A base. prefix is used where the type does not override the member -dotnet_diagnostic.SST1101.severity = none # An instance member is accessed without a this. prefix — we don't require this. prefixing +dotnet_diagnostic.SST1101.severity = none # see docs/rules/SST1101.md dotnet_diagnostic.SST1102.severity = error # A query clause is separated from the previous clause by a blank line dotnet_diagnostic.SST1103.severity = error # Query clauses mix single-line and multi-line layout dotnet_diagnostic.SST1104.severity = error # A query clause shares the last line of a multi-line previous clause @@ -1175,9 +1170,12 @@ dotnet_diagnostic.SST1112.severity = error # An empty parameter list's closing p dotnet_diagnostic.SST1113.severity = error # A comma does not sit on the previous parameter's line dotnet_diagnostic.SST1114.severity = error # A blank line separates the declaration from its parameter list dotnet_diagnostic.SST1115.severity = error # A blank line separates a parameter from the preceding comma +dotnet_diagnostic.SST1116.severity = error # A qualified name can be shortened without changing the symbol it binds to +dotnet_diagnostic.SST1117.severity = error # Instance member access follows the configured this. qualification style dotnet_diagnostic.SST1118.severity = none # A parameter should not span multiple lines +dotnet_diagnostic.SST1119.severity = error # A numeric literal groups its digit separators irregularly dotnet_diagnostic.SST1120.severity = error # A comment contains no text -dotnet_diagnostic.SST1121.severity = none # A framework type name is used instead of its built-in alias — duplicate of RCS1013 +dotnet_diagnostic.SST1121.severity = error # A framework type name is used instead of its built-in alias (opt-in rule, enabled here) dotnet_diagnostic.SST1122.severity = error # An empty string literal is used instead of string.Empty dotnet_diagnostic.SST1123.severity = error # A #region is placed inside a code element body dotnet_diagnostic.SST1124.severity = error # A #region directive is used @@ -1193,7 +1191,9 @@ dotnet_diagnostic.SST1134.severity = error # An attribute shares a line with ano dotnet_diagnostic.SST1135.severity = error # A using directive names a namespace or type that is not fully qualified dotnet_diagnostic.SST1136.severity = error # Several enum members share a line dotnet_diagnostic.SST1137.severity = error # Sibling elements are indented differently from one another +dotnet_diagnostic.SST1138.severity = error # A free-standing block declares nothing dotnet_diagnostic.SST1139.severity = error # A numeric literal is cast where a literal suffix would express the type +dotnet_diagnostic.SST1140.severity = error # Wrapped conditional operators should start indented continuation lines dotnet_diagnostic.SST1141.severity = error # An explicit ValueTuple<...> is used where tuple syntax would do dotnet_diagnostic.SST1142.severity = error # A tuple element is accessed by ItemN where it has a name dotnet_diagnostic.SST1143.severity = error # A boolean expression is compared to a true/false literal @@ -1245,10 +1245,17 @@ dotnet_diagnostic.SST1188.severity = error # Use the 'default' literal instead o dotnet_diagnostic.SST1189.severity = error # Variables should not be self-assigned dotnet_diagnostic.SST1190.severity = error # Doubled negation operators should be removed dotnet_diagnostic.SST1191.severity = error # Long numeric literals should use digit separators -dotnet_diagnostic.SST1192.severity = none # Control characters in string literals should be escaped +dotnet_diagnostic.SST1192.severity = none # Control characters in string literals should be escaped +dotnet_diagnostic.SST1193.severity = error # Keep initial member values with construction +dotnet_diagnostic.SST1194.severity = error # Keep initial collection values with construction +dotnet_diagnostic.SST1195.severity = error # Write null fallback with ?? +dotnet_diagnostic.SST1196.severity = error # Write null-guarded access with ?. +dotnet_diagnostic.SST1197.severity = error # Collapse return-only branches into one return +dotnet_diagnostic.SST1198.severity = error # Collapse assignment-only branches into one assignment +dotnet_diagnostic.SST1199.severity = error # Prefer compile-time type names # Ordering -dotnet_diagnostic.SST1200.severity = none # Using directives should be placed outside the namespace — usings live outside file-scoped namespaces +dotnet_diagnostic.SST1200.severity = error # Using directives should be placed outside the namespace dotnet_diagnostic.SST1201.severity = error # Members should be ordered by kind dotnet_diagnostic.SST1202.severity = error # Members should be ordered by accessibility dotnet_diagnostic.SST1203.severity = error # Constants should appear before fields @@ -1266,6 +1273,10 @@ dotnet_diagnostic.SST1214.severity = error # Static readonly fields should appea dotnet_diagnostic.SST1215.severity = error # Instance readonly fields should appear before instance non-readonly fields dotnet_diagnostic.SST1216.severity = error # Using static directives should be placed after regular usings and before aliases dotnet_diagnostic.SST1217.severity = error # Using static directives should be ordered alphabetically +dotnet_diagnostic.SST1218.severity = error # Other members separate a method's overloads +dotnet_diagnostic.SST1219.severity = error # A switch default clause is not placed last +dotnet_diagnostic.SST1220.severity = error # An all-named argument list is in a different order than the parameters. Code fix reorders it to declaration order. Info. +dotnet_diagnostic.SST1221.severity = error # `where` constraint clauses are not ordered to match the type-parameter list. Code fix reorders them. Info. # Naming dotnet_diagnostic.SST1300.severity = none # Types and members should be PascalCase — naming duplicates existing analyzers @@ -1286,6 +1297,9 @@ dotnet_diagnostic.SST1315.severity = error # Union member names should match the dotnet_diagnostic.SST1316.severity = none # Tuple element names should use the configured casing — tuple naming is not enforced here dotnet_diagnostic.SST1317.severity = none # Asynchronous method names should end with 'Async' — conflicts with this project's Rx-compatibility and naming mechanism dotnet_diagnostic.SST1318.severity = error # Overriding parameter names should match the base declaration +dotnet_diagnostic.SST1319.severity = error # An enumeration's type name is not PascalCase +dotnet_diagnostic.SST1320.severity = error # A parameter name matches its method's name +dotnet_diagnostic.SST1321.severity = none # Public APIs intentionally use Async to describe asynchronous observable behavior without returning an awaitable. # Maintainability dotnet_diagnostic.SST1400.severity = error # An element does not declare an access modifier @@ -1321,13 +1335,72 @@ dotnet_diagnostic.SST1430.severity = error # Rethrow with 'throw;' to preserve t dotnet_diagnostic.SST1431.severity = error # Static members of a generic type should use a type parameter dotnet_diagnostic.SST1432.severity = error # Classes with only static members should be static dotnet_diagnostic.SST1433.severity = error # Redundant constructors should be removed -dotnet_diagnostic.SST1434.severity = error # Empty finalizers should be removed +dotnet_diagnostic.SST1434.severity = error # see docs/rules/SST1434.md dotnet_diagnostic.SST1435.severity = error # Empty namespace declarations should be removed dotnet_diagnostic.SST1436.severity = error # Empty types should not be declared dotnet_diagnostic.SST1437.severity = error # Empty interfaces should not be declared dotnet_diagnostic.SST1438.severity = error # Methods should not be empty dotnet_diagnostic.SST1439.severity = error # Nested code blocks should not be left empty +dotnet_diagnostic.SST1440.severity = error # Private members with no local use should be removed +dotnet_diagnostic.SST1441.severity = error # Private fields assigned but never read should be removed +dotnet_diagnostic.SST1442.severity = error # A function has too many direct branch points +dotnet_diagnostic.SST1443.severity = error # A function has too much nested control flow +dotnet_diagnostic.SST1444.severity = error # A loop cannot naturally reach a second iteration +dotnet_diagnostic.SST1445.severity = error # A using directive is unnecessary +dotnet_diagnostic.SST1446.severity = error # An inheritance chain is deeper than the configured maximum +dotnet_diagnostic.SST1447.severity = error # An equality override delegates to object reference semantics +dotnet_diagnostic.SST1448.severity = error # An argument is passed explicitly to a caller-info parameter +dotnet_diagnostic.SST1449.severity = error # Code writes directly to the console dotnet_diagnostic.SST1450.severity = error # Store files as UTF-8 without a byte order mark +dotnet_diagnostic.SST1451.severity = error # A DateTime is created without a DateTimeKind +dotnet_diagnostic.SST1452.severity = error # A generic type parameter is never used +dotnet_diagnostic.SST1453.severity = error # Statements after an unconditional exit should be removed +dotnet_diagnostic.SST1454.severity = error # Composite format placeholders should match the supplied arguments +dotnet_diagnostic.SST1455.severity = error # Unsafe modifiers should be used only when unsafe syntax is present +dotnet_diagnostic.SST1456.severity = error # Readonly fields should not store mutable source-defined structs +dotnet_diagnostic.SST1457.severity = error # Global suppressions should point at real declarations +dotnet_diagnostic.SST1458.severity = error # Global suppression targets should use declaration ids directly +dotnet_diagnostic.SST1459.severity = error # Grouping parentheses should be removed when the parent syntax already isolates the expression +dotnet_diagnostic.SST1460.severity = error # Non-mutating struct members should be readonly +dotnet_diagnostic.SST1461.severity = error # Private parameters that are never read should be removed +dotnet_diagnostic.SST1462.severity = error # Suppressions for diagnostics already disabled by config should be removed +dotnet_diagnostic.SST1463.severity = error # Symbol-name strings should use nameof +dotnet_diagnostic.SST1464.severity = error # An else clause follows a branch that always jumps and can be unwrapped +dotnet_diagnostic.SST1465.severity = error # An else block that only wraps an if can collapse to else-if +dotnet_diagnostic.SST1466.severity = error # A case label sharing a section with default is redundant +dotnet_diagnostic.SST1467.severity = error # A hand-driven enumerator loop can use foreach +dotnet_diagnostic.SST1468.severity = error # Boolean logic should use the short-circuiting && and || operators +dotnet_diagnostic.SST1469.severity = error # A non-nullable value type is compared to null +dotnet_diagnostic.SST1470.severity = error # A trailing catch clause that only rethrows should be removed +dotnet_diagnostic.SST1471.severity = error # Magic numbers should be named constants +dotnet_diagnostic.SST1472.severity = error # Signatures should not declare too many parameters +dotnet_diagnostic.SST1473.severity = error # A floating-point value is compared for exact equality (zero comparison allowed by default) +dotnet_diagnostic.SST1474.severity = error # Both sides of an operator are the same expression +dotnet_diagnostic.SST1475.severity = error # A condition repeats an earlier one in the chain, so its branch cannot run +dotnet_diagnostic.SST1476.severity = error # Every branch of a conditional has the same body +dotnet_diagnostic.SST1477.severity = error # An integer division is widened to floating point after it has already truncated +dotnet_diagnostic.SST1478.severity = error # A shift count is zero, negative, or at least the operand's width +dotnet_diagnostic.SST1479.severity = error # A count or length is compared against a value it can never take +dotnet_diagnostic.SST1480.severity = error # An exception is constructed and then discarded +dotnet_diagnostic.SST1481.severity = error # A bitwise operation has a constant operand that makes it pointless +dotnet_diagnostic.SST1482.severity = error # GetHashCode reads mutable state +dotnet_diagnostic.SST1483.severity = error # A constructor calls an overridable member +dotnet_diagnostic.SST1484.severity = error # A declaration shadows an outer field or property (inherited fields included) +dotnet_diagnostic.SST1485.severity = error # A member that must not throw throws +dotnet_diagnostic.SST1486.severity = error # The same string literal is repeated instead of being named +dotnet_diagnostic.SST1487.severity = error # A collection element is assigned twice with nothing reading it in between +dotnet_diagnostic.SST1488.severity = error # An exception type does not declare the standard constructors (parameterless waivable) +dotnet_diagnostic.SST1489.severity = error # An exception type carries serialization members the target framework has obsoleted +dotnet_diagnostic.SST1490.severity = error # A base list names an interface the rest of the list already implies +dotnet_diagnostic.SST1491.severity = error # A modifier restates the declaration's default +dotnet_diagnostic.SST1492.severity = error # A value is tested against what it is then assigned, so the guard decides nothing +dotnet_diagnostic.SST1493.severity = error # A method's whole body is a constant +dotnet_diagnostic.SST1494.severity = error # A trailing argument repeats the parameter's default +dotnet_diagnostic.SST1495.severity = error # '==' compares references on a type that overrides Equals, so the two disagree +dotnet_diagnostic.SST1496.severity = error # An abstract type declares nothing abstract +dotnet_diagnostic.SST1497.severity = error # A local is declared and never read +dotnet_diagnostic.SST1498.severity = error # Only a nested type uses a private member +dotnet_diagnostic.SST1499.severity = error # A static field visible outside its type can still be changed (internal fields included by default) # Layout dotnet_diagnostic.SST1500.severity = error # A brace in a multi-line construct shares its line with other code @@ -1351,6 +1424,19 @@ dotnet_diagnostic.SST1517.severity = error # The file begins with one or more bl dotnet_diagnostic.SST1518.severity = error # The file does not end with exactly one newline dotnet_diagnostic.SST1519.severity = error # A multi-line child statement of a control-flow keyword omits its braces dotnet_diagnostic.SST1520.severity = error # The clauses of an if/else chain use braces inconsistently +dotnet_diagnostic.SST1521.severity = error # A line is longer than the configured maximum (default 120 characters) +dotnet_diagnostic.SST1522.severity = error # A file has more code lines than the configured maximum (default 500) +dotnet_diagnostic.SST1523.severity = error # A member has more code lines than the configured maximum (default 60) +dotnet_diagnostic.SST1524.severity = error # A switch section has more code lines than the configured maximum (default 20) +dotnet_diagnostic.SST1525.severity = error # A multi-statement `switch` section has no braces; the braces-on policy extends to switch sections. Code fix wraps it. +dotnet_diagnostic.SST1526.severity = error # A wrapped binary expression places the operator inconsistently. Configurable (`before`/`after`, default before). Opt-in. +dotnet_diagnostic.SST1527.severity = error # The `=>` of an expression-bodied member wraps inconsistently. Configurable. Opt-in. +dotnet_diagnostic.SST1528.severity = error # The `=` of a wrapped initializer wraps inconsistently. Configurable. Opt-in. +dotnet_diagnostic.SST1529.severity = error # A wrapped `?.`/`.` call chain places the break inconsistently. Configurable. Opt-in. +dotnet_diagnostic.SST1530.severity = error # A newline sits between a type declaration and its base list. Code fix pulls the base list onto the declaration line. Opt-in. +dotnet_diagnostic.SST1531.severity = error # A short object initializer is split across lines. Code fix collapses it when it fits. Opt-in. +dotnet_diagnostic.SST1532.severity = error # A file mixes line endings. Configurable (`lf`/`crlf`, default lf). Opt-in. +dotnet_diagnostic.SST1533.severity = error # A source file contains no code. Opt-in. # Documentation dotnet_diagnostic.SST1600.severity = error # Externally visible members should be documented @@ -1361,7 +1447,7 @@ dotnet_diagnostic.SST1605.severity = error # Partial element documentation shoul dotnet_diagnostic.SST1606.severity = error # The summary should have text dotnet_diagnostic.SST1607.severity = error # Partial element summary should have text dotnet_diagnostic.SST1608.severity = error # Documentation should not use the default placeholder summary -dotnet_diagnostic.SST1609.severity = none # Property documentation should have a value +dotnet_diagnostic.SST1609.severity = none # Property documentation should have a value dotnet_diagnostic.SST1610.severity = error # Property value documentation should have text dotnet_diagnostic.SST1611.severity = error # Parameters should be documented dotnet_diagnostic.SST1612.severity = error # Parameter documentation should match the parameters @@ -1398,12 +1484,21 @@ dotnet_diagnostic.SST1654.severity = error # Extension blocks should be document dotnet_diagnostic.SST1655.severity = error # Extension block parameters should be documented dotnet_diagnostic.SST1656.severity = error # Extension block type parameters should be documented dotnet_diagnostic.SST1657.severity = error # Extension block documentation should reference a real parameter or type parameter +dotnet_diagnostic.SST1658.severity = error # Documentation text repeats a word +dotnet_diagnostic.SST1659.severity = error # A comment has no text at all +dotnet_diagnostic.SST1660.severity = error # The `` tags are not in parameter order. Code fix reorders them. Info. +dotnet_diagnostic.SST1661.severity = error # A snippet uses ``/`` mismatched to single- vs multi-line content. Code fix swaps the tag. Info. +dotnet_diagnostic.SST1662.severity = none # A thrown exception type has no `` documentation. Code fix adds the skeleton. Opt-in. +dotnet_diagnostic.SST1663.severity = none # A `//` comment before a public member reads like a summary; use `///`. Code fix converts it. Opt-in. +dotnet_diagnostic.SST1664.severity = none # A summary separates paragraphs with blank lines instead of ``. Code fix wraps them. Opt-in. # Concurrency and modernization -dotnet_diagnostic.SST1900.severity = error # A dedicated object lock field should be a System.Threading.Lock +dotnet_diagnostic.SST1900.severity = error # see docs/rules/SST1900.md dotnet_diagnostic.SST1901.severity = error # A lock targets a field or property reachable from outside the declaring type dotnet_diagnostic.SST1902.severity = error # Do not lock on 'this', a Type, or a string dotnet_diagnostic.SST1903.severity = error # Do not lock on a newly-created object +dotnet_diagnostic.SST1904.severity = error # A lock targets a non-readonly field +dotnet_diagnostic.SST1905.severity = error # An async method or converted delegate returns void dotnet_diagnostic.SST2000.severity = suggestion # A null check plus throw should use ArgumentNullException.ThrowIfNull dotnet_diagnostic.SST2001.severity = error # Use ArgumentException.ThrowIfNullOrEmpty dotnet_diagnostic.SST2002.severity = error # Use ArgumentException.ThrowIfNullOrWhiteSpace @@ -1411,6 +1506,18 @@ dotnet_diagnostic.SST2003.severity = suggestion # A disposed check should use Ob dotnet_diagnostic.SST2004.severity = suggestion # A range check should use an ArgumentOutOfRangeException.ThrowIf... helper dotnet_diagnostic.SST2005.severity = error # Use the 'is' type pattern instead of comparing an 'as' cast to null dotnet_diagnostic.SST2006.severity = error # Use the 'is not' pattern instead of negating an 'is' check +dotnet_diagnostic.SST2007.severity = error # Use declaration patterns instead of an is check followed by a cast local +dotnet_diagnostic.SST2008.severity = error # Negated pattern tests should use is-not patterns +dotnet_diagnostic.SST2009.severity = error # A catch that tests then rethrows can use a when filter +dotnet_diagnostic.SST2010.severity = error # A type reads the machine clock directly instead of through a TimeProvider +dotnet_diagnostic.SST2011.severity = error # An instant is recorded from the local clock rather than in UTC +dotnet_diagnostic.SST2012.severity = error # A GUID is constructed with the parameterless constructor instead of Guid.Empty +dotnet_diagnostic.SST2013.severity = error # An if whose entire body is another if should be merged +dotnet_diagnostic.SST2014.severity = error # A goto jumps to a label +dotnet_diagnostic.SST2015.severity = error # A ++ or -- is buried inside a larger expression +dotnet_diagnostic.SST2016.severity = error # A DateTime on a visible signature loses its offset at the boundary +dotnet_diagnostic.SST2017.severity = error # A .Date or .TimeOfDay read proves the value is only a date, or only a time of day +dotnet_diagnostic.SST2018.severity = error # A redundant null check sits beside an is type pattern # Modern language and library usage dotnet_diagnostic.SST1700.severity = error # An extension block declares no members @@ -1421,25 +1528,497 @@ dotnet_diagnostic.SST1704.severity = error # A class declaring extension blocks dotnet_diagnostic.SST1705.severity = error # A class mixes classic extension methods with extension blocks dotnet_diagnostic.SST1706.severity = error # An extension block targets a broad receiver type such as object or dynamic dotnet_diagnostic.SST1707.severity = error # Extension blocks should be ordered by receiver type +dotnet_diagnostic.SST1708.severity = error # An extension method never uses its `this` receiver, so it need not be an extension. +dotnet_diagnostic.SST1709.severity = none # A method in a `*Extensions` class whose first parameter lacks `this`. Code fix converts it to an extension block. Opt-in. dotnet_diagnostic.SST1800.severity = error # Record classes should be sealed dotnet_diagnostic.SST1801.severity = error # A positional record parameter does not match the configured casing dotnet_diagnostic.SST1802.severity = error # A record declares a settable rather than init-only instance property dotnet_diagnostic.SST1803.severity = error # A record struct is not declared readonly +dotnet_diagnostic.SST1804.severity = error # A positional record has an empty `{ }` body where `;` would do. Code fix rewrites it. Info. dotnet_diagnostic.SST2100.severity = error # An empty collection creation can use [] dotnet_diagnostic.SST2101.severity = error # An explicit collection creation can use [...] +dotnet_diagnostic.SST2102.severity = error # A span-targeted stackalloc initializer can use a collection expression +dotnet_diagnostic.SST2103.severity = error # A collection-builder factory call can use a collection expression +dotnet_diagnostic.SST2104.severity = error # A short builder-local sequence can return a collection expression +dotnet_diagnostic.SST2105.severity = error # A literal array materialized with LINQ can use the target collection expression directly dotnet_diagnostic.SST2200.severity = error # A single-use backing field can use the C# 14 field keyword -dotnet_diagnostic.RCS1040.severity = none # covered by SST1180 - -################### -# PublicApiAnalyzers (RSxxxx) - public API surface tracking -################### +dotnet_diagnostic.SST2201.severity = error # A return-only switch statement can use a switch expression +dotnet_diagnostic.SST2202.severity = error # An object creation repeats an explicit target type +dotnet_diagnostic.SST2203.severity = error # An array or string index can use from-end indexing +dotnet_diagnostic.SST2204.severity = error # A string slice can use range syntax +dotnet_diagnostic.SST2205.severity = none # An enum switch statement omits named enum values — duplicate of IDE0010 (disabled: too noisy for default/fallthrough) and S131; statement switches deliberately no-op omitted values, and a default to satisfy it is rejected by SST1179/S3532. SST2206 keeps the valuable switch-expression exhaustiveness check. +dotnet_diagnostic.SST2206.severity = error # An enum switch expression omits named enum values +dotnet_diagnostic.SST2207.severity = error # A null guard and return can keep the throw in the returned expression +dotnet_diagnostic.SST2208.severity = error # An out variable can be declared at the call site +dotnet_diagnostic.SST2209.severity = none # A null-forgiving operator has no local effect +dotnet_diagnostic.SST2210.severity = error # A nullable directive repeats the current file-local state +dotnet_diagnostic.SST2211.severity = error # A nullable restore directive has no file-local state to restore +dotnet_diagnostic.SST2212.severity = error # Literal UTF-8 byte data can use a u8 string literal +dotnet_diagnostic.SST2213.severity = error # A typed pattern has an unnecessary discard designation +dotnet_diagnostic.SST2214.severity = error # A tuple temporary only feeds copied element locals +dotnet_diagnostic.SST2215.severity = error # A temporary local swaps two locals +dotnet_diagnostic.SST2216.severity = error # A tuple element name repeats the inferred name +dotnet_diagnostic.SST2217.severity = error # A manual hash-code expression can use System.HashCode.Combine +dotnet_diagnostic.SST2218.severity = error # Lambda parameter types can be omitted when the target already supplies them +dotnet_diagnostic.SST2219.severity = error # A single-expression property accessor can use an expression body +dotnet_diagnostic.SST2220.severity = error # An interpolation hole can carry the value and literal format directly +dotnet_diagnostic.SST2221.severity = error # An ignored expression value is assigned to the discard +dotnet_diagnostic.SST2222.severity = error # A local value is overwritten before it is read +dotnet_diagnostic.SST2223.severity = error # A null fallback assignment can use ??= +dotnet_diagnostic.SST2224.severity = error # An anonymous object can become a tuple literal for local value bundles +dotnet_diagnostic.SST2225.severity = error # A foreach loop hides an explicit element cast +dotnet_diagnostic.SST2226.severity = error # A cast hides an inner explicit conversion +dotnet_diagnostic.SST2227.severity = error # A post-assignment null fallback can be folded into the assigned expression +dotnet_diagnostic.SST2228.severity = error # A delegate local used only as a call target can be a local function +dotnet_diagnostic.SST2229.severity = error # see docs/rules/SST2229.md +dotnet_diagnostic.SST2230.severity = error # see docs/rules/SST2230.md +dotnet_diagnostic.SST2231.severity = error # A broad object pattern can use a direct null pattern +dotnet_diagnostic.SST2232.severity = error # nameof does not need concrete generic type arguments +dotnet_diagnostic.SST2233.severity = none # see docs/rules/SST2233.md +dotnet_diagnostic.SST2234.severity = error # Nullable should use the T? shorthand +dotnet_diagnostic.SST2235.severity = error # Capture-free local functions should be static +dotnet_diagnostic.SST2236.severity = error # Tail-position using blocks can use using declarations +dotnet_diagnostic.SST2237.severity = error # Single block-scoped namespaces can use file-scoped syntax +dotnet_diagnostic.SST2238.severity = error # Nested property patterns can use extended property syntax +dotnet_diagnostic.SST2239.severity = error # Forwarding lambdas can use method groups +dotnet_diagnostic.SST2240.severity = error # Delegate null checks can use conditional invocation +dotnet_diagnostic.SST2241.severity = error # Constructors that only store parameters can use primary-constructor storage +dotnet_diagnostic.SST2242.severity = error # Enum switch statement mappings should name every enum value or include a catch-all +dotnet_diagnostic.SST2243.severity = error # A verbatim string with escapes or line breaks can use a raw string literal +dotnet_diagnostic.SST2244.severity = error # A numeric literal's suffix is lower case +dotnet_diagnostic.SST2245.severity = error # A for loop with only a condition should be a while loop +dotnet_diagnostic.SST2246.severity = error # A chain of conditional expressions testing one value against constants can be a switch expression +dotnet_diagnostic.SST2247.severity = error # Consecutive locals copying one value's members in order can be a deconstruction +dotnet_diagnostic.SST2248.severity = error # Two constant comparisons of the same value can fold into one is-pattern +dotnet_diagnostic.SST2249.severity = error # A literal-format string.Format or literal concatenation can be an interpolated string +dotnet_diagnostic.SST2250.severity = error # A bare local assigned once by the next statement can be an initialized declaration +dotnet_diagnostic.SST2251.severity = error # A method call names type arguments that inference would supply +dotnet_diagnostic.SST2252.severity = error # A switch statement is nested inside another switch statement +dotnet_diagnostic.SST2254.severity = none # A target-typed `new()` is written where an explicit type reads more clearly; the code fix restores `new TypeName(...)`. Opt-in — the counterpart to SST2202's target-typed direction, so a team enables at most one. +dotnet_diagnostic.SST2255.severity = error # A hand-written null-or-empty string test. Code fix uses `string.IsNullOrEmpty`. +dotnet_diagnostic.SST2256.severity = error # An extension method called in static form. Code fix rewrites to instance form. Info. +dotnet_diagnostic.SST2257.severity = error # A lambda block body that is a single `return`. Code fix uses an expression body. Info. +dotnet_diagnostic.SST2258.severity = error # A redundant explicit delegate wrapper (`new EventHandler(M)`). Code fix drops it. Info. +dotnet_diagnostic.SST2259.severity = error # A stray `;` after a type declaration. Code fix removes it. Info. +dotnet_diagnostic.SST2260.severity = error # An `as` cast to a type the operand already has. Code fix removes it. Info. +dotnet_diagnostic.SST2261.severity = error # `(x && !y) +dotnet_diagnostic.SST2262.severity = error # A raw string literal whose content needs no raw syntax. Code fix demotes it. Info. +dotnet_diagnostic.SST2263.severity = error # An infinite loop whose body re-derives its stop condition. Code fix hoists the condition into the header. Info. +dotnet_diagnostic.SST2264.severity = error # A numeric literal cast to an enum. Code fix names the member. +dotnet_diagnostic.SST2265.severity = none # Consecutive fluent calls on one receiver can fold into a chain. Opt-in. +dotnet_diagnostic.SST2266.severity = none # A local read exactly once can be inlined into that use. Opt-in. +dotnet_diagnostic.SST2267.severity = none # Infinite loops written in mixed `while(true)`/`for(;;)` styles. Configurable. Opt-in. +dotnet_diagnostic.SST2268.severity = none # Inconsistent `()` on object creation with an initializer. Configurable. Opt-in. +dotnet_diagnostic.SST2269.severity = none # Inconsistent parentheses around a conditional's condition. Configurable. Opt-in. +dotnet_diagnostic.SST2270.severity = none # Inconsistent explicit-vs-implicit array-creation type. Configurable. Opt-in. +dotnet_diagnostic.SST2271.severity = none # `var`-vs-explicit local type per the configured preference. Configurable. Opt-in. +dotnet_diagnostic.SST2272.severity = none # `[Flags]` member values written as mixed decimals and shifts. Configurable. Opt-in. +dotnet_diagnostic.SST2273.severity = none # A function or loop body wraps its work in a trailing `if` that could be an early-exit guard clause. Code fix inverts it. Configurable threshold. Opt-in. +dotnet_diagnostic.SST2274.severity = error # A value assigned with `as` and then null-checked is an `is` declaration pattern in one step. Code fix rewrites it. +dotnet_diagnostic.SST2275.severity = error # A method whose block body is a single statement can use an expression body `=> expr`. Code fix rewrites it. +dotnet_diagnostic.SST2276.severity = none # A constructor whose block body is a single statement can use an expression body. Code fix rewrites it. Opt-in. +dotnet_diagnostic.SST2277.severity = none # An operator whose block body is a single `return` can use an expression body. Code fix rewrites it. Opt-in. +dotnet_diagnostic.SST2278.severity = none # A conversion operator whose block body is a single `return` can use an expression body. Code fix rewrites it. Opt-in. +dotnet_diagnostic.SST2279.severity = error # A get-only property whose getter is a single `return` can use a whole-member expression body. Code fix rewrites it. +dotnet_diagnostic.SST2280.severity = error # A get-only indexer whose getter is a single `return` can use a whole-member expression body. Code fix rewrites it. +dotnet_diagnostic.SST2281.severity = error # A local function whose block body is a single statement can use an expression body. Code fix rewrites it. +dotnet_diagnostic.SST2282.severity = error # A reference-type `ReferenceEquals` check against `null` reads as an `is null` / `is not null` pattern. Code fix rewrites it. +dotnet_diagnostic.SST2283.severity = error # A null guard that throws right before assigning the guarded value can fold into the assignment as `?? throw`. Code fix rewrites it. +# Design +dotnet_diagnostic.SST2300.severity = error # A class implements IDisposable but builds only half of the disposal pattern +dotnet_diagnostic.SST2301.severity = error # A class implementing IEquatable for itself can still be derived from +dotnet_diagnostic.SST2302.severity = error # A type overloads an operator without the rest of the set it belongs to +dotnet_diagnostic.SST2303.severity = error # A [Flags] enum's members are not distinct bit values +dotnet_diagnostic.SST2304.severity = error # An event's delegate does not have the standard (object sender, TEventArgs e) shape +dotnet_diagnostic.SST2305.severity = error # A mutable collection property declares a caller-visible setter +dotnet_diagnostic.SST2306.severity = error # A collection-returning member hands back null +dotnet_diagnostic.SST2307.severity = error # A generic method's type parameter appears in no parameter, so no caller can infer it +dotnet_diagnostic.SST2308.severity = error # An [Obsolete] attribute carries no message +dotnet_diagnostic.SST2309.severity = error # An externally visible member declares an optional parameter, so callers bake in the default +dotnet_diagnostic.SST2310.severity = error # Deprecated code is still here; remove it once its last caller is gone +dotnet_diagnostic.SST2311.severity = error # A visible const is copied into every assembly that reads it +dotnet_diagnostic.SST2312.severity = error # A type is declared outside any namespace +dotnet_diagnostic.SST2313.severity = error # An enum is stored as a type the project does not allow +dotnet_diagnostic.SST2314.severity = none # An [Obsolete] has a message but no DiagnosticId — unusable here: ObsoleteAttribute.DiagnosticId is .NET 5+, and this source is shared with net462 +dotnet_diagnostic.SST2315.severity = error # A type owns a disposable field but does not implement IDisposable +dotnet_diagnostic.SST2316.severity = error # A type declares Dispose or DisposeAsync without implementing IDisposable +dotnet_diagnostic.SST2317.severity = error # A disposable type owns a raw IntPtr without a SafeHandle or finalizer +dotnet_diagnostic.SST2318.severity = error # Two members have token-identical bodies +dotnet_diagnostic.SST2319.severity = error # An overload's optional default can never be used +dotnet_diagnostic.SST2320.severity = error # An interface inherits two interfaces that declare the same member +dotnet_diagnostic.SST2321.severity = error # Environment.Exit or Environment.FailFast is called from library code +dotnet_diagnostic.SST2322.severity = error # A non-private readonly field holds a mutable collection callers can still change +dotnet_diagnostic.SST2323.severity = error # A stateless abstract class declaring only public abstract members should be an interface +dotnet_diagnostic.SST2324.severity = error # A member is declared more accessible than its containing type +dotnet_diagnostic.SST2325.severity = error # An async method checks an argument after its first await +dotnet_diagnostic.SST2326.severity = none # An interface-typed value is narrowed to a concrete implementation — this is a high-performance core library, not strictly SOLID code, and narrowing to a known runtime type is a normal fast-path technique here: foreach over IEnumerable boxes List's struct enumerator (40 bytes per call, measured) where the indexed loop behind the type test allocates nothing +dotnet_diagnostic.SST2327.severity = error # A type tests its own runtime type against a class instead of dispatching through a virtual member +dotnet_diagnostic.SST2328.severity = error # A raw native pointer handle is exposed instead of a SafeHandle +dotnet_diagnostic.SST2329.severity = error # A `[Flags]` enum declares no zero-valued member. Code fix adds `None = 0`. +dotnet_diagnostic.SST2330.severity = error # A `[Flags]` member is a numeric literal equal to a combination of others (`All = 7`). Code fix writes `A +dotnet_diagnostic.SST2331.severity = none # An enum leaves member values implicit, so their numbers depend on declaration order. Opt-in. +dotnet_diagnostic.SST2332.severity = error # An auto-property's `private set` is only written during construction; make it get-only. +dotnet_diagnostic.SST2333.severity = none # A generic comparison/equality contract is implemented without its non-generic counterpart. Opt-in. +dotnet_diagnostic.SST2334.severity = none # A publicly visible type has no `[DebuggerDisplay]`. Opt-in. +dotnet_diagnostic.SST2335.severity = none # Parts of a partial type disagree on the `static` modifier. Opt-in. + +# Correctness +dotnet_diagnostic.SST2400.severity = error # Two arguments name each other's parameters and have been transposed +dotnet_diagnostic.SST2401.severity = error # A catch targets NullReferenceException +dotnet_diagnostic.SST2402.severity = error # An instance constructor assigns a static field of its own type +dotnet_diagnostic.SST2403.severity = error # 'this' escapes from a constructor before the object is fully built +dotnet_diagnostic.SST2404.severity = error # An iterator's argument guard does not run until the first MoveNext +dotnet_diagnostic.SST2405.severity = error # A [DebuggerDisplay] string names a member the type does not have +dotnet_diagnostic.SST2406.severity = error # A loop's stop condition reads only variables the loop never writes +dotnet_diagnostic.SST2407.severity = error # A declared event is never raised +dotnet_diagnostic.SST2408.severity = error # A StringBuilder is filled and never read +dotnet_diagnostic.SST2409.severity = error # A throw constructs a general exception type (Exception/SystemException/ApplicationException) +dotnet_diagnostic.SST2410.severity = error # A created disposable is never disposed and never leaves the method +dotnet_diagnostic.SST2411.severity = error # A for loop declares and tests a counter it never steps +dotnet_diagnostic.SST2412.severity = error # A for loop update moves the counter away from its stop condition +dotnet_diagnostic.SST2413.severity = error # A for loop condition can never be true on the first pass +dotnet_diagnostic.SST2414.severity = error # Two branches of a conditional share the same implementation +dotnet_diagnostic.SST2415.severity = error # A non-short-circuiting & or | evaluates a right operand that does work +dotnet_diagnostic.SST2416.severity = error # A modulus result on a signed type is compared directly to 1 +dotnet_diagnostic.SST2417.severity = error # A compound assignment operator is transposed (=+, =-, =!) +dotnet_diagnostic.SST2418.severity = error # The result of an immutable value's method is discarded +dotnet_diagnostic.SST2419.severity = error # A set or collection operation is performed against itself +dotnet_diagnostic.SST2420.severity = error # An IndexOf result is tested with > 0, skipping index 0 +dotnet_diagnostic.SST2421.severity = error # A write targets a readonly field of an unconstrained type parameter +dotnet_diagnostic.SST2422.severity = error # A property getter returns a different field than its setter writes +dotnet_diagnostic.SST2423.severity = error # A disposable created in a using statement is returned +dotnet_diagnostic.SST2424.severity = error # An override changes a parameter's default value +dotnet_diagnostic.SST2425.severity = error # An override drops an optional argument on its base call +dotnet_diagnostic.SST2426.severity = error # An override adds or removes params on a parameter +dotnet_diagnostic.SST2427.severity = error # A derived overload widens a parameter and hides the base overload +dotnet_diagnostic.SST2428.severity = error # A static initializer reads a static field declared later +dotnet_diagnostic.SST2429.severity = error # A setter, init, add, or remove accessor never reads value +dotnet_diagnostic.SST2430.severity = error # A serialization callback has the wrong signature +dotnet_diagnostic.SST2431.severity = error # A ToString override can return null +dotnet_diagnostic.SST2432.severity = error # GetType is called on a value that is already a System.Type +dotnet_diagnostic.SST2433.severity = error # A caller-info parameter is not last in the list +dotnet_diagnostic.SST2434.severity = error # An array is assigned through a covariant element type +dotnet_diagnostic.SST2435.severity = error # A non-object base's value-equality Equals is used as a reference-equality fast path +dotnet_diagnostic.SST2436.severity = error # An event is raised with a null sender or null args +dotnet_diagnostic.SST2437.severity = error # A generic type inherits from itself recursively +dotnet_diagnostic.SST2438.severity = error # A catch that discards its exception logs at Error or Critical without it +dotnet_diagnostic.SST2439.severity = error # An exception is passed as a log template argument instead of the exception parameter +dotnet_diagnostic.SST2440.severity = error # Log template arguments are transposed +dotnet_diagnostic.SST2441.severity = error # A log template has an empty or non-identifier placeholder +dotnet_diagnostic.SST2442.severity = error # A log template repeats a named placeholder +dotnet_diagnostic.SST2443.severity = error # An ILogger is injected or created with the wrong category type +dotnet_diagnostic.SST2444.severity = error # A regular expression pattern is invalid +dotnet_diagnostic.SST2445.severity = error # A culture-sensitive custom date or time format is used without an invariant culture +dotnet_diagnostic.SST2446.severity = error # A Stream.ReadAsync result is discarded through ConfigureAwait or a local +dotnet_diagnostic.SST2448.severity = error # A combined or opaque delegate is removed with - or -=, which strips only a contiguous run +dotnet_diagnostic.SST2449.severity = error # A lambda or anonymous-method handler is removed with -=, which never matches it +dotnet_diagnostic.SST2450.severity = error # A Debug.Assert condition performs a side effect that a release build compiles out +dotnet_diagnostic.SST2451.severity = error # Every constructor is private and no member ever creates an instance +dotnet_diagnostic.SST2452.severity = error # A [Pure] method returns void, Task, or ValueTask, so it has no observable result +dotnet_diagnostic.SST2456.severity = error # An override or new field-like event gets its own backing delegate field +dotnet_diagnostic.SST2457.severity = error # An integer Sum wrapped in unchecked still throws on overflow +dotnet_diagnostic.SST2458.severity = error # A bitwise operator is applied to an enum not declared [Flags] +dotnet_diagnostic.SST2459.severity = error # [Optional] on a ref or out parameter advertises an optionality no caller can use +dotnet_diagnostic.SST2460.severity = error # [DefaultValue] on a method or record parameter is inert +dotnet_diagnostic.SST2462.severity = error # A new member is less accessible than the inherited member it hides +dotnet_diagnostic.SST2463.severity = error # A field differs from an inherited accessible field only by case +dotnet_diagnostic.SST2464.severity = error # A mutable class declares a value-equality operator ==, so it is lost as a hash key +dotnet_diagnostic.SST2465.severity = error # A for loop body reassigns the counter or the local its condition tests +dotnet_diagnostic.SST2467.severity = error # A params overload is shadowed by a same-arity overload with a more specific last parameter +dotnet_diagnostic.SST2468.severity = error # A classic partial method is declared but never implemented, so its calls are removed +dotnet_diagnostic.SST2470.severity = error # Two string literals concatenate with no space, fusing a SQL keyword into the next token +dotnet_diagnostic.SST2472.severity = error # A type is exported for a contract it neither implements nor inherits +dotnet_diagnostic.SST2473.severity = error # A shared export part is constructed with new, bypassing the container +dotnet_diagnostic.SST2474.severity = error # A part-creation-policy attribute is applied to a type with no [Export] +dotnet_diagnostic.SST2475.severity = error # An entity's primary key is typed DateTime or DateTimeOffset +dotnet_diagnostic.SST2479.severity = error # A loop variable captured by a callback stored beyond the iteration reads its final value +dotnet_diagnostic.SST2481.severity = error # A GetHashCode override folds the base identity hash into a value hash +dotnet_diagnostic.SST2484.severity = error # A handle read through DangerousGetHandle is not reference-counted +dotnet_diagnostic.SST2485.severity = error # A NotImplementedException is left in shipped code +dotnet_diagnostic.SST2486.severity = error # An assembly is loaded by path or partial name instead of Assembly.Load +dotnet_diagnostic.SST2487.severity = error # A [ConstructorArgument] does not name a constructor parameter +dotnet_diagnostic.SST2488.severity = error # An exception is logged and rethrown, duplicating the record +dotnet_diagnostic.SST2489.severity = error # A relational comparison is decided by the operand's type rather than its value +dotnet_diagnostic.SST2490.severity = error # Adjacent try statements with identical handling should be merged +dotnet_diagnostic.SST2491.severity = error # A non-`async` method returns an awaitable from inside `using`/`try-finally`/`lock`, so the resource is torn down before the task completes. Code fix makes it `async`. +dotnet_diagnostic.SST2492.severity = error # A null-guard throws on a parameter the signature declares may be null. +dotnet_diagnostic.SST2493.severity = error # `== null`/`!= null` on an unconstrained generic `T`. Code fix uses `is null`/`is not null`. +dotnet_diagnostic.SST2494.severity = error # A `??` whose left operand is a constant null, so the right is always taken. Code fix folds it. +dotnet_diagnostic.SST2495.severity = error # A `[Flags]` combination includes an operand whose bits another already covers. Code fix removes it. +dotnet_diagnostic.SST2496.severity = error # An explicit `Dispose`/`Close` on a resource an enclosing `using` already disposes. Code fix removes it. Info. + +# Testing +dotnet_diagnostic.SST2500.severity = error # A test method contains no assertion and no expected-exception check +dotnet_diagnostic.SST2501.severity = error # An equality or identity assertion compares an expression with itself +dotnet_diagnostic.SST2502.severity = error # An equality assertion passes the constant as actual and the computed value as expected +dotnet_diagnostic.SST2503.severity = error # An equality assertion compares a value against a boolean literal +dotnet_diagnostic.SST2504.severity = error # A test fixture declares and inherits no test method +dotnet_diagnostic.SST2505.severity = error # A test method declares parameters but no data source +dotnet_diagnostic.SST2506.severity = error # A test method calls Thread.Sleep +dotnet_diagnostic.SST2507.severity = error # A test method declares its expected failure with an expected-exception attribute +dotnet_diagnostic.SST2508.severity = error # A fluent assertion is started but never completed +dotnet_diagnostic.SST2509.severity = error # A test method has a shape the runner cannot execute + +# Logging +dotnet_diagnostic.SST2600.severity = error # Application output is written through legacy Trace instead of a structured logger +dotnet_diagnostic.SST2601.severity = error # A logger field or property does not follow the logger naming convention + +# Frameworks +dotnet_diagnostic.SST2700.severity = error # An MVC route template contains a backslash; route segments are separated by `/`, so the route is unreachable. Code fix replaces `\` with `/`. +dotnet_diagnostic.SST2701.severity = error # A `[JSInvokable]` method is not public, so JavaScript interop cannot call it. Code fix makes it public. +dotnet_diagnostic.SST2702.severity = error # A `[SupplyParameterFromQuery]` property has a type the framework cannot bind from the query string, which throws at runtime. +dotnet_diagnostic.SST2703.severity = error # A routable component's route constraint (`{id:int}`) disagrees with the matching `[Parameter]` CLR type, so the route silently fails to match. +dotnet_diagnostic.SST2704.severity = error # A public action on an `[ApiController]` declares no HTTP-verb attribute, so it answers every verb and can make routing ambiguous. +dotnet_diagnostic.SST2705.severity = none # A bound model member is a non-nullable value type with no required marker, so a request that omits it binds the default with no error. Opt-in. +dotnet_diagnostic.SST2706.severity = error # A Windows Forms entry point carries neither `[STAThread]` nor `[MTAThread]`; without STA, clipboard, drag-and-drop, and common dialogs misbehave. Code fix adds `[STAThread]`. +dotnet_diagnostic.SST2707.severity = none # A fire-and-forget `Task.Run` in a controller captures the request's `HttpContext`, which is disposed when the request ends, so the background work throws `ObjectDisposedException`. Opt-in. +dotnet_diagnostic.SST2708.severity = error # A component subscribes to an event in a lifecycle method but never unsubscribes, so the event source keeps the component alive — a per-session leak on a Server circuit. +dotnet_diagnostic.SST2709.severity = error # `StateHasChanged` is called while the component is being disposed, which the renderer no longer supports and throws. +dotnet_diagnostic.SST2710.severity = error # `StateHasChanged` is called directly from a timer callback, off the renderer's dispatcher; marshal it with `InvokeAsync(StateHasChanged)`. +dotnet_diagnostic.SST2711.severity = error # A synchronous component lifecycle method is overridden as `async void`, which the framework never awaits; override the `…Async` twin returning `Task`. Code fix rewrites the signature. +dotnet_diagnostic.SST2712.severity = error # An `[Inject]`/`[CascadingParameter]` property has no setter, so the framework's reflection-based binding leaves it null. Code fix adds a setter. +dotnet_diagnostic.SST2713.severity = error # A `DotNetObjectReference.Create(this)` is passed inline and never stored, so nothing can dispose it and it leaks on the JavaScript side. + +################### +# PerformanceSharp Analyzers (PSH) +################### +performancesharp.avoid_linq_on_hot_path = true +# performancesharp.empty_string_style = pattern # PSH1204 (pattern | length | is_null_or_empty; the last two are only offered where the string is provably not null) +# performancesharp.excluded_properties = Items, Keys # PSH1017 (comma-separated; properties allowed to copy on read) +# performancesharp.include_public = false # PSH1411 (set true in an app to seal public types too; a break in a library) +dotnet_diagnostic.PSH1000.severity = error # Anonymous functions without captures should be static +dotnet_diagnostic.PSH1001.severity = error # Avoid allocating zero-length arrays (fix prefers [] on C# 12+, else Array.Empty()) +dotnet_diagnostic.PSH1002.severity = error # Empty finalizers should be removed +dotnet_diagnostic.PSH1003.severity = error # 'in' parameters should use readonly structs +dotnet_diagnostic.PSH1004.severity = error # Constant arrays passed as arguments should be hoisted +dotnet_diagnostic.PSH1005.severity = error # Structs should define equality members to avoid boxing comparisons +dotnet_diagnostic.PSH1006.severity = error # ConcurrentDictionary factories should use the lambda argument +dotnet_diagnostic.PSH1007.severity = error # Pass large readonly structs by 'in' reference (size threshold + exclusions configurable) +dotnet_diagnostic.PSH1008.severity = error # GC.SuppressFinalize does nothing for sealed finalizer-free types +dotnet_diagnostic.PSH1009.severity = error # Bound variable-length stackalloc with a constant guard +dotnet_diagnostic.PSH1010.severity = error # Clear reference-typed arrays when returning them to the pool +dotnet_diagnostic.PSH1011.severity = error # Pass state to callbacks through the state-taking overload +dotnet_diagnostic.PSH1012.severity = error # Compare type parameter values with EqualityComparer.Default +dotnet_diagnostic.PSH1013.severity = error # Expose constant UTF-8 data as a ReadOnlySpan property +dotnet_diagnostic.PSH1014.severity = error # Declare immutable structs as readonly +dotnet_diagnostic.PSH1015.severity = error # Avoid casting value types through object +dotnet_diagnostic.PSH1016.severity = error # Test enum flags with bitwise operators instead of Enum.HasFlag +dotnet_diagnostic.PSH1017.severity = error # A property allocates a copy of a collection on every read (excludable via performancesharp.PSH1017.excluded_properties) +dotnet_diagnostic.PSH1018.severity = error # A hand-written array is passed to a params parameter +dotnet_diagnostic.PSH1019.severity = error # The range indexer on an array allocates a copy; slice with AsSpan/AsMemory +dotnet_diagnostic.PSH1020.severity = error # Prefer a jagged array over a multidimensional one +dotnet_diagnostic.PSH1021.severity = error # An explicit GC.Collect or GC.WaitForPendingFinalizers forces collection the runtime tunes itself +dotnet_diagnostic.PSH1022.severity = error # A parameterless `new EventArgs()` allocates where the shared `EventArgs.Empty` singleton would serve. Code fix uses the singleton. +dotnet_diagnostic.PSH1100.severity = error # Hot-path code should avoid System.Linq.Enumerable calls +dotnet_diagnostic.PSH1101.severity = none # LINQ terminal predicate simplification is reserved for test code; production code should avoid LINQ on hot paths +dotnet_diagnostic.PSH1102.severity = none # LINQ type-filter simplification is reserved for test code; production code should avoid LINQ on hot paths +dotnet_diagnostic.PSH1103.severity = error # Prefer the collection's own count over enumerating +dotnet_diagnostic.PSH1104.severity = error # Use TryGetValue instead of ContainsKey followed by an indexer read +dotnet_diagnostic.PSH1105.severity = error # Avoid double lookups on dictionaries and sets +dotnet_diagnostic.PSH1106.severity = error # Index collections directly instead of using LINQ element access +dotnet_diagnostic.PSH1107.severity = error # Filter sequences before sorting them +dotnet_diagnostic.PSH1108.severity = error # Chain secondary sorts with ThenBy +dotnet_diagnostic.PSH1109.severity = error # Merge consecutive Where calls +dotnet_diagnostic.PSH1110.severity = error # Use the collection's own predicate methods over LINQ +dotnet_diagnostic.PSH1111.severity = error # Use Contains for membership tests +dotnet_diagnostic.PSH1112.severity = error # Seed the collection through its constructor (fix honors performancesharp.prefer_collection_expressions) +dotnet_diagnostic.PSH1113.severity = error # Sort naturally instead of ordering by the element itself +dotnet_diagnostic.PSH1114.severity = none # Freeze static lookup collections that are never mutated. Opt-in. +dotnet_diagnostic.PSH1115.severity = error # Insert-if-absent should probe the dictionary once +dotnet_diagnostic.PSH1116.severity = error # Probe string-keyed collections with a span through GetAlternateLookup +dotnet_diagnostic.PSH1117.severity = error # Ask the collection whether it is empty +dotnet_diagnostic.PSH1118.severity = error # Take the extreme element with Min/Max/MinBy/MaxBy instead of sorting +dotnet_diagnostic.PSH1119.severity = error # Check for elements with Any instead of counting them all +dotnet_diagnostic.PSH1120.severity = error # Do not materialize a sequence with ToList/ToArray just to enumerate it +dotnet_diagnostic.PSH1122.severity = error # Read a sorted set's extreme through its Min/Max property, not the LINQ extension +dotnet_diagnostic.PSH1124.severity = error # Read a linked list's end through its First/Last property, not the LINQ extension +dotnet_diagnostic.PSH1125.severity = error # Do not enumerate the same lazy sequence twice +dotnet_diagnostic.PSH1126.severity = error # Ask whether an async sequence has elements instead of counting them +dotnet_diagnostic.PSH1127.severity = error # Clear an array instead of filling it with its default +dotnet_diagnostic.PSH1200.severity = error # Compare strings without allocating case-converted copies +dotnet_diagnostic.PSH1201.severity = error # Use the char overload for single-character strings +dotnet_diagnostic.PSH1202.severity = error # Append characters as char, not single-character strings +dotnet_diagnostic.PSH1203.severity = error # Let StringBuilder do the formatting work +dotnet_diagnostic.PSH1204.severity = error # Test for empty strings by length +dotnet_diagnostic.PSH1205.severity = error # Remove interpolation that does no work +dotnet_diagnostic.PSH1206.severity = error # Do not build strings by concatenation in loops +dotnet_diagnostic.PSH1207.severity = error # Specify StringComparison for culture-sensitive string operations +dotnet_diagnostic.PSH1208.severity = error # Encode constant strings with u8 literals +dotnet_diagnostic.PSH1209.severity = error # Build transformed strings with string.Create +dotnet_diagnostic.PSH1210.severity = error # Compare UTF-8 bytes without decoding them +dotnet_diagnostic.PSH1211.severity = error # Pass values directly instead of ToString results +dotnet_diagnostic.PSH1212.severity = error # Slice with AsSpan when the call accepts a span +dotnet_diagnostic.PSH1213.severity = error # Probe repeated character sets through SearchValues +dotnet_diagnostic.PSH1214.severity = error # Append the parts of a concatenation separately, not the concatenated whole +dotnet_diagnostic.PSH1215.severity = error # Use string.Concat instead of string.Join with an empty separator +dotnet_diagnostic.PSH1216.severity = error # Use string.Equals instead of comparing string.Compare to zero +dotnet_diagnostic.PSH1217.severity = error # A sequence is copied to an array just to be read straight back +dotnet_diagnostic.PSH1218.severity = error # A substring is allocated only to search it; slice with AsSpan instead +dotnet_diagnostic.PSH1219.severity = error # Ask whether a string is blank without trimming it +dotnet_diagnostic.PSH1220.severity = error # A length argument spells out the run that reaches the end anyway +dotnet_diagnostic.PSH1221.severity = error # An IndexOf compared to 0 scans the whole string to answer a prefix test +dotnet_diagnostic.PSH1222.severity = error # Concatenate slices without materializing them +dotnet_diagnostic.PSH1223.severity = error # A reused composite format string is re-parsed on every call +dotnet_diagnostic.PSH1224.severity = error # Convert bytes to hex in one call, not by building the string twice +dotnet_diagnostic.PSH1225.severity = error # Decode bytes to a string in one call, without a throwaway char[] +dotnet_diagnostic.PSH1226.severity = error # A string's `ToCharArray()` result is only iterated, allocating a throwaway `char[]`; iterate the string directly. Code fix drops the copy. +dotnet_diagnostic.PSH1227.severity = error # A cheaper equivalent exists — `string.CompareOrdinal` over `Compare(…, Ordinal)`, `Debug.Fail` over `Debug.Assert(false, …)`. Info. Code fix rewrites the call. +dotnet_diagnostic.PSH1300.severity = error # Use System.Threading.Lock for a dedicated lock object +dotnet_diagnostic.PSH1301.severity = error # Do not wrap a single task in WhenAll or WaitAll +dotnet_diagnostic.PSH1302.severity = error # TaskCompletionSource should run continuations asynchronously +dotnet_diagnostic.PSH1303.severity = error # Do not block an async method with Thread.Sleep +dotnet_diagnostic.PSH1304.severity = error # Use PeriodicTimer instead of pacing a loop with Task.Delay +dotnet_diagnostic.PSH1305.severity = error # Enumerate a ConcurrentDictionary directly, not its Keys/Values snapshots +dotnet_diagnostic.PSH1306.severity = none # Guard one-time execution with an interlocked latch. Opt-in. +dotnet_diagnostic.PSH1307.severity = error # Access interlocked fields with Volatile +dotnet_diagnostic.PSH1308.severity = error # Return the completed task instead of Task.FromResult +dotnet_diagnostic.PSH1309.severity = none # Register cancellation callbacks without flowing the execution context. Opt-in. +dotnet_diagnostic.PSH1310.severity = error # Dispose IAsyncDisposable resources with await using in async code +dotnet_diagnostic.PSH1311.severity = error # Remove a pass-through async state machine and return the task directly +dotnet_diagnostic.PSH1312.severity = error # Return a completed task instead of null +dotnet_diagnostic.PSH1313.severity = error # A synchronous call where an async overload fits +dotnet_diagnostic.PSH1314.severity = error # Read and write streams through the memory-based overloads +dotnet_diagnostic.PSH1315.severity = error # A blocking wait on an awaitable that may not be done +dotnet_diagnostic.PSH1316.severity = error # A ValueTask is awaited in a loop or awaited after being copied +dotnet_diagnostic.PSH1400.severity = error # Use the static HashData method for one-shot hashing +dotnet_diagnostic.PSH1401.severity = error # Attribute types should be sealed +dotnet_diagnostic.PSH1402.severity = error # Use const for compile-time constants +dotnet_diagnostic.PSH1403.severity = error # Do not initialize fields to their default value +dotnet_diagnostic.PSH1404.severity = error # Get the assembly from typeof instead of a stack walk +dotnet_diagnostic.PSH1405.severity = error # Use the direct Environment APIs +dotnet_diagnostic.PSH1406.severity = error # Ask Regex for the answer directly +dotnet_diagnostic.PSH1407.severity = error # Query the dictionary, not its Keys view +dotnet_diagnostic.PSH1408.severity = error # Measure elapsed time with Stopwatch timestamps +dotnet_diagnostic.PSH1409.severity = error # Use the built-in throw helpers for argument guards +dotnet_diagnostic.PSH1410.severity = none # Mark trivial forwarders for aggressive inlining. Opt-in. +dotnet_diagnostic.PSH1411.severity = error # Seal non-public types nothing derives from so the JIT can devirtualize +dotnet_diagnostic.PSH1412.severity = error # Use Random.Shared instead of allocating a Random +dotnet_diagnostic.PSH1413.severity = error # Read the Unix epoch from the framework, not a hand-built DateTime +dotnet_diagnostic.PSH1414.severity = error # Mark members that do not touch instance state as static +dotnet_diagnostic.PSH1415.severity = error # Hold the concrete type when the concrete type is what you have +dotnet_diagnostic.PSH1416.severity = error # Cache the serializer options instead of building them per call +dotnet_diagnostic.PSH1417.severity = error # Do not compute an expensive argument for an assertion +dotnet_diagnostic.PSH1418.severity = error # An HttpClient is constructed on every call +dotnet_diagnostic.PSH1419.severity = error # A time-zone is resolved with a platform-specific id instead of the cross-platform API +dotnet_diagnostic.PSH1420.severity = error # A shareable client held in an instance field of an Azure Functions worker class is rebuilt on every invocation, leaking sockets and connections; share a static/singleton client or inject `IHttpClientFactory`. + +# ASP.NET Core - inert in this repo (no route handlers or middleware), enabled so the set stays complete +dotnet_diagnostic.PSH1500.severity = error # A minimal API handler returns Results instead of TypedResults, boxing the result +dotnet_diagnostic.PSH1501.severity = error # Middleware uses the legacy nested-delegate Use overload, allocating a per-request closure +dotnet_diagnostic.PSH1502.severity = error # A route handler returns a deferred sequence the serializer enumerates on the request thread +dotnet_diagnostic.PSH1503.severity = error # Response caching is used where server-side output caching applies +dotnet_diagnostic.PSH1505.severity = error # Exceptions are handled in an MVC exception filter instead of an IExceptionHandler +dotnet_diagnostic.PSH1506.severity = error # The HTTP request or response body is read or written synchronously (`ReadToEnd`, `Body.Read`, `Body.Write`), which blocks a thread on Kestrel and buffers the whole payload; use the async overload. Code fix awaits it when the method is already async. + +# Blazor +dotnet_diagnostic.PSH1600.severity = error # A delegate captured per iteration inside a component render loop reallocates on every render (measured ~128 B per row per render) and churns the diff; hoist it to a cached delegate or a precomputed per-item model. +dotnet_diagnostic.PSH1601.severity = error # A JavaScript-interop call is issued once per loop iteration; on Interactive Server each is a separate SignalR round-trip. Batch into a single call over the collection. +dotnet_diagnostic.PSH1602.severity = error # `StateHasChanged` is called unconditionally in `OnAfterRender`/`OnAfterRenderAsync`, scheduling another render every time — a runaway loop. Guard it with `firstRender` or a state flag. +dotnet_diagnostic.PSH1603.severity = error # A non-delegate allocation is used as a component-parameter value inside a render loop, allocating per item and forcing the child to re-render each pass. Sibling of PSH1600. + +################### +# SecuritySharp Analyzers (SES) +################### +# Every rule is raised to error, including the three that ship at suggestion (SES1403, SES1506, +# SES1605). Security rules only ever suggest an API they can resolve in the compilation and report +# local shapes only - there is no interprocedural taint tracking, so the taint-flow CA rules stay on. +# securitysharp.SES1003.iterations = 100000 # minimum accepted PBKDF2 iteration count +# securitysharp.SES1403.maxdepth = 64 # highest accepted System.Text.Json MaxDepth + +# Cryptography +dotnet_diagnostic.SES1001.severity = error # AEAD encryption must not use a constant or reused nonce +dotnet_diagnostic.SES1002.severity = error # Password-based key derivation must not use a constant or predictable salt +dotnet_diagnostic.SES1003.severity = error # Password-based key derivation must use a sufficient iteration count +dotnet_diagnostic.SES1004.severity = error # A secret must not be produced from Guid.NewGuid() +dotnet_diagnostic.SES1005.severity = error # Compare secret values in constant time +dotnet_diagnostic.SES1006.severity = error # A Data Protection key ring is persisted without a ProtectKeysWith call, so keys sit unencrypted at rest +dotnet_diagnostic.SES1007.severity = error # A cryptographic primitive is implemented by hand instead of using a vetted platform algorithm +dotnet_diagnostic.SES1008.severity = error # An XML signature is verified with the no-key CheckSignature overload, trusting the document's own KeyInfo +dotnet_diagnostic.SES1009.severity = error # A password is stored without a slow, salted key-derivation function + +# Transport +dotnet_diagnostic.SES1102.severity = error # Do not accept any server certificate +dotnet_diagnostic.SES1104.severity = error # Certificate-chain validation must not be deliberately weakened +dotnet_diagnostic.SES1105.severity = error # Bearer and OpenID Connect metadata must not be retrieved over plain HTTP outside development +dotnet_diagnostic.SES1106.severity = error # Do not send HttpClient requests to a cleartext http URL +dotnet_diagnostic.SES1107.severity = error # A SQL connection string weakens transport security via TrustServerCertificate or a disabled Encrypt +dotnet_diagnostic.SES1108.severity = error # A custom server-certificate validation callback unconditionally returns true, so any certificate is trusted + +# Secrets +dotnet_diagnostic.SES1201.severity = error # Do not hard-code secrets in source +dotnet_diagnostic.SES1202.severity = error # Do not hard-code a credential value +dotnet_diagnostic.SES1203.severity = error # A connection string names a user but supplies an empty or missing password + +# Injection +dotnet_diagnostic.SES1301.severity = error # Do not build a process command line from non-constant string parts +dotnet_diagnostic.SES1302.severity = error # A shell-executed process must not use a non-constant FileName +dotnet_diagnostic.SES1303.severity = error # Regular-expression pattern must not be built from non-constant data +dotnet_diagnostic.SES1304.severity = error # An archive entry name must not build a write path without a containment check +dotnet_diagnostic.SES1305.severity = error # Do not build a storage path from an uploaded file name +dotnet_diagnostic.SES1306.severity = error # Do not compile or execute non-constant C# via the scripting API +dotnet_diagnostic.SES1307.severity = error # Path.GetTempFileName creates a predictable, world-readable temporary file +dotnet_diagnostic.SES1308.severity = error # A file or directory is created group- or world-writable +dotnet_diagnostic.SES1309.severity = error # An XSLT stylesheet is loaded with embedded script enabled +dotnet_diagnostic.SES1310.severity = error # A directory bind is performed without authenticating + +# Serialization +dotnet_diagnostic.SES1401.severity = error # A type resolved from non-constant data must not be instantiated or deserialized +dotnet_diagnostic.SES1402.severity = error # Do not load an assembly from raw bytes or a non-constant location +dotnet_diagnostic.SES1403.severity = error # JSON deserialization depth limit must stay within a safe ceiling +dotnet_diagnostic.SES1404.severity = error # A type is instantiated by name from a non-constant Activator typeName +dotnet_diagnostic.SES1405.severity = error # MessagePack typeless deserialization reconstructs whatever type the payload names +dotnet_diagnostic.SES1406.severity = warning # Reflection must not reach non-public members via BindingFlags.NonPublic (opt-in; replaces S3011) + +# Web hardening +dotnet_diagnostic.SES1501.severity = error # A CORS policy must not allow credentials together with any origin +dotnet_diagnostic.SES1502.severity = error # A CORS origin predicate must not unconditionally allow every origin +dotnet_diagnostic.SES1503.severity = error # JWT signature verification must not be disabled on TokenValidationParameters +dotnet_diagnostic.SES1504.severity = error # A cookie with SameSite=None must be marked Secure +dotnet_diagnostic.SES1505.severity = error # The request body size limit must not be removed +dotnet_diagnostic.SES1506.severity = error # The developer exception page must be guarded by a development-environment check +dotnet_diagnostic.SES1507.severity = error # AllowAnonymous and Authorize on the same declaration conflict +dotnet_diagnostic.SES1508.severity = error # A validation method must not fail open by returning success from a catch +dotnet_diagnostic.SES1509.severity = error # A backtracking-prone constant regex runs without a match timeout or NonBacktracking +dotnet_diagnostic.SES1510.severity = error # A controller redirects to a non-constant URL, allowing an open redirect +dotnet_diagnostic.SES1511.severity = error # The forwarded-headers trust boundary is cleared, letting proxies spoof the client IP +dotnet_diagnostic.SES1512.severity = error # Sensitive framework diagnostics are enabled without a development-environment guard +dotnet_diagnostic.SES1513.severity = error # An AuthorizeAsync result is discarded, so the guarded operation runs regardless +dotnet_diagnostic.SES1514.severity = error # OpenID Connect protections (PKCE, state, nonce) are disabled +dotnet_diagnostic.SES1515.severity = error # A Content-Security-Policy value disables its own protection + +# AI trust boundaries +dotnet_diagnostic.SES1601.severity = error # An LLM system prompt must be a constant, trusted template +dotnet_diagnostic.SES1602.severity = error # Do not route AI model output into a process, file, or raw SQL sink +dotnet_diagnostic.SES1603.severity = error # An AI tool declared read-only or non-destructive must not call a state-changing API +dotnet_diagnostic.SES1604.severity = error # Prompt-template input encoding must not be disabled +dotnet_diagnostic.SES1605.severity = error # AI instrumentation must not enable sensitive-data capture +dotnet_diagnostic.SES1606.severity = error # Do not fetch model weights over cleartext HTTP + +# Web UI trust boundaries +dotnet_diagnostic.SES1701.severity = error # Raw HTML is rendered from a non-constant value (`MarkupString`/`AddMarkupContent`), bypassing automatic encoding — an XSS risk. Sanitizer allow-list via `securitysharp.SES1701.sanitizers`. +dotnet_diagnostic.SES1702.severity = error # A JavaScript-interop call targets a script-evaluation primitive (`eval`, `Function`, `document.write`), turning interop into a script-injection channel. +dotnet_diagnostic.SES1703.severity = error # `[Authorize]` on a non-routable component enforces nothing — authorization runs as a routing concern. Exempt types via `securitysharp.SES1703.exempt_types`. +dotnet_diagnostic.SES1704.severity = error # `IHttpContextAccessor` or a cascading `HttpContext` is used in an interactively-rendered component, where it is null or frozen at circuit start. +dotnet_diagnostic.SES1705.severity = error # `NavigationManager.NavigateTo` is called with a target that is not a verified relative URL — an open-redirect risk. Validator allow-list via `securitysharp.SES1705.validators`. +dotnet_diagnostic.SES1706.severity = error # An uploaded file is read with an unbounded or client-chosen size limit, letting an attacker fill server memory. Threshold via `securitysharp.SES1706.max_bytes`. +dotnet_diagnostic.SES1707.severity = error # A secret-shaped literal appears in code reachable as WebAssembly, which downloads to the browser in full — guaranteed disclosure. +dotnet_diagnostic.SES1708.severity = error # `CircuitOptions.DetailedErrors` is enabled, shipping server exception detail to every connected client. +dotnet_diagnostic.SES1709.severity = error # `SerializeAllClaims` serializes every claim into client-readable WebAssembly authentication state, exposing internal ids, tokens, and PII. +dotnet_diagnostic.SES1710.severity = error # Antiforgery validation is disabled on a form (`[RequireAntiforgeryToken(required: false)]`), removing CSRF protection. + + +################### +# Microsoft.CodeAnalysis.PublicApiAnalyzers (RS) +################### +# Public API surface tracking dotnet_diagnostic.RS0016.severity = error # public symbol missing from the PublicAPI baseline dotnet_diagnostic.RS0017.severity = error # PublicAPI baseline entry no longer in source ################### -# Trimming Analyzer Warnings (IL2001 - IL2123) -# See: https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-warnings/ +# Microsoft.NET.ILLink.Analyzers (IL) ################### +# Trimming +# See: https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-warnings/ dotnet_diagnostic.IL2001.severity = error # Type in UnreferencedCode attribute doesn't have matching RequiresUnreferencedCode dotnet_diagnostic.IL2002.severity = error # Method with RequiresUnreferencedCode called from code without that attribute dotnet_diagnostic.IL2003.severity = error # RequiresUnreferencedCode attribute is only supported on methods @@ -1555,10 +2134,8 @@ dotnet_diagnostic.IL2117.severity = error # Methods with DynamicallyAccessedMemb dotnet_diagnostic.IL2122.severity = error # Reflection call to method with UnreferencedCode attribute cannot be statically analyzed dotnet_diagnostic.IL2123.severity = error # DynamicallyAccessedMembers on method or parameter doesn't match overridden member -################### -# AOT Analyzer Warnings (IL3xxx) +# Native AOT # See: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/warnings/ -################### dotnet_diagnostic.IL3050.severity = error # Using member annotated with RequiresDynamicCode dotnet_diagnostic.IL3051.severity = error # RequiresDynamicCode attribute is only supported on methods and constructors dotnet_diagnostic.IL3052.severity = error # RequiresDynamicCode attribute on type is not supported @@ -1568,490 +2145,473 @@ dotnet_diagnostic.IL3055.severity = error # MakeGenericType on non-supported typ dotnet_diagnostic.IL3056.severity = error # MakeGenericMethod on non-supported method requires dynamic code dotnet_diagnostic.IL3057.severity = error # Reflection access to generic parameter requires dynamic code - ################### -# SonarAnalyzer (Sxxxx) - Blocker Bug +# SonarAnalyzer.CSharp (S) ################### -dotnet_diagnostic.S1048.severity = error # Finalizers should not throw exceptions +# Repository suppressions +dotnet_diagnostic.S1075.severity = none # Hardcoded URI — canonical SourceLink hosts are the point +dotnet_diagnostic.S2436.severity = none # Too many generic parameters — needed for the projector overload +dotnet_diagnostic.S4036.severity = none # PATH-relative process spawn — benchmark only, trusted env +dotnet_diagnostic.S8969.severity = none # Nullability inference is inconsistent across the repository's target frameworks + +# Blocker bugs +dotnet_diagnostic.S1048.severity = none # Finalizers should not throw exceptions — covered by SST1485 dotnet_diagnostic.S2190.severity = none # Loops and recursions should not be infinite dotnet_diagnostic.S2275.severity = none # Composite format strings should not lead to unexpected behavior at runtime - DUPLICATE CA2241 -dotnet_diagnostic.S2857.severity = error # SQL keywords should be delimited by whitespace -dotnet_diagnostic.S2930.severity = error # "IDisposables" should be disposed -dotnet_diagnostic.S2931.severity = error # Classes with "IDisposable" members should implement "IDisposable" -dotnet_diagnostic.S3464.severity = error # Type inheritance should not be recursive -dotnet_diagnostic.S3869.severity = error # "SafeHandle.DangerousGetHandle" should not be called -dotnet_diagnostic.S3889.severity = error # "Thread.Resume" and "Thread.Suspend" should not be used -dotnet_diagnostic.S4159.severity = error # Classes should implement their "ExportAttribute" interfaces - -################### -# SonarAnalyzer (Sxxxx) - Critical Bug -################### -dotnet_diagnostic.S2551.severity = error # Shared resources should not be used for locking -dotnet_diagnostic.S2952.severity = error # Classes should "Dispose" of members from the classes' own "Dispose" methods -dotnet_diagnostic.S3449.severity = error # Right operands of shift operators should be integers -dotnet_diagnostic.S4275.severity = error # Getters and setters should access the expected fields -dotnet_diagnostic.S4277.severity = error # "Shared" parts should not be created with "new" -dotnet_diagnostic.S4583.severity = error # Calls to delegate's method "BeginInvoke" should be paired with calls to "EndInvoke" -dotnet_diagnostic.S4586.severity = error # Non-async "Task/Task" methods should not return null -dotnet_diagnostic.S5856.severity = error # Regular expressions should be syntactically valid -dotnet_diagnostic.S6674.severity = error # Log message template should be syntactically correct - -################### -# SonarAnalyzer (Sxxxx) - Major Bug -################### -dotnet_diagnostic.S1244.severity = error # Floating point numbers should not be tested for equality +dotnet_diagnostic.S2857.severity = none # SQL keywords should be delimited by whitespace — covered by SST2470 +dotnet_diagnostic.S2930.severity = none # "IDisposables" should be disposed — covered by SST2410 +dotnet_diagnostic.S2931.severity = none # Classes with "IDisposable" members should implement "IDisposable" — covered by SST2315 +dotnet_diagnostic.S3464.severity = none # Type inheritance should not be recursive — covered by SST2437 +dotnet_diagnostic.S3869.severity = none # "SafeHandle.DangerousGetHandle" should not be called -> replaced by SST2484 +dotnet_diagnostic.S3889.severity = none # "Thread.Resume" and "Thread.Suspend" should not be used -> replaced by obsolete or compiler +dotnet_diagnostic.S4159.severity = none # Classes should implement their "ExportAttribute" interfaces — covered by SST2472 + +# Critical bugs +dotnet_diagnostic.S2551.severity = none # Shared resources should not be used for locking — covered by SST1902 +dotnet_diagnostic.S2952.severity = none # Classes should "Dispose" of members from the classes' own "Dispose" methods -> replaced by SST2315 +dotnet_diagnostic.S3449.severity = none # Right operands of shift operators should be integers -> replaced by SST1478 +dotnet_diagnostic.S4275.severity = none # Getters and setters should access the expected fields — covered by SST2422 +dotnet_diagnostic.S4277.severity = none # "Shared" parts should not be created with "new" — covered by SST2473 +dotnet_diagnostic.S4583.severity = none # Calls to delegate's method "BeginInvoke" should be paired with calls to "EndInvoke" -> replaced by obsolete (APM BeginInvoke/EndInvoke) +dotnet_diagnostic.S4586.severity = none # Non-async "Task/Task" methods should not return null — covered by PSH1312 +dotnet_diagnostic.S5856.severity = none # Regular expressions should be syntactically valid — covered by SST2444 +dotnet_diagnostic.S6674.severity = none # Log message template should be syntactically correct — covered by SST2441 + +# Major bugs +dotnet_diagnostic.S1244.severity = none # Floating point numbers should not be tested for equality — covered by SST1473 dotnet_diagnostic.S1656.severity = none # Variables should not be self-assigned — covered by SST1189 -dotnet_diagnostic.S1751.severity = error # Loops with at most one iteration should be refactored -dotnet_diagnostic.S1764.severity = error # Identical expressions should not be used on both sides of operators -dotnet_diagnostic.S1848.severity = error # Objects should not be created to be dropped immediately without being used -dotnet_diagnostic.S1862.severity = error # Related "if/else if" statements should not have the same condition -dotnet_diagnostic.S2114.severity = error # Collections should not be passed as arguments to their own methods -dotnet_diagnostic.S2123.severity = error # Values should not be uselessly incremented -dotnet_diagnostic.S2201.severity = error # Methods without side effects should not have their return values ignored -dotnet_diagnostic.S2225.severity = error # "ToString()" method should not return null -dotnet_diagnostic.S2251.severity = error # A "for" loop update clause should move the counter in the right direction -dotnet_diagnostic.S2252.severity = error # For-loop conditions should be true at least once -dotnet_diagnostic.S2445.severity = error # Blocks should be synchronized on read-only fields -dotnet_diagnostic.S2688.severity = error # "NaN" should not be used in comparisons -dotnet_diagnostic.S2757.severity = error # Non-existent operators like "=+" should not be used +dotnet_diagnostic.S1751.severity = none # Loops with at most one iteration should be refactored - covered by SST1444 +dotnet_diagnostic.S1764.severity = none # Identical expressions should not be used on both sides of operators — covered by SST1474 +dotnet_diagnostic.S1848.severity = none # Objects should not be created to be dropped immediately without being used -> replaced by SST1480 +dotnet_diagnostic.S1862.severity = none # Related "if/else if" statements should not have the same condition — covered by SST1475 +dotnet_diagnostic.S2114.severity = none # Collections should not be passed as arguments to their own methods — covered by SST2419 +dotnet_diagnostic.S2123.severity = none # Values should not be uselessly incremented -> replaced by SST2222 +dotnet_diagnostic.S2201.severity = none # Methods without side effects should not have their return values ignored — covered by SST2418 +dotnet_diagnostic.S2225.severity = none # "ToString()" method should not return null — covered by SST2431 +dotnet_diagnostic.S2251.severity = none # A "for" loop update clause should move the counter in the right direction — covered by SST2412 +dotnet_diagnostic.S2252.severity = none # For-loop conditions should be true at least once — covered by SST2413 +dotnet_diagnostic.S2445.severity = none # Blocks should be synchronized on read-only fields — covered by SST1904 +dotnet_diagnostic.S2688.severity = none # "NaN" should not be used in comparisons — covered by SST1473 +dotnet_diagnostic.S2757.severity = none # Non-existent operators like "=+" should not be used — covered by SST2417 dotnet_diagnostic.S2761.severity = none # Doubled prefix operators "!!" and "~~" should not be used — covered by SST1190 -dotnet_diagnostic.S2995.severity = error # "Object.ReferenceEquals" should not be used for value types -dotnet_diagnostic.S2996.severity = error # "ThreadStatic" fields should not be initialized -dotnet_diagnostic.S2997.severity = error # "IDisposables" created in a "using" statement should not be returned -dotnet_diagnostic.S3005.severity = error # "ThreadStatic" should not be used on non-static fields -dotnet_diagnostic.S3168.severity = error # "async" methods should not return "void" -dotnet_diagnostic.S3172.severity = error # Delegates should not be subtracted -dotnet_diagnostic.S3244.severity = error # Anonymous delegates should not be used to unsubscribe from Events -dotnet_diagnostic.S3249.severity = error # Classes directly extending "object" should not call "base" in "GetHashCode" or "Equals" -dotnet_diagnostic.S3263.severity = error # Static fields should appear in the order they must be initialized -dotnet_diagnostic.S3343.severity = error # Caller information parameters should come at the end of the parameter list -dotnet_diagnostic.S3346.severity = error # Expressions used in "Debug.Assert" should not produce side effects -dotnet_diagnostic.S3453.severity = error # Classes should not have only "private" constructors -dotnet_diagnostic.S3466.severity = error # Optional parameters should be passed to "base" calls -dotnet_diagnostic.S3598.severity = error # One-way "OperationContract" methods should have "void" return type -dotnet_diagnostic.S3603.severity = error # Methods with "Pure" attribute should return a value -dotnet_diagnostic.S3610.severity = error # Nullable type comparison should not be redundant -dotnet_diagnostic.S3903.severity = error # Types should be defined in named namespaces -dotnet_diagnostic.S3923.severity = error # All branches in a conditional structure should not have exactly the same implementation -dotnet_diagnostic.S3926.severity = error # Deserialization methods should be provided for "OptionalField" members -dotnet_diagnostic.S3927.severity = error # Serialization event handlers should be implemented correctly -dotnet_diagnostic.S3981.severity = error # Collection sizes and array length comparisons should make sense -dotnet_diagnostic.S3984.severity = error # Exceptions should not be created without being thrown -dotnet_diagnostic.S4143.severity = error # Collection elements should not be replaced unconditionally -dotnet_diagnostic.S4210.severity = error # Windows Forms entry points should be marked with STAThread -dotnet_diagnostic.S4260.severity = error # "ConstructorArgument" parameters should exist in constructors -dotnet_diagnostic.S4428.severity = error # "PartCreationPolicyAttribute" should be used with "ExportAttribute" -dotnet_diagnostic.S6507.severity = error # Blocks should not be synchronized on local variables -dotnet_diagnostic.S6677.severity = error # Message template placeholders should be unique -dotnet_diagnostic.S6797.severity = error # Blazor query parameter type should be supported -dotnet_diagnostic.S6798.severity = error # [JSInvokable] attribute should only be used on public methods -dotnet_diagnostic.S6800.severity = error # Component parameter type should match the route parameter type constraint -dotnet_diagnostic.S6930.severity = error # Backslash should be avoided in route templates - -################### -# SonarAnalyzer (Sxxxx) - Minor Bug -################### +dotnet_diagnostic.S2995.severity = none # "Object.ReferenceEquals" should not be used for value types -> replaced by CA2013 +dotnet_diagnostic.S2996.severity = none # "ThreadStatic" fields should not be initialized -> replaced by CA2019 +dotnet_diagnostic.S2997.severity = none # "IDisposables" created in a "using" statement should not be returned — covered by SST2423 +dotnet_diagnostic.S3005.severity = none # "ThreadStatic" should not be used on non-static fields -> replaced by CA2259 +dotnet_diagnostic.S3168.severity = none # "async" methods should not return "void" — covered by SST1905 +dotnet_diagnostic.S3172.severity = none # Delegates should not be subtracted — covered by SST2448 +dotnet_diagnostic.S3244.severity = none # Anonymous delegates should not be used to unsubscribe from Events — covered by SST2449 +dotnet_diagnostic.S3249.severity = none # Covered by SST1447 (canonical) +dotnet_diagnostic.S3263.severity = none # Static fields should appear in the order they must be initialized — covered by SST2428 +dotnet_diagnostic.S3343.severity = none # Caller information parameters should come at the end of the parameter list — covered by SST2433 +dotnet_diagnostic.S3346.severity = none # Expressions used in "Debug.Assert" should not produce side effects — covered by SST2450 +dotnet_diagnostic.S3453.severity = none # Classes should not have only "private" constructors — covered by SST2451 +dotnet_diagnostic.S3466.severity = none # Optional parameters should be passed to "base" calls — covered by SST2425 +dotnet_diagnostic.S3598.severity = none # One-way "OperationContract" methods should have "void" return type -> replaced by obsolete (WCF) +dotnet_diagnostic.S3603.severity = none # Methods with "Pure" attribute should return a value — covered by SST2452 +dotnet_diagnostic.S3610.severity = none # Nullable type comparison should not be redundant -> replaced by compiler CS0472 +dotnet_diagnostic.S3903.severity = none # Types should be defined in named namespaces — covered by SST2312 +dotnet_diagnostic.S3923.severity = none # All branches in a conditional structure should not have exactly the same implementation — covered by SST1476 +dotnet_diagnostic.S3926.severity = none # Deserialization methods should be provided for "OptionalField" members -> replaced by obsolete (legacy binary serialization) +dotnet_diagnostic.S3927.severity = none # Serialization event handlers should be implemented correctly — covered by SST2430 +dotnet_diagnostic.S3981.severity = none # Collection sizes and array length comparisons should make sense — covered by SST1479 +dotnet_diagnostic.S3984.severity = none # Exceptions should not be created without being thrown — covered by SST1480 +dotnet_diagnostic.S4143.severity = none # Collection elements should not be replaced unconditionally — covered by SST1487 +dotnet_diagnostic.S4210.severity = none # Windows Forms entry points should be marked with STAThread -> replaced by SST2706 +dotnet_diagnostic.S4260.severity = none # "ConstructorArgument" parameters should exist in constructors -> replaced by SST2487 +dotnet_diagnostic.S4428.severity = none # "PartCreationPolicyAttribute" should be used with "ExportAttribute" — covered by SST2474 +dotnet_diagnostic.S6507.severity = none # Blocks should not be synchronized on local variables — covered by SST1903 +dotnet_diagnostic.S6677.severity = none # Message template placeholders should be unique — covered by SST2442 +dotnet_diagnostic.S6797.severity = none # Blazor query parameter type should be supported -> replaced by SST2702 +dotnet_diagnostic.S6798.severity = none # [JSInvokable] attribute should only be used on public methods -> replaced by SST2701 +dotnet_diagnostic.S6800.severity = none # Component parameter type should match the route parameter type constraint -> replaced by SST2703 +dotnet_diagnostic.S6930.severity = none # Backslash should be avoided in route templates -> replaced by SST2700 + +# Minor bugs dotnet_diagnostic.S1206.severity = none # "Equals(Object)" and "GetHashCode()" should be overridden in pairs - DUPLICATE CA2218 -dotnet_diagnostic.S1226.severity = error # Method parameters, caught exceptions and foreach variables' initial values should not be ignored -dotnet_diagnostic.S2183.severity = error # Integral numbers should not be shifted by zero or more than their number of bits-1 -dotnet_diagnostic.S2184.severity = error # Results of integer division should not be assigned to floating point variables -dotnet_diagnostic.S2328.severity = error # "GetHashCode" should not reference mutable fields -dotnet_diagnostic.S2345.severity = error # Flags enumerations should explicitly initialize all their members -dotnet_diagnostic.S2674.severity = error # The length returned from a stream read should be checked -dotnet_diagnostic.S2934.severity = error # Property assignments should not be made for "readonly" fields not constrained to reference types -dotnet_diagnostic.S2955.severity = error # Generic parameters not constrained to reference types should not be compared to "null" -dotnet_diagnostic.S3363.severity = error # Date and time should not be used as a type for primary keys -dotnet_diagnostic.S3397.severity = error # "base.Equals" should not be used to check for reference equality in "Equals" if "base" is not "object" -dotnet_diagnostic.S3456.severity = error # "string.ToCharArray()" and "ReadOnlySpan.ToArray()" should not be called redundantly -dotnet_diagnostic.S3887.severity = error # Mutable, non-private fields should not be "readonly" - -################### -# SonarAnalyzer (Sxxxx) - Blocker Vulnerability -################### -dotnet_diagnostic.S2115.severity = error # A secure password should be used when connecting to a database -dotnet_diagnostic.S2755.severity = error # XML parsers should not be vulnerable to XXE attacks -dotnet_diagnostic.S3884.severity = error # "CoSetProxyBlanket" and "CoInitializeSecurity" should not be used -dotnet_diagnostic.S6418.severity = error # Secrets should not be hard-coded - -################### -# SonarAnalyzer (Sxxxx) - Critical Vulnerability -################### -dotnet_diagnostic.S4423.severity = error # Weak SSL/TLS protocols should not be used -dotnet_diagnostic.S4426.severity = error # Cryptographic keys should be robust -dotnet_diagnostic.S4433.severity = error # LDAP connections should be authenticated -dotnet_diagnostic.S4830.severity = error # Server certificates should be verified during SSL/TLS connections -dotnet_diagnostic.S5344.severity = error # Passwords should not be stored in plaintext or with a fast hashing algorithm -dotnet_diagnostic.S5445.severity = error # Insecure temporary file creation methods should not be used -dotnet_diagnostic.S5542.severity = error # Encryption algorithms should be used with secure mode and padding scheme -dotnet_diagnostic.S5547.severity = error # Cipher algorithms should be robust -dotnet_diagnostic.S5659.severity = error # JWT should be signed and verified with strong cipher algorithms - -################### -# SonarAnalyzer (Sxxxx) - Major Vulnerability -################### -dotnet_diagnostic.S2068.severity = error # Credentials should not be hard-coded -dotnet_diagnostic.S2612.severity = error # File permissions should not be set to world-accessible values -dotnet_diagnostic.S4211.severity = error # Members should not have conflicting transparency annotations -dotnet_diagnostic.S4212.severity = error # Serialization constructors should be secured -dotnet_diagnostic.S6377.severity = error # XML signatures should be validated securely -dotnet_diagnostic.S7039.severity = error # Content Security Policies should be restrictive - -################### -# SonarAnalyzer (Sxxxx) - Blocker Code Smell -################### -dotnet_diagnostic.S1147.severity = error # Exit methods should not be called +dotnet_diagnostic.S1226.severity = none # Method parameters, caught exceptions and foreach variables' initial values should not be ignored +dotnet_diagnostic.S2183.severity = none # Integral numbers should not be shifted by zero or more than their number of bits-1 — covered by SST1478 +dotnet_diagnostic.S2184.severity = none # Results of integer division should not be assigned to floating point variables — covered by SST1477 +dotnet_diagnostic.S2328.severity = none # "GetHashCode" should not reference mutable fields — covered by SST1482 +dotnet_diagnostic.S2345.severity = none # Flags enumerations should explicitly initialize all their members — covered by SST2303 +dotnet_diagnostic.S2674.severity = none # The length returned from a stream read should be checked — covered by SST2446 +dotnet_diagnostic.S2934.severity = none # Property assignments should not be made for "readonly" fields not constrained to reference types — covered by SST2421 +dotnet_diagnostic.S2955.severity = none # Generic parameters not constrained to reference types should not be compared to "null" -> replaced by compiler CS0019/CS0037 +dotnet_diagnostic.S3363.severity = none # Date and time should not be used as a type for primary keys — covered by SST2475 +dotnet_diagnostic.S3397.severity = none # "base.Equals" should not be used to check for reference equality in "Equals" if "base" is not "object" — covered by SST2435 +dotnet_diagnostic.S3456.severity = none # "string.ToCharArray()" and "ReadOnlySpan.ToArray()" should not be called redundantly — covered by PSH1217 +dotnet_diagnostic.S3887.severity = none # Mutable, non-private fields should not be "readonly" — covered by SST2322 + +# Blocker vulnerabilities +dotnet_diagnostic.S2115.severity = none # A secure password should be used when connecting to a database -> replaced by SES1203 +dotnet_diagnostic.S2755.severity = none # XML parsers should not be vulnerable to XXE attacks -> replaced by CA3075 +dotnet_diagnostic.S3884.severity = none # "CoSetProxyBlanket" and "CoInitializeSecurity" should not be used -> replaced by obsolete (COM interop security) +dotnet_diagnostic.S6418.severity = none # Secrets should not be hard-coded — covered by SES1201 + +# Critical vulnerabilities +dotnet_diagnostic.S4423.severity = none # Weak SSL/TLS protocols should not be used -> replaced by CA5397/CA5398 +dotnet_diagnostic.S4426.severity = none # Cryptographic keys should be robust -> replaced by CA5385 (RSA) / CA5384 (DSA) +dotnet_diagnostic.S4433.severity = none # LDAP connections should be authenticated -> replaced by SES1310 +dotnet_diagnostic.S4830.severity = none # Server certificates should be verified during SSL/TLS connections -> replaced by SES1102 or SES1108 +dotnet_diagnostic.S5344.severity = none # Passwords should not be stored in plaintext or with a fast hashing algorithm -> replaced by SES1009 +dotnet_diagnostic.S5445.severity = none # Insecure temporary file creation methods should not be used -> replaced by SES1307 +dotnet_diagnostic.S5542.severity = none # Encryption algorithms should be used with secure mode and padding scheme -> replaced by CA5358 +dotnet_diagnostic.S5547.severity = none # Cipher algorithms should be robust -> replaced by CA5351 +dotnet_diagnostic.S5659.severity = none # JWT should be signed and verified with strong cipher algorithms -> replaced by SES1503 + +# Major vulnerabilities +dotnet_diagnostic.S2068.severity = none # Credentials should not be hard-coded -> replaced by SES1201 +dotnet_diagnostic.S2612.severity = none # File permissions should not be set to world-accessible values -> replaced by SES1308 +dotnet_diagnostic.S4211.severity = none # Members should not have conflicting transparency annotations -> replaced by obsolete (Code Access Security) +dotnet_diagnostic.S4212.severity = none # Serialization constructors should be secured -> replaced by obsolete (Code Access Security) +dotnet_diagnostic.S6377.severity = none # XML signatures should be validated securely -> replaced by SES1008 +dotnet_diagnostic.S7039.severity = none # Content Security Policies should be restrictive -> replaced by SES1515 + +# Blocker code smells +dotnet_diagnostic.S1147.severity = none # Exit methods should not be called — covered by SST2321 dotnet_diagnostic.S1451.severity = none # Track lack of copyright and license headers -dotnet_diagnostic.S2178.severity = error # Short-circuit logic should be used in boolean contexts -dotnet_diagnostic.S2187.severity = error # Test classes should contain at least one test case -dotnet_diagnostic.S2306.severity = error # "async" and "await" should not be used as identifiers +dotnet_diagnostic.S2178.severity = none # Short-circuit logic should be used in boolean contexts — covered by SST2415 +dotnet_diagnostic.S2187.severity = none # Test classes should contain at least one test case — covered by SST2504 +dotnet_diagnostic.S2306.severity = none # "async" and "await" should not be used as identifiers -> replaced by compiler (contextual keyword) dotnet_diagnostic.S2368.severity = none # Public methods should not have multidimensional array parameters — jagged arrays are the chosen layout for hot-path lookup tables -dotnet_diagnostic.S2387.severity = error # Child class fields should not shadow parent class fields -dotnet_diagnostic.S2437.severity = error # Unnecessary bit operations should not be performed -dotnet_diagnostic.S2699.severity = error # Tests should include assertions -dotnet_diagnostic.S2953.severity = error # Methods named "Dispose" should implement "IDisposable.Dispose" -dotnet_diagnostic.S2970.severity = error # Assertions should be complete -dotnet_diagnostic.S3060.severity = error # "is" should not be used with "this" -dotnet_diagnostic.S3237.severity = error # "value" contextual keyword should be used -dotnet_diagnostic.S3427.severity = error # Method overloads with default parameter values should not overlap -dotnet_diagnostic.S3433.severity = error # Test method signatures should be correct -dotnet_diagnostic.S3443.severity = error # Type should not be examined on "System.Type" instances -dotnet_diagnostic.S3875.severity = error # "operator==" should not be overloaded on reference types -dotnet_diagnostic.S3877.severity = error # Exceptions should not be thrown from unexpected methods -dotnet_diagnostic.S4462.severity = error # Calls to "async" methods should not be blocking -dotnet_diagnostic.S6422.severity = error # Calls to "async" methods should not be blocking in Azure Functions -dotnet_diagnostic.S6424.severity = error # Interfaces for durable entities should satisfy the restrictions - -################### -# SonarAnalyzer (Sxxxx) - Critical Code Smell -################### -dotnet_diagnostic.S1006.severity = error # Method overrides should not change parameter defaults +dotnet_diagnostic.S2387.severity = none # Child class fields should not shadow parent class fields — covered by SST1484 +dotnet_diagnostic.S2437.severity = none # Unnecessary bit operations should not be performed — covered by SST1481 +dotnet_diagnostic.S2699.severity = none # Tests should include assertions -> replaced by SST2500 +dotnet_diagnostic.S2953.severity = none # Methods named "Dispose" should implement "IDisposable.Dispose" — covered by SST2316 +dotnet_diagnostic.S2970.severity = none # Assertions should be complete -> replaced by SST2508 +dotnet_diagnostic.S3060.severity = none # "is" should not be used with "this" -> replaced by SST2327 +dotnet_diagnostic.S3237.severity = none # "value" contextual keyword should be used — covered by SST2429 +dotnet_diagnostic.S3427.severity = none # Method overloads with default parameter values should not overlap — covered by SST2319 +dotnet_diagnostic.S3433.severity = none # Test method signatures should be correct -> replaced by SST2509 +dotnet_diagnostic.S3443.severity = none # Type should not be examined on "System.Type" instances — covered by SST2432 +dotnet_diagnostic.S3875.severity = none # "operator==" should not be overloaded on reference types -> replaced by SST2464 +dotnet_diagnostic.S3877.severity = none # Exceptions should not be thrown from unexpected methods — covered by SST1485 +dotnet_diagnostic.S4462.severity = none # Calls to "async" methods should not be blocking - covered by PSH1315 +dotnet_diagnostic.S6422.severity = none # Calls to "async" methods should not be blocking in Azure Functions — covered by PSH1315 +dotnet_diagnostic.S6424.severity = none # Interfaces for durable entities should satisfy the restrictions -> off: Durable Entity-specific; not used in this library + +# Critical code smells +dotnet_diagnostic.S1006.severity = none # Method overrides should not change parameter defaults — covered by SST2424 dotnet_diagnostic.S1067.severity = none # Expressions should not be too complex -dotnet_diagnostic.S1163.severity = error # Exceptions should not be thrown in finally blocks +dotnet_diagnostic.S1163.severity = none # Exceptions should not be thrown in finally blocks -> replaced by CA2219 dotnet_diagnostic.S1186.severity = none # Methods should not be empty — covered by SST1438 -dotnet_diagnostic.S121.severity = error # Control structures should use curly braces (kept over SA1503 — Sonar 20ms vs SA1503 50ms) -dotnet_diagnostic.S1215.severity = none # "GC.Collect" should not be called +dotnet_diagnostic.S121.severity = none # Control structures should use curly braces (kept over SA1503 — Sonar 20ms vs SA1503 50ms) -> replaced by SST1503 +dotnet_diagnostic.S1215.severity = none # "GC.Collect" should not be called — covered by PSH1021 dotnet_diagnostic.S126.severity = none # "if ... else if" constructs should end with "else" clauses dotnet_diagnostic.S131.severity = none # "switch/Select" statements should contain a "default/Case Else" clauses dotnet_diagnostic.S134.severity = none # Control flow statements "if", "switch", "for", "foreach", "while", "do" and "try" should not be nested too deeply -dotnet_diagnostic.S1541.severity = error # Methods and properties should not be too complex -dotnet_diagnostic.S1699.severity = error # Constructors should only call non-overridable methods -dotnet_diagnostic.S1821.severity = error # "switch" statements should not be nested -dotnet_diagnostic.S1944.severity = error # Invalid casts should be avoided -dotnet_diagnostic.S1994.severity = error # "for" loop increment clauses should modify the loops' counters -dotnet_diagnostic.S2197.severity = error # Modulus results should not be checked for direct equality -dotnet_diagnostic.S2198.severity = error # Unnecessary mathematical comparisons should not be made +dotnet_diagnostic.S1541.severity = none # Methods and properties should not be too complex - covered by SST1442 +dotnet_diagnostic.S1699.severity = none # Constructors should only call non-overridable methods — covered by SST1483 +dotnet_diagnostic.S1821.severity = none # "switch" statements should not be nested -> replaced by SST2252 +dotnet_diagnostic.S1944.severity = none # Invalid casts should be avoided -> replaced by compiler CS0030 +dotnet_diagnostic.S1994.severity = none # "for" loop increment clauses should modify the loops' counters — covered by SST2411 +dotnet_diagnostic.S2197.severity = none # Modulus results should not be checked for direct equality — covered by SST2416 +dotnet_diagnostic.S2198.severity = none # Unnecessary mathematical comparisons should not be made -> replaced by SST2489 dotnet_diagnostic.S2223.severity = none # Non-constant static fields should not be visible - DUPLICATE CA2211 -dotnet_diagnostic.S2290.severity = error # Field-like events should not be virtual -dotnet_diagnostic.S2291.severity = error # Overflow checking should not be disabled for "Enumerable.Sum" -dotnet_diagnostic.S2302.severity = error # "nameof" should be used -dotnet_diagnostic.S2330.severity = error # Array covariance should not be used -dotnet_diagnostic.S2339.severity = error # Public constant members should not be used +dotnet_diagnostic.S2290.severity = none # Field-like events should not be virtual -> replaced by SST2456 +dotnet_diagnostic.S2291.severity = none # Overflow checking should not be disabled for "Enumerable.Sum" — covered by SST2457 +dotnet_diagnostic.S2302.severity = none # "nameof" should be used — covered by SST1415 +dotnet_diagnostic.S2330.severity = none # Array covariance should not be used — covered by SST2434 +dotnet_diagnostic.S2339.severity = none # Public constant members should not be used — covered by SST2311 dotnet_diagnostic.S2346.severity = none # Flags enumerations zero-value members should be named "None" - DUPLICATE CA1008 -dotnet_diagnostic.S2360.severity = error # Optional parameters should not be used -dotnet_diagnostic.S2365.severity = error # Properties should not make collection or array copies +dotnet_diagnostic.S2360.severity = none # Optional parameters should not be used — conflicts with SST2433, which owns caller-info parameters requiring a default +dotnet_diagnostic.S2365.severity = none # Properties should not make collection or array copies — covered by PSH1017 dotnet_diagnostic.S2479.severity = none # Whitespace and control characters in string literals should be explicit — covered by SST1192 -dotnet_diagnostic.S2692.severity = error # "IndexOf" checks should not be for positive numbers -dotnet_diagnostic.S2696.severity = error # Instance members should not write to "static" fields -dotnet_diagnostic.S2701.severity = error # Literal boolean values should not be used in assertions -dotnet_diagnostic.S3215.severity = error # "interface" instances should not be cast to concrete types -dotnet_diagnostic.S3216.severity = error # "ConfigureAwait(false)" should be used -dotnet_diagnostic.S3217.severity = error # "Explicit" conversions of "foreach" loops should not be used -dotnet_diagnostic.S3218.severity = error # Inner class members should not shadow outer class "static" or type members -dotnet_diagnostic.S3265.severity = error # Non-flags enums should not be used in bitwise operations -dotnet_diagnostic.S3353.severity = error # Unchanged variables should be marked as "const" -dotnet_diagnostic.S3447.severity = error # "[Optional]" should not be used on "ref" or "out" parameters -dotnet_diagnostic.S3451.severity = error # "[DefaultValue]" should not be used when "[DefaultParameterValue]" is meant -dotnet_diagnostic.S3600.severity = error # "params" should not be introduced on overrides -dotnet_diagnostic.S3776.severity = error # Cognitive Complexity of methods should not be too high -dotnet_diagnostic.S3871.severity = error # Exception types should be "public" +dotnet_diagnostic.S2692.severity = none # "IndexOf" checks should not be for positive numbers — covered by SST2420 +dotnet_diagnostic.S2696.severity = none # Instance members should not write to "static" fields -> replaced by SST2402 +dotnet_diagnostic.S2701.severity = none # Literal boolean values should not be used in assertions — covered by SST2503 +dotnet_diagnostic.S3215.severity = none # "interface" instances should not be cast to concrete types -> deliberately unenforced, same reason as SST2326 +dotnet_diagnostic.S3216.severity = none # "ConfigureAwait(false)" should be used -> replaced by CA2007 +dotnet_diagnostic.S3217.severity = none # "Explicit" conversions of "foreach" loops should not be used — covered by SST2225 +dotnet_diagnostic.S3218.severity = none # Inner class members should not shadow outer class "static" or type members — covered by SST1484 +dotnet_diagnostic.S3265.severity = none # Non-flags enums should not be used in bitwise operations — covered by SST2458 +dotnet_diagnostic.S3353.severity = none # Unchanged variables should be marked as "const" — covered by PSH1402 +dotnet_diagnostic.S3447.severity = none # "[Optional]" should not be used on "ref" or "out" parameters — covered by SST2459 +dotnet_diagnostic.S3451.severity = none # "[DefaultValue]" should not be used when "[DefaultParameterValue]" is meant — covered by SST2460 +dotnet_diagnostic.S3600.severity = none # "params" should not be introduced on overrides — covered by SST2426 +dotnet_diagnostic.S3776.severity = none # Cognitive Complexity of methods should not be too high - covered by SST1443 +dotnet_diagnostic.S3871.severity = none # Exception types should be "public" -> replaced by CA1064 dotnet_diagnostic.S3874.severity = none # "out" and "ref" parameters — repo idiom is TryX(..., out T value) -dotnet_diagnostic.S3904.severity = error # Assemblies should have version information -dotnet_diagnostic.S3937.severity = error # Number patterns should be regular -dotnet_diagnostic.S3972.severity = error # Conditionals should start on new lines -dotnet_diagnostic.S3973.severity = error # A conditionally executed single line should be denoted by indentation -dotnet_diagnostic.S3998.severity = error # Threads should not lock on objects with weak identity -dotnet_diagnostic.S4000.severity = error # Pointers to unmanaged memory should not be visible -dotnet_diagnostic.S4015.severity = error # Inherited member visibility should not be decreased -dotnet_diagnostic.S4019.severity = error # Base class methods should not be hidden -dotnet_diagnostic.S4025.severity = error # Child class fields should not differ from parent class fields only by capitalization +dotnet_diagnostic.S3904.severity = none # Assemblies should have version information -> replaced by obsolete (SDK supplies assembly version) +dotnet_diagnostic.S3937.severity = none # Number patterns should be regular — covered by SST1119 +dotnet_diagnostic.S3972.severity = none # Conditionals should start on new lines — covered by SST1146 +dotnet_diagnostic.S3973.severity = none # A conditionally executed single line should be denoted by indentation -> replaced by SST1503 +dotnet_diagnostic.S3998.severity = none # Threads should not lock on objects with weak identity -> replaced by SST1902 +dotnet_diagnostic.S4000.severity = none # Pointers to unmanaged memory should not be visible -> replaced by SST2328 +dotnet_diagnostic.S4015.severity = none # Inherited member visibility should not be decreased — covered by SST2462 +dotnet_diagnostic.S4019.severity = none # Base class methods should not be hidden — covered by SST2427 +dotnet_diagnostic.S4025.severity = none # Child class fields should not differ from parent class fields only by capitalization — covered by SST2463 dotnet_diagnostic.S4039.severity = none # Interface methods should be callable by derived types - DUPLICATE CA1033 dotnet_diagnostic.S4487.severity = none # Unread "private" fields should be removed -dotnet_diagnostic.S4524.severity = error # "default" clauses should be first or last -dotnet_diagnostic.S4635.severity = error # Start index should be used instead of calling Substring -dotnet_diagnostic.S5034.severity = error # "ValueTask" should be consumed correctly -dotnet_diagnostic.S6967.severity = error # ModelState.IsValid should be called in controller actions -dotnet_diagnostic.S8367.severity = error # Identifiers should not conflict with the C# 14 "field" contextual keyword -dotnet_diagnostic.S8368.severity = error # Identifiers should not conflict with the C# 14 "extension" contextual keyword -dotnet_diagnostic.S8380.severity = error # Return types named "partial" should be escaped with "@" -dotnet_diagnostic.S8381.severity = error # "scoped" should be escaped when used as an identifier or type name in parenthesized lambda parameter lists +dotnet_diagnostic.S4524.severity = none # "default" clauses should be first or last — covered by SST1219 +dotnet_diagnostic.S4635.severity = none # Start index should be used instead of calling Substring — covered by PSH1218 +dotnet_diagnostic.S5034.severity = none # "ValueTask" should be consumed correctly — covered by PSH1316 +dotnet_diagnostic.S6967.severity = none # ModelState.IsValid should be called in controller actions -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S8367.severity = none # Identifiers should not conflict with the C# 14 "field" contextual keyword - the C# 14 compiler reports this: CS9273 (error) for a local named field in an accessor, CS9258 (warning) for a rebinding read +dotnet_diagnostic.S8368.severity = none # Identifiers should not conflict with the C# 14 "extension" contextual keyword - the C# 14 compiler reports this as CS9306, and SST1300 already flags the lowercase type name +dotnet_diagnostic.S8380.severity = none # Return types named "partial" should be escaped with "@" - the compiler reports this as CS8981, and SST1300 already flags the lowercase type name +dotnet_diagnostic.S8381.severity = none # "scoped" should be escaped when used as an identifier or type name in parenthesized lambda parameter lists -> replaced by compiler dotnet_diagnostic.S927.severity = none # Parameter names should match base declaration and other partial definitions - DUPLICATE CA1725 -################### -# SonarAnalyzer (Sxxxx) - Major Code Smell -################### -dotnet_diagnostic.S103.severity = error # Lines should not be too long -dotnet_diagnostic.S104.severity = error # Files should not have too many lines of code -dotnet_diagnostic.S106.severity = error # Standard outputs should not be used directly to log anything -dotnet_diagnostic.S1066.severity = error # Mergeable "if" statements should be combined -dotnet_diagnostic.S107.severity = error # Methods should not have too many parameters +# Major code smells +dotnet_diagnostic.S103.severity = none # Lines should not be too long — covered by SST1521 +dotnet_diagnostic.S104.severity = none # Files should not have too many lines of code — covered by SST1522 +dotnet_diagnostic.S106.severity = none # Covered by SST1449 (canonical) +dotnet_diagnostic.S1066.severity = none # Mergeable "if" statements should be combined — covered by SST2013 +dotnet_diagnostic.S107.severity = none # Methods should not have too many parameters — covered by SST1472 dotnet_diagnostic.S108.severity = none # Nested blocks of code should not be left empty — covered by SST1439 -dotnet_diagnostic.S109.severity = error # Magic numbers should not be used -dotnet_diagnostic.S110.severity = error # Inheritance tree of classes should not be too deep -dotnet_diagnostic.S1110.severity = error # Redundant pairs of parentheses should be removed -dotnet_diagnostic.S1117.severity = error # Local variables should not shadow class fields or properties +dotnet_diagnostic.S109.severity = none # Magic numbers should not be used — covered by SST1471 +dotnet_diagnostic.S110.severity = none # Inheritance tree of classes should not be too deep — covered by SST1446 +dotnet_diagnostic.S1110.severity = none # Redundant pairs of parentheses should be removed — covered by SST1459 +dotnet_diagnostic.S1117.severity = none # Local variables should not shadow class fields or properties — covered by SST1484 dotnet_diagnostic.S1118.severity = none # Utility classes should not have public constructors - DUPLICATE CA1052 -dotnet_diagnostic.S112.severity = error # General or reserved exceptions should never be thrown +dotnet_diagnostic.S112.severity = none # General or reserved exceptions should never be thrown — covered by SST2409 dotnet_diagnostic.S1121.severity = none # Assignments should not be made from within sub-expressions — covered by SST1187 -dotnet_diagnostic.S1123.severity = error # "Obsolete" attributes should include explanations -dotnet_diagnostic.S1134.severity = error # Track uses of "FIXME" tags -dotnet_diagnostic.S1144.severity = none # Unused private types or members should be removed - DUPLICATE IDE0051 -dotnet_diagnostic.S1151.severity = error # "switch case" clauses should not have too many lines of code -dotnet_diagnostic.S1168.severity = error # Empty arrays and collections should be returned instead of null -dotnet_diagnostic.S1172.severity = error # Unused method parameters should be removed +dotnet_diagnostic.S1123.severity = none # "Obsolete" attributes should include explanations — covered by SST2308 +dotnet_diagnostic.S1134.severity = none # Track uses of "FIXME" tags -> off: TODO comment tracker; not enforced here +dotnet_diagnostic.S1144.severity = none # Covered by SST1440 (canonical) +dotnet_diagnostic.S1151.severity = none # "switch case" clauses should not have too many lines of code — covered by SST1524 +dotnet_diagnostic.S1168.severity = none # Empty arrays and collections should be returned instead of null — covered by SST2306 +dotnet_diagnostic.S1172.severity = none # Unused method parameters should be removed — covered by SST1461 dotnet_diagnostic.S1200.severity = none # Classes should not be coupled to too many other classes -dotnet_diagnostic.S122.severity = error # Statements should be on separate lines +dotnet_diagnostic.S122.severity = none # Statements should be on separate lines — covered by SST1107 dotnet_diagnostic.S125.severity = none # Sections of code should not be commented out — covered by SST1148 -dotnet_diagnostic.S127.severity = error # "for" loop stop conditions should be invariant -dotnet_diagnostic.S138.severity = error # Functions should not have too many lines of code -dotnet_diagnostic.S1479.severity = error # "switch" statements with many "case" clauses should have only one statement -dotnet_diagnostic.S1607.severity = error # Tests should not be ignored -dotnet_diagnostic.S1696.severity = error # NullReferenceException should not be caught +dotnet_diagnostic.S127.severity = none # "for" loop stop conditions should be invariant — covered by SST2465 +dotnet_diagnostic.S138.severity = none # Functions should not have too many lines of code — covered by SST1523 +dotnet_diagnostic.S1479.severity = none # "switch" statements with many "case" clauses — covered by SST1423 +dotnet_diagnostic.S1607.severity = none # Tests should not be ignored -> off: ignored-test tracker; not enforced here +dotnet_diagnostic.S1696.severity = none # NullReferenceException should not be caught — covered by SST2401 dotnet_diagnostic.S1854.severity = none # Unused assignments should be removed - DUPLICATE IDE0059 -dotnet_diagnostic.S1871.severity = error # Two branches in a conditional structure should not have exactly the same implementation -dotnet_diagnostic.S2139.severity = error # Exceptions should be either logged or rethrown but not both +dotnet_diagnostic.S1871.severity = none # Two branches in a conditional structure should not have exactly the same implementation — covered by SST2414 +dotnet_diagnostic.S2139.severity = none # Exceptions should be either logged or rethrown but not both -> replaced by SST2488 dotnet_diagnostic.S2166.severity = none # Classes named like "Exception" should extend "Exception" or a subclass - DUPLICATE CA1710 -dotnet_diagnostic.S2234.severity = error # Arguments should be passed in the same order as the method parameters -dotnet_diagnostic.S2326.severity = error # Unused type parameters should be removed -dotnet_diagnostic.S2327.severity = error # "try" statements with identical "catch" and/or "finally" blocks should be merged -dotnet_diagnostic.S2357.severity = error # Fields should be private -dotnet_diagnostic.S2372.severity = error # Exceptions should not be thrown from property getters -dotnet_diagnostic.S2376.severity = error # Write-only properties should not be used -dotnet_diagnostic.S2629.severity = error # Logging templates should be constant -dotnet_diagnostic.S2681.severity = error # Multiline blocks should be enclosed in curly braces +dotnet_diagnostic.S2234.severity = none # Arguments should be passed in the same order as the method parameters -> replaced by SST2400 +dotnet_diagnostic.S2326.severity = none # Covered by SST1452 (canonical) +dotnet_diagnostic.S2327.severity = none # "try" statements with identical "catch" and/or "finally" blocks should be merged -> replaced by SST2490 +dotnet_diagnostic.S2357.severity = none # Fields should be private — duplicate of SST1401 (canonical); fields intentionally exposed (e.g. public test fields for reflection) already carry per-site SST1401 suppressions +dotnet_diagnostic.S2372.severity = none # Exceptions should not be thrown from property getters — covered by SST1485 +dotnet_diagnostic.S2376.severity = none # Write-only properties should not be used — covered by SST1421 +dotnet_diagnostic.S2629.severity = none # Logging templates should be constant -> replaced by CA2254 +dotnet_diagnostic.S2681.severity = none # Multiline blocks should be enclosed in curly braces — covered by SST1503 dotnet_diagnostic.S2743.severity = none # Static fields should not be used in generic types - DUPLICATE CA1000 — covered by SST1431 -dotnet_diagnostic.S2925.severity = error # "Thread.Sleep" should not be used in tests +dotnet_diagnostic.S2925.severity = none # "Thread.Sleep" should not be used in tests — covered by SST2506 dotnet_diagnostic.S2933.severity = none # Fields that are only assigned in the constructor should be "readonly" - DUPLICATE IDE0044 -dotnet_diagnostic.S2971.severity = error # LINQ expressions should be simplified -dotnet_diagnostic.S3010.severity = error # Static fields should not be updated in constructors -dotnet_diagnostic.S3011.severity = error # Reflection should not be used to increase accessibility of classes, methods, or fields +dotnet_diagnostic.S2971.severity = none # LINQ expressions should be simplified — covered by PSH1101/PSH1102 +dotnet_diagnostic.S3010.severity = none # Static fields should not be updated in constructors — covered by SST2402 +dotnet_diagnostic.S3011.severity = none # Reflection should not be used to increase accessibility of classes, methods, or fields -> replaced by SES1406 dotnet_diagnostic.S3059.severity = none # Types should not have members with visibility set higher than the type's visibility -dotnet_diagnostic.S3063.severity = error # "StringBuilder" data should be used -dotnet_diagnostic.S3169.severity = error # Multiple "OrderBy" calls should not be used -dotnet_diagnostic.S3246.severity = error # Generic type parameters should be co/contravariant when possible -dotnet_diagnostic.S3262.severity = error # "params" should be used on overrides -dotnet_diagnostic.S3264.severity = error # Events should be invoked +dotnet_diagnostic.S3063.severity = none # "StringBuilder" data should be used — covered by SST2408 +dotnet_diagnostic.S3169.severity = none # Multiple "OrderBy" calls should not be used — covered by PSH1108 +dotnet_diagnostic.S3246.severity = none # Generic type parameters should be co/contravariant when possible -> off: low-precision variance suggestion; not enforced +dotnet_diagnostic.S3262.severity = none # "params" should be used on overrides — covered by SST2426 +dotnet_diagnostic.S3264.severity = none # Events should be invoked — covered by SST2407 dotnet_diagnostic.S3358.severity = none # Ternary operators should not be nested — covered by SST1147 -dotnet_diagnostic.S3366.severity = error # "this" should not be exposed from constructors -dotnet_diagnostic.S3415.severity = error # Assertion arguments should be passed in the correct order -dotnet_diagnostic.S3431.severity = error # "[ExpectedException]" should not be used +dotnet_diagnostic.S3366.severity = none # "this" should not be exposed from constructors — covered by SST2403 +dotnet_diagnostic.S3415.severity = none # Assertion arguments should be passed in the correct order — covered by SST2502 +dotnet_diagnostic.S3431.severity = none # "[ExpectedException]" should not be used — covered by SST2507 dotnet_diagnostic.S3442.severity = none # "abstract" classes should not have "public" constructors — covered by SST1428 dotnet_diagnostic.S3445.severity = none # Exceptions should not be explicitly rethrown — covered by SST1430 -dotnet_diagnostic.S3457.severity = error # Composite format strings should be used correctly -dotnet_diagnostic.S3597.severity = error # "ServiceContract" and "OperationContract" attributes should be used together -dotnet_diagnostic.S3880.severity = none # Finalizers should not be empty — covered by SST1434 -dotnet_diagnostic.S3881.severity = error # "IDisposable" should be implemented correctly -dotnet_diagnostic.S3885.severity = error # "Assembly.Load" should be used -dotnet_diagnostic.S3898.severity = error # Value types should implement "IEquatable" -dotnet_diagnostic.S3902.severity = error # "Assembly.GetExecutingAssembly" should not be called -dotnet_diagnostic.S3906.severity = error # Event Handlers should have the correct signature -dotnet_diagnostic.S3908.severity = error # Generic event handlers should be used -dotnet_diagnostic.S3909.severity = error # Collections should implement the generic interface +dotnet_diagnostic.S3457.severity = none # Composite format strings should be used correctly — covered by SST1454 +dotnet_diagnostic.S3597.severity = none # "ServiceContract" and "OperationContract" attributes should be used together -> replaced by obsolete (WCF) +dotnet_diagnostic.S3880.severity = none # Finalizers should not be empty — covered by PSH1002 +dotnet_diagnostic.S3881.severity = none # "IDisposable" should be implemented correctly — covered by SST2300 +dotnet_diagnostic.S3885.severity = none # "Assembly.Load" should be used -> replaced by SST2486 +dotnet_diagnostic.S3898.severity = none # Covered by PSH1005 (canonical) +dotnet_diagnostic.S3902.severity = none # Covered by PSH1404 (canonical) +dotnet_diagnostic.S3906.severity = none # Event Handlers should have the correct signature — covered by SST2304 +dotnet_diagnostic.S3908.severity = none # Generic event handlers should be used -> replaced by SST2304 +dotnet_diagnostic.S3909.severity = none # Collections should implement the generic interface -> replaced by CA1010 dotnet_diagnostic.S3925.severity = none # "ISerializable" should be implemented correctly - BinaryFormatter / ISerializable serialization is obsoleted (SYSLIB0050/0051) in modern .NET; we do not opt into legacy serialization for any exception type dotnet_diagnostic.S3928.severity = none # Parameter names used into ArgumentException constructors should match an existing one - DUPLICATE CA2208 dotnet_diagnostic.S3956.severity = none # "Generic.List" instances should not be part of public APIs -dotnet_diagnostic.S3971.severity = error # "GC.SuppressFinalize" should not be called -dotnet_diagnostic.S3990.severity = error # Assemblies should be marked as CLS compliant -dotnet_diagnostic.S3992.severity = error # Assemblies should explicitly specify COM visibility -dotnet_diagnostic.S3993.severity = error # Custom attributes should be marked with "System.AttributeUsageAttribute" +dotnet_diagnostic.S3971.severity = none # "GC.SuppressFinalize" should not be called — conflicts with PSH1008, which owns the pointless-SuppressFinalize direction and exempts unsealed types +dotnet_diagnostic.S3990.severity = none # Assemblies should be marked as CLS compliant -> replaced by CA1014 +dotnet_diagnostic.S3992.severity = none # Assemblies should explicitly specify COM visibility -> replaced by CA1017 +dotnet_diagnostic.S3993.severity = none # Custom attributes should be marked with "System.AttributeUsageAttribute" -> replaced by CA1018 dotnet_diagnostic.S3994.severity = none # URI Parameters should not be strings dotnet_diagnostic.S3995.severity = none # URI return values should not be strings dotnet_diagnostic.S3996.severity = none # URI properties should not be strings -dotnet_diagnostic.S3997.severity = error # String URI overloads should call "System.Uri" overloads -dotnet_diagnostic.S4002.severity = error # Disposable types should declare finalizers -dotnet_diagnostic.S4004.severity = error # Collection properties should be readonly +dotnet_diagnostic.S3997.severity = none # String URI overloads should call "System.Uri" overloads -> replaced by CA1054/CA1056/CA1057 +dotnet_diagnostic.S4002.severity = none # Disposable types should declare finalizers — covered by SST2317 +dotnet_diagnostic.S4004.severity = none # Collection properties should be readonly — covered by SST2305 dotnet_diagnostic.S4005.severity = none # "System.Uri" arguments should be used instead of strings -dotnet_diagnostic.S4016.severity = error # Enumeration members should not be named "Reserved" +dotnet_diagnostic.S4016.severity = none # Enumeration members should not be named "Reserved" -> replaced by CA1700 dotnet_diagnostic.S4017.severity = none # Method signatures should not contain nested generic types -dotnet_diagnostic.S4035.severity = error # Classes implementing "IEquatable" should be sealed -dotnet_diagnostic.S4050.severity = error # Operators should be overloaded consistently +dotnet_diagnostic.S4035.severity = none # Classes implementing "IEquatable" should be sealed — covered by SST2301 +dotnet_diagnostic.S4050.severity = none # Operators should be overloaded consistently — covered by SST2302 dotnet_diagnostic.S4055.severity = none # Literals should not be passed as localized parameters -dotnet_diagnostic.S4057.severity = error # Locales should be set for data types +dotnet_diagnostic.S4057.severity = none # Locales should be set for data types -> replaced by obsolete dotnet_diagnostic.S4059.severity = none # Property names should not match get methods - DUPLICATE CA1721 -dotnet_diagnostic.S4070.severity = error # Non-flags enums should not be marked with "FlagsAttribute" -dotnet_diagnostic.S4144.severity = error # Methods should not have identical implementations -dotnet_diagnostic.S4200.severity = error # Native methods should be wrapped +dotnet_diagnostic.S4070.severity = none # Non-flags enums should not be marked with "FlagsAttribute" — covered by SST2303 +dotnet_diagnostic.S4144.severity = none # Methods should not have identical implementations — covered by SST2318 +dotnet_diagnostic.S4200.severity = none # Native methods should be wrapped -> replaced by CA1401 dotnet_diagnostic.S4214.severity = none # "P/Invoke" methods should not be visible - DUPLICATE CA1401 -dotnet_diagnostic.S4220.severity = error # Events should have proper arguments -dotnet_diagnostic.S4456.severity = error # Parameter validation in yielding methods should be wrapped -dotnet_diagnostic.S4457.severity = none # Parameter validation in "async"/"await" methods should be wrapped -dotnet_diagnostic.S4545.severity = error # "DebuggerDisplayAttribute" strings should reference existing members -dotnet_diagnostic.S4581.severity = error # "new Guid()" should not be used -dotnet_diagnostic.S6354.severity = error # Use a testable date/time provider -dotnet_diagnostic.S6419.severity = error # Azure Functions should be stateless -dotnet_diagnostic.S6420.severity = error # Client instances should not be recreated on each Azure Function invocation -dotnet_diagnostic.S6421.severity = error # Azure Functions should use Structured Error Handling -dotnet_diagnostic.S6423.severity = error # Azure Functions should log all failures -dotnet_diagnostic.S6561.severity = error # Avoid using "DateTime.Now" for benchmarking or timing operations -dotnet_diagnostic.S6562.severity = error # Always set the "DateTimeKind" when creating new "DateTime" instances -dotnet_diagnostic.S6563.severity = error # Use UTC when recording DateTime instants -dotnet_diagnostic.S6566.severity = error # Use "DateTimeOffset" instead of "DateTime" -dotnet_diagnostic.S6575.severity = error # Use "TimeZoneInfo.FindSystemTimeZoneById" without converting the timezones with "TimezoneConverter" +dotnet_diagnostic.S4220.severity = none # Events should have proper arguments — covered by SST2436 +dotnet_diagnostic.S4456.severity = none # Parameter validation in yielding methods should be wrapped — covered by SST2404 +dotnet_diagnostic.S4457.severity = none # Parameter validation in "async"/"await" methods should be wrapped — covered by SST2325 +dotnet_diagnostic.S4545.severity = none # "DebuggerDisplayAttribute" strings should reference existing members — covered by SST2405 +dotnet_diagnostic.S4581.severity = none # "new Guid()" should not be used — covered by SST2012 +dotnet_diagnostic.S6354.severity = none # Use a testable date/time provider — covered by SST2010 +dotnet_diagnostic.S6419.severity = none # Azure Functions should be stateless -> off: Azure Functions-specific; not used in this library +dotnet_diagnostic.S6420.severity = none # Client instances should not be recreated on each Azure Function invocation — covered by PSH1418 +dotnet_diagnostic.S6421.severity = none # Azure Functions should use Structured Error Handling -> off: Azure Functions-specific; not used in this library +dotnet_diagnostic.S6423.severity = none # Azure Functions should log all failures -> off: Azure Functions-specific; not used in this library +dotnet_diagnostic.S6561.severity = none # Avoid using "DateTime.Now" for benchmarking or timing operations — covered by PSH1408 +dotnet_diagnostic.S6562.severity = none # Covered by SST1451 (canonical) +dotnet_diagnostic.S6563.severity = none # Use UTC when recording DateTime instants — covered by SST2011 +dotnet_diagnostic.S6566.severity = none # Use "DateTimeOffset" instead of "DateTime" — covered by SST2016 +dotnet_diagnostic.S6575.severity = none # Use "TimeZoneInfo.FindSystemTimeZoneById" without converting the timezones with "TimezoneConverter" -> replaced by PSH1419 dotnet_diagnostic.S6580.severity = none # Use a format provider when parsing date and time - DUPLICATE CA1305 -dotnet_diagnostic.S6673.severity = error # Log message template placeholders should be in the right order -dotnet_diagnostic.S6802.severity = error # Using lambda expressions in loops should be avoided in Blazor markup section -dotnet_diagnostic.S6803.severity = error # Parameters with SupplyParameterFromQuery attribute should be used only in routable components -dotnet_diagnostic.S6931.severity = error # ASP.NET controller actions should not have a route template starting with "/" -dotnet_diagnostic.S6932.severity = error # Use model binding instead of reading raw request data -dotnet_diagnostic.S6934.severity = error # A Route attribute should be added to the controller when a route template is specified at the action level -dotnet_diagnostic.S6960.severity = error # Controllers should not have mixed responsibilities -dotnet_diagnostic.S6961.severity = error # API Controllers should derive from ControllerBase instead of Controller -dotnet_diagnostic.S6962.severity = error # You should pool HTTP connections with HttpClientFactory -dotnet_diagnostic.S6964.severity = error # Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting. -dotnet_diagnostic.S6965.severity = error # REST API actions should be annotated with an HTTP verb attribute -dotnet_diagnostic.S6966.severity = error # Awaitable method should be used -dotnet_diagnostic.S6968.severity = error # Actions that return a value should be annotated with ProducesResponseTypeAttribute containing the return type -dotnet_diagnostic.S881.severity = error # Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression -dotnet_diagnostic.S907.severity = error # "goto" statement should not be used - -################### -# SonarAnalyzer (Sxxxx) - Minor Code Smell -################### -dotnet_diagnostic.S100.severity = error # Methods and properties should be named in PascalCase (kept over SA1300 — S100+S101 47ms vs SA1300 101ms) -dotnet_diagnostic.S101.severity = error # Types should be named in PascalCase (kept over SA1300 — see S100) -dotnet_diagnostic.S105.severity = error # Tabulation characters should not be used +dotnet_diagnostic.S6673.severity = none # Log message template placeholders should be in the right order — covered by SST2440 +dotnet_diagnostic.S6802.severity = none # Using lambda expressions in loops should be avoided in Blazor markup section -> replaced by PSH1600 +dotnet_diagnostic.S6803.severity = none # Parameters with SupplyParameterFromQuery attribute should be used only in routable components -> off: Blazor-specific; no Blazor surface in this library +dotnet_diagnostic.S6931.severity = none # ASP.NET controller actions should not have a route template starting with "/" -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S6932.severity = none # Use model binding instead of reading raw request data -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S6934.severity = none # A Route attribute should be added to the controller when a route template is specified at the action level -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S6960.severity = none # Controllers should not have mixed responsibilities -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S6961.severity = none # API Controllers should derive from ControllerBase instead of Controller -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S6962.severity = none # You should pool HTTP connections with HttpClientFactory — covered by PSH1418 +dotnet_diagnostic.S6964.severity = none # Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting. -> replaced by SST2705 (opt-in) +dotnet_diagnostic.S6965.severity = none # REST API actions should be annotated with an HTTP verb attribute -> replaced by SST2704 +dotnet_diagnostic.S6966.severity = none # Awaitable method should be used — covered by PSH1313 +dotnet_diagnostic.S6968.severity = none # Actions that return a value should be annotated with ProducesResponseTypeAttribute containing the return type -> off: ASP.NET MVC-specific; no controllers in this library +dotnet_diagnostic.S881.severity = none # Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression — covered by SST2015 +dotnet_diagnostic.S907.severity = none # "goto" statement should not be used — covered by SST2014 + +# Minor code smells +dotnet_diagnostic.S100.severity = none # Methods and properties should be named in PascalCase — covered by SST1300 +dotnet_diagnostic.S101.severity = none # Types should be named in PascalCase — covered by SST1300 +dotnet_diagnostic.S105.severity = none # Tabulation characters should not be used — covered by SST1027 dotnet_diagnostic.S1104.severity = none # Fields should not have public accessibility - DUPLICATE CA1051 -dotnet_diagnostic.S1109.severity = error # A close curly brace should be located at the beginning of a line +dotnet_diagnostic.S1109.severity = none # A close curly brace should be located at the beginning of a line — covered by SST1500 dotnet_diagnostic.S1116.severity = none # Empty statements should be removed - DUPLICATE SA1106 dotnet_diagnostic.S1125.severity = none # Boolean literals should not be redundant — covered by SST1182 -dotnet_diagnostic.S1128.severity = error # Unnecessary "using" should be removed (kept over IDE0005 — Sonar 551ms vs IDE0005 2.364s) +dotnet_diagnostic.S1128.severity = none # Covered by SST1445 (canonical) dotnet_diagnostic.S113.severity = none # Files should end with a newline -dotnet_diagnostic.S1155.severity = error # "Any()" should be used to test for emptiness +dotnet_diagnostic.S1155.severity = none # "Any()" should be used to test for emptiness — covered by PSH1119 dotnet_diagnostic.S1185.severity = none # Overriding members should do more than simply call the same member in the base class - DUPLICATE RCS1132 -dotnet_diagnostic.S1192.severity = error # String literals should not be duplicated -dotnet_diagnostic.S1199.severity = error # Nested code blocks should not be used +dotnet_diagnostic.S1192.severity = none # String literals should not be duplicated — covered by SST1486 +dotnet_diagnostic.S1199.severity = none # Nested code blocks should not be used — covered by SST1138 dotnet_diagnostic.S1210.severity = none # "Equals" and the comparison operators should be overridden when implementing "IComparable" - DUPLICATE CA1036 dotnet_diagnostic.S1227.severity = none # break statements should not be used except for switch cases -dotnet_diagnostic.S1264.severity = error # A "while" loop should be used instead of a "for" loop +dotnet_diagnostic.S1264.severity = none # A "while" loop should be used instead of a "for" loop — covered by SST2245 dotnet_diagnostic.S1301.severity = none # "switch" statements should have at least 3 "case" clauses dotnet_diagnostic.S1312.severity = none # Logger fields should be "private static readonly" -dotnet_diagnostic.S1449.severity = error # Culture should be specified for "string" operations -dotnet_diagnostic.S1450.severity = error # Private fields only used as local variables in methods should become local variables -dotnet_diagnostic.S1481.severity = error # Unused local variables should be removed -dotnet_diagnostic.S1643.severity = error # Strings should not be concatenated using '+' in a loop -dotnet_diagnostic.S1659.severity = error # Multiple variables should not be declared on the same line -dotnet_diagnostic.S1694.severity = error # An abstract class should have both abstract and concrete methods -dotnet_diagnostic.S1698.severity = error # "==" should not be used when "Equals" is overridden -dotnet_diagnostic.S1858.severity = error # "ToString()" calls should not be redundant +dotnet_diagnostic.S1449.severity = none # Culture should be specified for "string" operations — covered by PSH1207 +dotnet_diagnostic.S1450.severity = none # Private fields only used as local variables in methods should become local variables — covered by SST1422 +dotnet_diagnostic.S1481.severity = none # Unused local variables should be removed — covered by SST1497 +dotnet_diagnostic.S1643.severity = none # Covered by PSH1206 (canonical) +dotnet_diagnostic.S1659.severity = none # Multiple variables should not be declared on the same line — covered by SST1132 +dotnet_diagnostic.S1694.severity = none # An abstract class should have both abstract and concrete methods — covered by SST2323 +dotnet_diagnostic.S1698.severity = none # "==" should not be used when "Equals" is overridden — covered by SST1495 +dotnet_diagnostic.S1858.severity = none # "ToString()" calls should not be redundant — covered by PSH1211 dotnet_diagnostic.S1905.severity = none # Redundant casts should not be used - DUPLICATE IDE0004 — covered by SST1175 -dotnet_diagnostic.S1939.severity = error # Inheritance list should not be redundant -dotnet_diagnostic.S1940.severity = error # Boolean checks should not be inverted +dotnet_diagnostic.S1939.severity = none # Inheritance list should not be redundant — covered by SST1490 and SST1177 +dotnet_diagnostic.S1940.severity = none # Boolean checks should not be inverted — covered by SST1172 dotnet_diagnostic.S2094.severity = none # Classes should not be empty — covered by SST1436 dotnet_diagnostic.S2148.severity = none # Underscores should be used to make large numbers readable — covered by SST1191 -dotnet_diagnostic.S2156.severity = error # "sealed" classes should not have "protected" members -dotnet_diagnostic.S2219.severity = error # Runtime type checking should be simplified +dotnet_diagnostic.S2156.severity = none # "sealed" classes should not have "protected" members — covered by SST1427 +dotnet_diagnostic.S2219.severity = none # Runtime type checking should be simplified — covered by SST2007 dotnet_diagnostic.S2221.severity = none # "Exception" should not be caught -dotnet_diagnostic.S2292.severity = error # Trivial properties should be auto-implemented +dotnet_diagnostic.S2292.severity = none # Trivial properties should be auto-implemented — covered by SST1420 dotnet_diagnostic.S2325.severity = none # Methods and properties that don't access instance data should be static - DUPLICATE CA1822 -dotnet_diagnostic.S2333.severity = error # Redundant modifiers should not be used -dotnet_diagnostic.S2342.severity = error # Enumeration types should comply with a naming convention +dotnet_diagnostic.S2333.severity = none # Redundant modifiers should not be used — covered by SST1419/SST1491 +dotnet_diagnostic.S2342.severity = none # Enumeration types should comply with a naming convention — covered by SST1319 dotnet_diagnostic.S2344.severity = none # Enumeration type names should not have "Flags" or "Enum" suffixes - DUPLICATE CA1711 -dotnet_diagnostic.S2386.severity = error # Mutable fields should not be "public static" +dotnet_diagnostic.S2386.severity = none # Mutable fields should not be "public static" — covered by SST1499 dotnet_diagnostic.S2486.severity = none # Generic exceptions should not be ignored — covered by SST1429 -dotnet_diagnostic.S2737.severity = error # "catch" clauses should do more than rethrow -dotnet_diagnostic.S2760.severity = error # Sequential tests should not check the same condition -dotnet_diagnostic.S3052.severity = error # Members should not be initialized to default values -dotnet_diagnostic.S3220.severity = error # Method calls should not resolve ambiguously to overloads with "params" -dotnet_diagnostic.S3234.severity = error # "GC.SuppressFinalize" should not be invoked for types without destructors -dotnet_diagnostic.S3235.severity = error # Redundant parentheses should not be used -dotnet_diagnostic.S3236.severity = error # Caller information arguments should not be provided explicitly -dotnet_diagnostic.S3240.severity = error # The simplest possible condition syntax should be used -dotnet_diagnostic.S3241.severity = error # Methods should not return values that are never used +dotnet_diagnostic.S2737.severity = none # "catch" clauses should do more than rethrow — covered by SST1470 +dotnet_diagnostic.S2760.severity = none # Sequential tests should not check the same condition — covered by SST1475 +dotnet_diagnostic.S3052.severity = none # Covered by PSH1403 (canonical) +dotnet_diagnostic.S3220.severity = none # Method calls should not resolve ambiguously to overloads with "params" — covered by SST2467 +dotnet_diagnostic.S3234.severity = none # Covered by PSH1008 (canonical) +dotnet_diagnostic.S3235.severity = none # Redundant parentheses should not be used — covered by SST1459 +dotnet_diagnostic.S3236.severity = none # Covered by SST1448 (canonical) +dotnet_diagnostic.S3240.severity = none # The simplest possible condition syntax should be used - duplicate of SST1198 +dotnet_diagnostic.S3241.severity = none # Methods should not return values that are never used -> off: needs whole-program analysis; not enforced here dotnet_diagnostic.S3242.severity = none # Method parameters should be declared with base types -dotnet_diagnostic.S3247.severity = error # Duplicate casts should not be made -dotnet_diagnostic.S3251.severity = error # Implementations should be provided for "partial" methods +dotnet_diagnostic.S3247.severity = none # Duplicate casts should not be made — covered by SST1175 +dotnet_diagnostic.S3251.severity = none # Implementations should be provided for "partial" methods — covered by SST2468 dotnet_diagnostic.S3253.severity = none # Constructor and destructor declarations should not be redundant — covered by SST1433 -dotnet_diagnostic.S3254.severity = error # Default parameter values should not be passed as arguments -dotnet_diagnostic.S3256.severity = error # "string.IsNullOrEmpty" should be used -dotnet_diagnostic.S3257.severity = error # Declarations and initializations should be as concise as possible -dotnet_diagnostic.S3260.severity = error # Non-derived "private" classes and records should be "sealed" +dotnet_diagnostic.S3254.severity = none # Default parameter values should not be passed as arguments — covered by SST1494 +dotnet_diagnostic.S3256.severity = none # "string.IsNullOrEmpty" should be used — covered by PSH1204 (style configurable) +dotnet_diagnostic.S3257.severity = none # Declarations and initializations should be as concise as possible -> replaced by SST2202 +dotnet_diagnostic.S3260.severity = none # Non-derived "private" classes and records should be "sealed" — covered by PSH1411 dotnet_diagnostic.S3261.severity = none # Namespaces should not be empty — covered by SST1435 dotnet_diagnostic.S3267.severity = none # Loops should be simplified with "LINQ" expressions dotnet_diagnostic.S3376.severity = none # Attribute, EventArgs, and Exception type names should end with the type being extended - DUPLICATE CA1710 -dotnet_diagnostic.S3398.severity = error # "private" methods called only by inner classes should be moved to those classes -dotnet_diagnostic.S3400.severity = error # Methods should not return constants -dotnet_diagnostic.S3416.severity = error # Loggers should be named for their enclosing types -dotnet_diagnostic.S3440.severity = error # Variables should not be checked against the values they're about to be assigned -dotnet_diagnostic.S3441.severity = error # Redundant property names should be omitted in anonymous classes -dotnet_diagnostic.S3444.severity = error # Interfaces should not simply inherit from base interfaces with colliding members -dotnet_diagnostic.S3450.severity = error # Parameters with "[DefaultParameterValue]" attributes should also be marked "[Optional]" -dotnet_diagnostic.S3458.severity = error # Empty "case" clauses that fall through to the "default" should be omitted -dotnet_diagnostic.S3459.severity = error # Unassigned members should be removed -dotnet_diagnostic.S3532.severity = error # Empty "default" clauses should be removed -dotnet_diagnostic.S3604.severity = error # Member initializer values should not be redundant +dotnet_diagnostic.S3398.severity = none # "private" methods called only by inner classes should be moved to those classes — covered by SST1498 +dotnet_diagnostic.S3400.severity = none # Methods should not return constants — covered by SST1493 +dotnet_diagnostic.S3416.severity = none # Loggers should be named for their enclosing types — covered by SST2443 +dotnet_diagnostic.S3440.severity = none # Variables should not be checked against the values they're about to be assigned — covered by SST1492 +dotnet_diagnostic.S3441.severity = none # Redundant property names should be omitted in anonymous classes — covered by SST1173 +dotnet_diagnostic.S3444.severity = none # Interfaces should not simply inherit from base interfaces with colliding members — covered by SST2320 +dotnet_diagnostic.S3450.severity = none # Parameters with "[DefaultParameterValue]" attributes should also be marked "[Optional]" -> replaced by obsolete (legacy DefaultParameterValue) +dotnet_diagnostic.S3458.severity = none # Empty "case" clauses that fall through to the "default" should be omitted — covered by SST1466 +dotnet_diagnostic.S3459.severity = none # Unassigned members should be removed - the compiler reports this as CS0649; the rule only ever fires on private never-assigned fields +dotnet_diagnostic.S3532.severity = none # Empty "default" clauses should be removed — covered by SST1179 +dotnet_diagnostic.S3604.severity = none # Member initializer values should not be redundant — covered by PSH1403 dotnet_diagnostic.S3626.severity = none # Jump statements should not be redundant — covered by SST1174 -dotnet_diagnostic.S3717.severity = error # Track use of "NotImplementedException" -dotnet_diagnostic.S3872.severity = error # Parameter names should not duplicate the names of their methods -dotnet_diagnostic.S3876.severity = error # Strings or integral types should be used for indexers -dotnet_diagnostic.S3878.severity = error # Arrays should not be created for params parameters -dotnet_diagnostic.S3897.severity = error # Classes that provide "Equals()" should implement "IEquatable" -dotnet_diagnostic.S3962.severity = none # "static readonly" constants should be "const" instead - DUPLICATE CA1802 +dotnet_diagnostic.S3717.severity = none # Track use of "NotImplementedException" -> replaced by SST2485 +dotnet_diagnostic.S3872.severity = none # Parameter names should not duplicate the names of their methods -> replaced by SST1320 +dotnet_diagnostic.S3876.severity = none # Strings or integral types should be used for indexers -> replaced by CA1043 +dotnet_diagnostic.S3878.severity = none # Arrays should not be created for params parameters — covered by PSH1018 +dotnet_diagnostic.S3897.severity = none # Classes that provide "Equals()" should implement "IEquatable" -> replaced by CA1067 +dotnet_diagnostic.S3962.severity = none # Covered by PSH1402 (canonical) dotnet_diagnostic.S3963.severity = none # "static" fields should be initialized inline - DUPLICATE CA1810 dotnet_diagnostic.S3967.severity = none # Multidimensional arrays should not be used - DUPLICATE CA1814 -dotnet_diagnostic.S4018.severity = error # All type parameters should be used in the parameter list to enable type inference -dotnet_diagnostic.S4022.severity = error # Enumerations should have "Int32" storage +dotnet_diagnostic.S4018.severity = none # All type parameters should be used in the parameter list to enable type inference — covered by SST2307 +dotnet_diagnostic.S4022.severity = none # Enumerations should have "Int32" storage — covered by SST2313 dotnet_diagnostic.S4023.severity = none # Interfaces should not be empty — covered by SST1437 -dotnet_diagnostic.S4026.severity = error # Assemblies should be marked with "NeutralResourcesLanguageAttribute" -dotnet_diagnostic.S4027.severity = error # Exceptions should provide standard constructors +dotnet_diagnostic.S4026.severity = none # Assemblies should be marked with "NeutralResourcesLanguageAttribute" -> replaced by CA1824 +dotnet_diagnostic.S4027.severity = none # Exceptions should provide standard constructors — covered by SST1488 dotnet_diagnostic.S4040.severity = none # Strings should be normalized to uppercase - DUPLICATE CA1308 dotnet_diagnostic.S4041.severity = none # Type names should not match namespaces - DUPLICATE CA1724 -dotnet_diagnostic.S4047.severity = error # Generics should be used when appropriate +dotnet_diagnostic.S4047.severity = none # Generics should be used when appropriate -> off: fuzzy prefer-generics suggestion; not enforced dotnet_diagnostic.S4049.severity = none # Properties should be preferred - DUPLICATE CA1024 -dotnet_diagnostic.S4052.severity = error # Types should not extend outdated base types +dotnet_diagnostic.S4052.severity = none # Types should not extend outdated base types -> replaced by obsolete dotnet_diagnostic.S4056.severity = none # Overloads with a "CultureInfo" or an "IFormatProvider" parameter should be used - DUPLICATE CA1305 -dotnet_diagnostic.S4058.severity = error # Overloads with a "StringComparison" parameter should be used +dotnet_diagnostic.S4058.severity = none # Covered by PSH1207 (canonical) dotnet_diagnostic.S4060.severity = none # Non-abstract attributes should be sealed - DUPLICATE CA1813 -dotnet_diagnostic.S4061.severity = error # "params" should be used instead of "varargs" +dotnet_diagnostic.S4061.severity = none # "params" should be used instead of "varargs" -> replaced by obsolete (__arglist varargs) dotnet_diagnostic.S4069.severity = none # Operator overloads should have named alternatives - DUPLICATE CA2225 -dotnet_diagnostic.S4136.severity = error # Method overloads should be grouped together -dotnet_diagnostic.S4201.severity = error # Null checks should not be combined with "is" operator checks -dotnet_diagnostic.S4225.severity = error # Extension methods should not extend "object" +dotnet_diagnostic.S4136.severity = none # Method overloads should be grouped together — covered by SST1218 +dotnet_diagnostic.S4201.severity = none # Null checks should not be combined with "is" operator checks — covered by SST2018 +dotnet_diagnostic.S4225.severity = none # Extension methods should not extend "object" — covered by SST1706 dotnet_diagnostic.S4226.severity = none # Extensions should be in separate namespaces dotnet_diagnostic.S4261.severity = none # Methods should be named according to their synchronicities - Async suffix not used -dotnet_diagnostic.S4663.severity = error # Comments should not be empty +dotnet_diagnostic.S4663.severity = none # Covered by SST1120 (canonical) dotnet_diagnostic.S6513.severity = none # "ExcludeFromCodeCoverage" attributes should include a justification - not available on net462 and older TFMs -dotnet_diagnostic.S6585.severity = error # Don't hardcode the format when turning dates and times to strings -dotnet_diagnostic.S6588.severity = error # Use the "UnixEpoch" field instead of creating "DateTime" instances that point to the beginning of the Unix epoch -dotnet_diagnostic.S6602.severity = none # "Find" method should be used instead of the "FirstOrDefault" extension - DUPLICATE CA1826 -dotnet_diagnostic.S6603.severity = error # The collection-specific "TrueForAll" method should be used instead of the "All" extension -dotnet_diagnostic.S6605.severity = error # Collection-specific "Exists" method should be used instead of the "Any" extension -dotnet_diagnostic.S6607.severity = error # The collection should be filtered before sorting by using "Where" before "OrderBy" -dotnet_diagnostic.S6608.severity = none # Prefer indexing instead of "Enumerable" methods on types implementing "IList" - DUPLICATE CA1826 -dotnet_diagnostic.S6609.severity = error # "Min/Max" properties of "Set" types should be used instead of the "Enumerable" extension methods -dotnet_diagnostic.S6610.severity = error # "StartsWith" and "EndsWith" overloads that take a "char" should be used instead of the ones that take a "string" -dotnet_diagnostic.S6612.severity = error # The lambda parameter should be used instead of capturing arguments in "ConcurrentDictionary" methods -dotnet_diagnostic.S6613.severity = error # "First" and "Last" properties of "LinkedList" should be used instead of the "First()" and "Last()" extension methods -dotnet_diagnostic.S6617.severity = error # "Contains" should be used instead of "Any" for simple equality checks -dotnet_diagnostic.S6618.severity = error # "string.Create" should be used instead of "FormattableString" -dotnet_diagnostic.S6664.severity = error # The code block contains too many logging calls -dotnet_diagnostic.S6667.severity = error # Logging in a catch clause should pass the caught exception as a parameter. -dotnet_diagnostic.S6668.severity = error # Logging arguments should be passed to the correct parameter -dotnet_diagnostic.S6669.severity = error # Logger field or property name should comply with a naming convention -dotnet_diagnostic.S6670.severity = error # "Trace.Write" and "Trace.WriteLine" should not be used -dotnet_diagnostic.S6672.severity = error # Generic logger injection should match enclosing type -dotnet_diagnostic.S6675.severity = error # "Trace.WriteLineIf" should not be used with "TraceSwitch" levels -dotnet_diagnostic.S6678.severity = error # Use PascalCase for named placeholders -dotnet_diagnostic.S818.severity = error # Literal suffixes should be upper case - -################### -# SonarAnalyzer (Sxxxx) - Info Code Smell -################### -dotnet_diagnostic.S1133.severity = error # Deprecated code should be removed -dotnet_diagnostic.S1135.severity = error # Track uses of "TODO" tags +dotnet_diagnostic.S6585.severity = none # Don't hardcode the format when turning dates and times to strings — covered by SST2445 +dotnet_diagnostic.S6588.severity = none # Use the "UnixEpoch" field instead of creating "DateTime" instances that point to the beginning of the Unix epoch — covered by PSH1413 +dotnet_diagnostic.S6594.severity = none # Covered by PSH1406 (canonical) +dotnet_diagnostic.S6602.severity = none # Covered by PSH1110 (canonical) +dotnet_diagnostic.S6603.severity = none # Covered by PSH1110 (canonical) +dotnet_diagnostic.S6605.severity = none # Covered by PSH1110 (canonical) +dotnet_diagnostic.S6607.severity = none # The collection should be filtered before sorting — covered by PSH1107 +dotnet_diagnostic.S6608.severity = none # Covered by PSH1106 (canonical) +dotnet_diagnostic.S6609.severity = none # "Min/Max" properties of "Set" types should be used instead of the "Enumerable" extension methods — covered by PSH1122 +dotnet_diagnostic.S6610.severity = none # Covered by PSH1201 (canonical) +dotnet_diagnostic.S6612.severity = none # The lambda parameter should be used instead of capturing arguments in "ConcurrentDictionary" methods — covered by PSH1006 +dotnet_diagnostic.S6613.severity = none # "First" and "Last" properties of "LinkedList" should be used instead of the "First()" and "Last()" extension methods — covered by PSH1124 +dotnet_diagnostic.S6617.severity = none # Covered by PSH1111 (canonical) +dotnet_diagnostic.S6618.severity = none # "string.Create" should be used instead of "FormattableString" — covered by PSH1209 +dotnet_diagnostic.S6664.severity = none # The code block contains too many logging calls -> off: fuzzy too-many-logging-calls metric; not enforced +dotnet_diagnostic.S6667.severity = none # Logging in a catch clause should pass the caught exception as a parameter. — covered by SST2438 +dotnet_diagnostic.S6668.severity = none # Logging arguments should be passed to the correct parameter — covered by SST2439 +dotnet_diagnostic.S6669.severity = none # Logger field or property name should comply with a naming convention -> replaced by SST2601 +dotnet_diagnostic.S6670.severity = none # "Trace.Write" and "Trace.WriteLine" should not be used — covered by SST2600 +dotnet_diagnostic.S6672.severity = none # Generic logger injection should match enclosing type — covered by SST2443 +dotnet_diagnostic.S6675.severity = none # "Trace.WriteLineIf" should not be used with "TraceSwitch" levels -> off: niche TraceSwitch misuse; not enforced here +dotnet_diagnostic.S6678.severity = none # Use PascalCase for named placeholders -> replaced by CA1727 +dotnet_diagnostic.S818.severity = none # Literal suffixes should be upper case — covered by SST2244 + +# Informational code smells +dotnet_diagnostic.S1133.severity = none # Deprecated code should be removed — covered by SST2310 +dotnet_diagnostic.S1135.severity = none # Track uses of "TODO" tags -> off: FIXME comment tracker; not enforced here dotnet_diagnostic.S1309.severity = none # Track uses of in-source issue suppressions -################### -# SonarAnalyzer (Sxxxx) - Uncategorized -################### +# Uncategorized dotnet_diagnostic.S9999-cpd.severity = error # Copy-paste token calculator dotnet_diagnostic.S9999-log.severity = error # Log generator dotnet_diagnostic.S9999-metadata.severity = error # File metadata generator @@ -2062,36 +2622,30 @@ dotnet_diagnostic.S9999-testMethodDeclaration.severity = error # Test method dec dotnet_diagnostic.S9999-token-type.severity = error # Token type calculator dotnet_diagnostic.S9999-warning.severity = error # Analysis Warning generator -################### -# SonarAnalyzer (Sxxxx) - Critical Security Hotspot -################### +# Critical security hotspots dotnet_diagnostic.S2245.severity = none # Using pseudorandom number generators (PRNGs) is security-sensitive - DUPLICATE CA5394 -dotnet_diagnostic.S2257.severity = error # Using non-standard cryptographic algorithms is security-sensitive -dotnet_diagnostic.S4502.severity = error # Disabling CSRF protections is security-sensitive -dotnet_diagnostic.S4790.severity = error # Using weak hashing algorithms is security-sensitive -dotnet_diagnostic.S4792.severity = error # Configuring loggers is security-sensitive -dotnet_diagnostic.S5042.severity = error # Expanding archive files without controlling resource consumption is security-sensitive -dotnet_diagnostic.S5332.severity = error # Using clear-text protocols is security-sensitive -dotnet_diagnostic.S5443.severity = error # Using publicly writable directories is security-sensitive - -################### -# SonarAnalyzer (Sxxxx) - Major Security Hotspot -################### -dotnet_diagnostic.S1313.severity = error # Using hardcoded IP addresses is security-sensitive -dotnet_diagnostic.S2077.severity = error # Formatting SQL queries is security-sensitive -dotnet_diagnostic.S5693.severity = error # Allowing requests with excessive content length is security-sensitive -dotnet_diagnostic.S5753.severity = error # Disabling ASP.NET "Request Validation" feature is security-sensitive -dotnet_diagnostic.S5766.severity = error # Creating Serializable objects without data validation checks is security-sensitive -dotnet_diagnostic.S6444.severity = error # Not specifying a timeout for regular expressions is security-sensitive -dotnet_diagnostic.S6640.severity = error # Using unsafe code blocks is security-sensitive - -################### -# SonarAnalyzer (Sxxxx) - Minor Security Hotspot -################### -dotnet_diagnostic.S2092.severity = error # Creating cookies without the "secure" flag is security-sensitive -dotnet_diagnostic.S3330.severity = error # Creating cookies without the "HttpOnly" flag is security-sensitive -dotnet_diagnostic.S4507.severity = error # Delivering code in production with debug features activated is security-sensitive -dotnet_diagnostic.S5122.severity = error # Having a permissive Cross-Origin Resource Sharing policy is security-sensitive +dotnet_diagnostic.S2257.severity = none # Using non-standard cryptographic algorithms is security-sensitive -> replaced by SES1007 +dotnet_diagnostic.S4502.severity = none # Disabling CSRF protections is security-sensitive -> replaced by in-box ASP.NET antiforgery analyzer +dotnet_diagnostic.S4790.severity = none # Using weak hashing algorithms is security-sensitive -> replaced by CA5350/CA5351 +dotnet_diagnostic.S4792.severity = none # Configuring loggers is security-sensitive -> off: logging-configuration audit hotspot; not enforced here +dotnet_diagnostic.S5042.severity = none # Expanding archive files without controlling resource consumption is security-sensitive -> off: unbounded decompression; needs taint analysis, out of scope +dotnet_diagnostic.S5332.severity = none # Using clear-text protocols is security-sensitive -> replaced by SES1106 +dotnet_diagnostic.S5443.severity = none # Using publicly writable directories is security-sensitive -> replaced by SES1308 + +# Major security hotspots +dotnet_diagnostic.S1313.severity = none # Using hardcoded IP addresses is security-sensitive -> off: hardcoded-IP heuristic; too noisy to enforce +dotnet_diagnostic.S2077.severity = none # Formatting SQL queries is security-sensitive -> replaced by CA2100 +dotnet_diagnostic.S5693.severity = none # Allowing requests with excessive content length is security-sensitive -> replaced by SES1505 +dotnet_diagnostic.S5753.severity = none # Disabling ASP.NET "Request Validation" feature is security-sensitive -> replaced by obsolete (legacy ASP.NET request validation) +dotnet_diagnostic.S5766.severity = none # Creating Serializable objects without data validation checks is security-sensitive -> off: legacy BinaryFormatter deserialization; obsolete path, not used here +dotnet_diagnostic.S6444.severity = none # Not specifying a timeout for regular expressions is security-sensitive -> replaced by SES1509 +dotnet_diagnostic.S6640.severity = none # Using unsafe code blocks is security-sensitive -> off: unsafe-code audit; not enforced here + +# Minor security hotspots +dotnet_diagnostic.S2092.severity = none # Creating cookies without the "secure" flag is security-sensitive -> replaced by CA5382 +dotnet_diagnostic.S3330.severity = none # Creating cookies without the "HttpOnly" flag is security-sensitive -> replaced by CA5383 +dotnet_diagnostic.S4507.severity = none # Delivering code in production with debug features activated is security-sensitive -> off: debug features in production; partly covered by SES1506, rest not enforced +dotnet_diagnostic.S5122.severity = none # Having a permissive Cross-Origin Resource Sharing policy is security-sensitive -> replaced by SES1501 ############################################# # JetBrains ReSharper / Rider Inspections @@ -2240,6 +2794,35 @@ resharper_string_literal_typo_highlighting = none # typo checks are out of scope resharper_identifier_typo_highlighting = none # typo checks are out of scope resharper_markup_text_typo_highlighting = none # typo checks are out of scope +############################################# +# Deliberate type-driven registration APIs +############################################# +# These public factory/registration APIs intentionally require callers to select types +# explicitly. SST2307 documents this as a legitimate exception to its inference rule. +[src/Extensions.Hosting.Avalonia/Extensions/AvaloniaBuilderExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.Identity.EntityFrameworkCore.Sqlite/HostBuilderEntityFrameworkCoreExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.Identity.EntityFrameworkCore/HostBuilderEntityFrameworkCoreExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.Maui/HostBuilderMauiExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.Maui/MauiBuilderExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.WinForms/HostBuilderWinFormsExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.WinUI/HostBuilderWinUIExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + +[src/Extensions.Hosting.Wpf/WpfBuilderExtensions.cs] +dotnet_diagnostic.SST2307.severity = none + ############################################# # Other Settings ############################################# @@ -2276,14 +2859,9 @@ end_of_line = lf [*.{cmd, bat}] end_of_line = crlf - ############################################# # Test projects (TUnit) ############################################# # TUnit instantiates test classes per test, so they must remain instance classes and # cannot be marked static — even when a partial declaration happens to hold only static # members (the instance [Test] methods live in sibling partial files). -[**/tests/**/*.cs] -dotnet_diagnostic.SST1432.severity = none -dotnet_diagnostic.RCS1072.severity = none # covered by SST1435 -dotnet_diagnostic.RCS1106.severity = none # covered by SST1434 diff --git a/Directory.Build.props b/Directory.Build.props index e0c6c2c..2425adb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -75,21 +75,23 @@ - + - + - + - + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5acbbeb..5fc6170 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,11 +13,13 @@ - + - + + + diff --git a/src/ReactiveList.Benchmarks/BenchmarkObservableExtensions.cs b/src/ReactiveList.Benchmarks/BenchmarkObservableExtensions.cs index 862e2b7..4c47b39 100644 --- a/src/ReactiveList.Benchmarks/BenchmarkObservableExtensions.cs +++ b/src/ReactiveList.Benchmarks/BenchmarkObservableExtensions.cs @@ -17,7 +17,7 @@ internal static class BenchmarkObservableExtensions /// Subscribes an action observer. /// The next-value handler. /// The subscription disposable. - public IDisposable SubscribeObserver(Action onNext) + internal IDisposable SubscribeObserver(Action onNext) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(onNext); diff --git a/src/ReactiveList.Benchmarks/DynamicDataParityBenchmarks.cs b/src/ReactiveList.Benchmarks/DynamicDataParityBenchmarks.cs index 6c2e8c2..8220e59 100644 --- a/src/ReactiveList.Benchmarks/DynamicDataParityBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/DynamicDataParityBenchmarks.cs @@ -12,26 +12,38 @@ namespace ReactiveList.Benchmarks; -/// Provides DynamicDataParityBenchmarks. +/// Benchmarks ReactiveList and DynamicData pipelines with equivalent observable behavior. [MemoryDiagnoser] public class DynamicDataParityBenchmarks { + /// Identifies the even values used by parity-based pipelines. private const int ParityDivisor = 2; + /// Scales values emitted by transform benchmarks. private const int TransformMultiplier = 2; + /// Stores the sequential values supplied to each benchmark invocation. private int[] _data = []; - /// Gets or sets the item count. + /// Gets or sets the number of sequential items supplied to each benchmark. [Params(1_000, 10_000)] public int Count { get; set; } - /// Provides Setup. + /// Initializes the sequential source data for the configured item count. [GlobalSetup] - public void Setup() => _data = Enumerable.Range(0, Count).ToArray(); + public void Setup() + { + var data = new int[Count]; + for (var index = 0; index < data.Length; index++) + { + data[index] = index; + } + + _data = data; + } - /// Provides ReactiveList_Connect_Preloaded_InitialSnapshot. - /// The result. + /// Benchmarks the initial snapshot delivered when connecting to a preloaded ReactiveList. + /// The number of changes reported by the observer. [Benchmark] public int ReactiveList_Connect_Preloaded_InitialSnapshot() { @@ -41,8 +53,8 @@ public int ReactiveList_Connect_Preloaded_InitialSnapshot() return total; } - /// Provides SourceList_Connect_Preloaded_InitialSnapshot. - /// The result. + /// Benchmarks the initial snapshot delivered when connecting to a preloaded SourceList. + /// The number of changes reported by the observer. [Benchmark] public int SourceList_Connect_Preloaded_InitialSnapshot() { @@ -53,14 +65,14 @@ public int SourceList_Connect_Preloaded_InitialSnapshot() return total; } - /// Provides ReactiveList_FilterTransformSort. - /// The result. + /// Benchmarks filtering, transforming, and sorting a ReactiveList change stream. + /// The number of changes delivered by the pipeline. [Benchmark] public int ReactiveList_FilterTransformSort() { using var list = new ReactiveList(); var total = 0; - var pipeline = CP.Primitives.ReactiveListExtensions.SortBy( + var pipeline = CP.Primitives.ReactiveListExtensions.SortBy( list.Connect() .WhereChanges(static change => change.Current % ParityDivisor == 0) .SelectChanges(static item => item * TransformMultiplier), @@ -71,8 +83,8 @@ public int ReactiveList_FilterTransformSort() return total; } - /// Provides SourceList_FilterTransformSortBind. - /// The result. + /// Benchmarks filtering, transforming, sorting, and binding a SourceList change stream. + /// The number of values in the bound collection. [Benchmark] public int SourceList_FilterTransformSortBind() { @@ -82,14 +94,14 @@ public int SourceList_FilterTransformSortBind() .Transform(static item => item * TransformMultiplier) .Sort(SortExpressionComparer.Ascending(static item => item)) .Bind(out ReadOnlyObservableCollection bound) - .SubscribeObserver(_ => { }); + .SubscribeObserver(static _ => { }); list.AddRange(_data); return bound.Count; } - /// Provides ReactiveList_INCC_AddRange_WithItemsSubscriber. - /// The result. + /// Benchmarks item collection-change events raised by a ReactiveList add range. + /// The number of collection-change events raised. [Benchmark] public int ReactiveList_INCC_AddRange_WithItemsSubscriber() { @@ -101,15 +113,15 @@ public int ReactiveList_INCC_AddRange_WithItemsSubscriber() return events; } - /// Provides SourceList_INCC_AddRange_WithBoundSubscriber. - /// The result. + /// Benchmarks bound collection-change events raised by a SourceList add range. + /// The number of collection-change events raised. [Benchmark] public int SourceList_INCC_AddRange_WithBoundSubscriber() { using var list = new SourceList(); using var subscription = list.Connect() .Bind(out ReadOnlyObservableCollection bound) - .SubscribeObserver(_ => { }); + .SubscribeObserver(static _ => { }); var events = 0; ((INotifyCollectionChanged)bound).CollectionChanged += (_, _) => events++; @@ -117,8 +129,8 @@ public int SourceList_INCC_AddRange_WithBoundSubscriber() return events; } - /// Provides QuaternaryList_Stream_AddRange_DeliveryWait. - /// The result. + /// Benchmarks delivery of a QuaternaryList stream notification for an add range. + /// The number of stream notifications received. [Benchmark] public int QuaternaryList_Stream_AddRange_DeliveryWait() { @@ -133,12 +145,12 @@ public int QuaternaryList_Stream_AddRange_DeliveryWait() }); list.AddRange(_data); - delivered.Wait(TimeSpan.FromSeconds(1)); + _ = delivered.Wait(TimeSpan.FromSeconds(1)); return events; } - /// Provides SourceList_Stream_AddRange_Delivery. - /// The result. + /// Benchmarks delivery of a SourceList change stream notification for an add range. + /// The number of change stream notifications received. [Benchmark] public int SourceList_Stream_AddRange_Delivery() { diff --git a/src/ReactiveList.Benchmarks/ListBenchmarks.cs b/src/ReactiveList.Benchmarks/ListBenchmarks.cs index 435a2a1..ab7f0cd 100644 --- a/src/ReactiveList.Benchmarks/ListBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/ListBenchmarks.cs @@ -9,36 +9,48 @@ namespace ReactiveList.Benchmarks; -/// Provides ListBenchmarks. +/// Benchmarks common list operations across List, ReactiveList, and SourceList. [MemoryDiagnoser] public class ListBenchmarks { + /// Divides the configured item count when an operation affects half the data. private const int HalfCountDivisor = 2; + /// Identifies the even values used by parity-based benchmarks. private const int ParityDivisor = 2; + /// Stores the sequential values supplied to each benchmark invocation. private int[] _data = []; - /// Gets or sets the item count. + /// Gets or sets the number of sequential items supplied to each benchmark. [Params(100, 1_000, 10_000)] public int Count { get; set; } - /// Provides Setup. + /// Initializes the sequential source data for the configured item count. [GlobalSetup] - public void Setup() => _data = Enumerable.Range(0, Count).ToArray(); + public void Setup() + { + var data = new int[Count]; + for (var index = 0; index < data.Length; index++) + { + data[index] = index; + } - /// Provides List_AddRange. - /// The result. + _data = data; + } + + /// Benchmarks appending the prepared values to a standard list. + /// The number of items appended. [Benchmark] public int List_AddRange() { - var list = new List(); + var list = new List(Count); list.AddRange(_data); return list.Count; } - /// Provides ReactiveList_AddRange. - /// The result. + /// Benchmarks appending the prepared values to a ReactiveList. + /// The number of items appended. [Benchmark] public int ReactiveList_AddRange() { @@ -47,8 +59,8 @@ public int ReactiveList_AddRange() return list.Count; } - /// Provides SourceList_AddRange. - /// The result. + /// Benchmarks appending the prepared values to a DynamicData SourceList edit. + /// The number of items appended. [Benchmark] public int SourceList_AddRange() { @@ -57,8 +69,8 @@ public int SourceList_AddRange() return list.Count; } - /// Provides List_RemoveRange. - /// The result. + /// Benchmarks removing half the prepared values from a standard list. + /// The number of items remaining. [Benchmark] public int List_RemoveRange() { @@ -67,8 +79,8 @@ public int List_RemoveRange() return list.Count; } - /// Provides ReactiveList_RemoveRange. - /// The result. + /// Benchmarks removing half the prepared values from a ReactiveList. + /// The number of items remaining. [Benchmark] public int ReactiveList_RemoveRange() { @@ -77,8 +89,8 @@ public int ReactiveList_RemoveRange() return list.Count; } - /// Provides SourceList_RemoveRange. - /// The result. + /// Benchmarks removing half the prepared values from a SourceList edit. + /// The number of items remaining. [Benchmark] public int SourceList_RemoveRange() { @@ -88,8 +100,8 @@ public int SourceList_RemoveRange() return list.Count; } - /// Provides List_Clear. - /// The result. + /// Benchmarks clearing a populated standard list. + /// The number of items remaining. [Benchmark] public int List_Clear() { @@ -98,8 +110,8 @@ public int List_Clear() return list.Count; } - /// Provides ReactiveList_Clear. - /// The result. + /// Benchmarks clearing a populated ReactiveList. + /// The number of items remaining. [Benchmark] public int ReactiveList_Clear() { @@ -108,8 +120,8 @@ public int ReactiveList_Clear() return list.Count; } - /// Provides SourceList_Clear. - /// The result. + /// Benchmarks clearing a populated SourceList. + /// The number of items remaining. [Benchmark] public int SourceList_Clear() { @@ -119,8 +131,8 @@ public int SourceList_Clear() return list.Count; } - /// Provides List_Search. - /// The result. + /// Benchmarks searching a standard list for its final prepared value. + /// A value indicating whether the final value was found. [Benchmark] public bool List_Search() { @@ -128,8 +140,8 @@ public bool List_Search() return list.Contains(Count - 1); } - /// Provides ReactiveList_Search. - /// The result. + /// Benchmarks searching a ReactiveList for its final prepared value. + /// A value indicating whether the final value was found. [Benchmark] public bool ReactiveList_Search() { @@ -137,30 +149,30 @@ public bool ReactiveList_Search() return list.Contains(Count - 1); } - /// Provides SourceList_Search. - /// The result. + /// Benchmarks searching a SourceList for its final prepared value. + /// A value indicating whether the final value was found. [Benchmark] public bool SourceList_Search() { using var list = new SourceList(); list.Edit(l => l.AddRange(_data)); - return list.Items.Contains(Count - 1); + return ContainsValue(list.Items, Count - 1); } - /// Provides ReactiveList_Add_WithObserver. - /// The result. + /// Benchmarks delivery of an add-range notification from a ReactiveList. + /// The number of items reported by the observer. [Benchmark] public int ReactiveList_Add_WithObserver() { using var list = new ReactiveList(); var total = 0; - using var sub = list.Added.SubscribeObserver(items => total += items.Count()); + using var sub = list.Added.SubscribeObserver(items => total += CountItems(items)); list.AddRange(_data); return total; } - /// Provides SourceList_Add_WithObserver. - /// The result. + /// Benchmarks delivery of an add-range change set from a SourceList. + /// The number of changes reported by the observer. [Benchmark] public int SourceList_Add_WithObserver() { @@ -171,36 +183,36 @@ public int SourceList_Add_WithObserver() return total; } - /// Provides List_Filter. - /// The result. + /// Benchmarks counting even values in a standard list. + /// The number of even values. [Benchmark] public int List_Filter() { var list = new List(_data); - return list.Count(static x => x % ParityDivisor == 0); + return CountEvenValues(list); } - /// Provides ReactiveList_Filter. - /// The result. + /// Benchmarks counting even values in a ReactiveList. + /// The number of even values. [Benchmark] public int ReactiveList_Filter() { using var list = new ReactiveList(_data); - return list.Count(static x => x % ParityDivisor == 0); + return CountEvenValues(list); } - /// Provides SourceList_Filter. - /// The result. + /// Benchmarks counting even values in a SourceList. + /// The number of even values. [Benchmark] public int SourceList_Filter() { using var list = new SourceList(); list.Edit(l => l.AddRange(_data)); - return list.Items.Count(static x => x % ParityDivisor == 0); + return CountEvenValues(list.Items); } - /// Provides ReactiveList_Connect. - /// The result. + /// Benchmarks delivery of change notifications from an initially empty ReactiveList. + /// The number of changes reported by the observer. [Benchmark] public int ReactiveList_Connect() { @@ -211,20 +223,13 @@ public int ReactiveList_Connect() return total; } - /// Provides SourceList_Connect. - /// The result. + /// Benchmarks delivery of change notifications from an initially empty SourceList. + /// The number of changes reported by the observer. [Benchmark] - public int SourceList_Connect() - { - using var list = new SourceList(); - var total = 0; - using var sub = list.Connect().SubscribeObserver(changes => total += changes.TotalChanges); - list.AddRange(_data); - return total; - } + public int SourceList_Connect() => SourceList_Add_WithObserver(); - /// Provides ReactiveList_Connect_Preloaded. - /// The result. + /// Benchmarks the initial snapshot delivered when connecting to a preloaded ReactiveList. + /// The number of changes reported by the observer. [Benchmark] public int ReactiveList_Connect_Preloaded() { @@ -234,8 +239,8 @@ public int ReactiveList_Connect_Preloaded() return total; } - /// Provides SourceList_Connect_Preloaded. - /// The result. + /// Benchmarks the initial snapshot delivered when connecting to a preloaded SourceList. + /// The number of changes reported by the observer. [Benchmark] public int SourceList_Connect_Preloaded() { @@ -246,25 +251,25 @@ public int SourceList_Connect_Preloaded() return total; } - /// Provides ReactiveList_ReplaceAll. - /// The result. + /// Benchmarks replacing every value in a ReactiveList. + /// The number of items after replacement. [Benchmark] public int ReactiveList_ReplaceAll() { using var list = new ReactiveList(_data); - var newData = Enumerable.Range(Count, Count).ToArray(); + var newData = CreateReplacementData(Count); list.ReplaceAll(newData); return list.Count; } - /// Provides SourceList_ReplaceAll. - /// The result. + /// Benchmarks replacing every value in a SourceList edit. + /// The number of items after replacement. [Benchmark] public int SourceList_ReplaceAll() { using var list = new SourceList(); list.Edit(l => l.AddRange(_data)); - var newData = Enumerable.Range(Count, Count).ToArray(); + var newData = CreateReplacementData(Count); list.Edit(innerList => { innerList.Clear(); @@ -273,8 +278,8 @@ public int SourceList_ReplaceAll() return list.Count; } - /// Provides ReactiveList_Move. - /// The result. + /// Benchmarks moving the first item halfway through a ReactiveList. + /// The number of items after the move. [Benchmark] public int ReactiveList_Move() { @@ -283,8 +288,8 @@ public int ReactiveList_Move() return list.Count; } - /// Provides SourceList_Move. - /// The result. + /// Benchmarks moving the first item halfway through a SourceList. + /// The number of items after the move. [Benchmark] public int SourceList_Move() { @@ -294,24 +299,97 @@ public int SourceList_Move() return list.Count; } - /// Provides ReactiveList_RemoveMany. - /// The result. + /// Benchmarks removing even values from a ReactiveList. + /// The number of items remaining. [Benchmark] public int ReactiveList_RemoveMany() { using var list = new ReactiveList(_data); - list.RemoveMany(static x => x % ParityDivisor == 0); + _ = list.RemoveMany(static x => x % ParityDivisor == 0); return list.Count; } - /// Provides SourceList_RemoveMany. - /// The result. + /// Benchmarks removing even values from a SourceList. + /// The number of items remaining. [Benchmark] public int SourceList_RemoveMany() { using var list = new SourceList(); list.Edit(l => l.AddRange(_data)); - list.RemoveMany(list.Items.Where(static x => x % ParityDivisor == 0)); + var itemsToRemove = new List(Count / HalfCountDivisor); + foreach (var item in list.Items) + { + if (item % ParityDivisor == 0) + { + itemsToRemove.Add(item); + } + } + + list.RemoveMany(itemsToRemove); return list.Count; } + + /// Counts the even values in the supplied sequence without allocating a LINQ iterator. + /// The values to inspect. + /// The number of even values in . + private static int CountEvenValues(IEnumerable items) + { + var count = 0; + foreach (var item in items) + { + if (item % ParityDivisor == 0) + { + count++; + } + } + + return count; + } + + /// Counts all values in a sequence without allocating a LINQ iterator. + /// The sequence element type. + /// The values to enumerate. + /// The number of enumerated values. + private static int CountItems(IEnumerable items) + { + var count = 0; + foreach (var item in items) + { + _ = item; + count++; + } + + return count; + } + + /// Searches an explicitly enumerated sequence for a value. + /// The values to search. + /// The value to find. + /// when the value is present; otherwise, . + private static bool ContainsValue(IEnumerable items, int value) + { + foreach (var item in items) + { + if (item == value) + { + return true; + } + } + + return false; + } + + /// Creates the sequence used to replace all initially prepared values. + /// The number of replacement values to create. + /// Sequential values starting at . + private static int[] CreateReplacementData(int count) + { + var data = new int[count]; + for (var index = 0; index < data.Length; index++) + { + data[index] = count + index; + } + + return data; + } } diff --git a/src/ReactiveList.Benchmarks/MicroParityBenchmarks.cs b/src/ReactiveList.Benchmarks/MicroParityBenchmarks.cs index b8ae2d1..429b280 100644 --- a/src/ReactiveList.Benchmarks/MicroParityBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/MicroParityBenchmarks.cs @@ -12,52 +12,89 @@ namespace ReactiveList.Benchmarks; -/// Provides MicroParityBenchmarks. +/// Benchmarks equivalent ReactiveList and DynamicData operations on small data sets. [MemoryDiagnoser] [CategoriesColumn] [RankColumn] -public class MicroParityBenchmarks : IDisposable +public sealed class MicroParityBenchmarks : IDisposable { + /// Provides a bit mask that partitions items into four groups. private const int GroupMask = 3; + /// Names the secondary index used by indexed lookup benchmarks. private const string GroupIndexName = "Group"; + /// Divides a count in half for partial-removal benchmarks. private const int HalfCountDivisor = 2; + /// Scales the payload stored in generated benchmark items. private const int ValueMultiplier = 17; + /// Stores all sequential numbers used by list benchmarks. private int[] _numbers = []; + /// Stores the even-number subset used by removal benchmarks. private int[] _evens = []; + /// Stores generated items used by indexed collection benchmarks. private MicroItem[] _items = []; + /// Stores the keyed form of the generated benchmark items. private KeyValuePair[] _pairs = []; + /// Holds the prepared indexed quaternary list. private QuaternaryList? _indexedList; + /// Holds the prepared DynamicData source list. private SourceList? _sourceItemList; + /// Holds the prepared indexed quaternary dictionary. private QuaternaryDictionary? _indexedDictionary; + /// Holds the prepared DynamicData source cache. private SourceCache? _sourceCache; + /// Tracks whether the prepared benchmark collections have been disposed. private bool _disposed; /// Gets or sets the item count. [Params(1, 8, 32, 128)] public int Count { get; set; } - /// Provides Setup. + /// Initializes the data and indexed collections used by the benchmarks. [GlobalSetup] public void Setup() { - _numbers = Enumerable.Range(0, Count).ToArray(); - _evens = _numbers.Where(static item => (item & 1) == 0).ToArray(); - _items = Enumerable.Range(0, Count) - .Select(static item => new MicroItem(item, item & GroupMask, item * ValueMultiplier)) - .ToArray(); - _pairs = _items.Select(static item => KeyValuePair.Create(item.Id, item)).ToArray(); + _numbers = new int[Count]; + _items = new MicroItem[Count]; + var evenCount = 0; + for (var index = 0; index < Count; index++) + { + _numbers[index] = index; + _items[index] = new(index, index & GroupMask, index * ValueMultiplier); + if ((index & 1) == 0) + { + evenCount++; + } + } + + _evens = new int[evenCount]; + var evenIndex = 0; + foreach (var number in _numbers) + { + if ((number & 1) == 0) + { + _evens[evenIndex] = number; + evenIndex++; + } + } + + _pairs = new KeyValuePair[_items.Length]; + for (var index = 0; index < _items.Length; index++) + { + var item = _items[index]; + _pairs[index] = KeyValuePair.Create(item.Id, item); + } _indexedList = new(); _indexedList.AddRange(_items); @@ -75,19 +112,31 @@ public void Setup() _disposed = false; } - /// Provides Cleanup. + /// Releases the indexed collections created for the benchmarks. [GlobalCleanup] - public void Cleanup() => Dispose(disposing: true); + public void Cleanup() => Dispose(); - /// Provides Dispose. + /// Releases the indexed collections created for the benchmarks. public void Dispose() { - Dispose(disposing: true); - GC.SuppressFinalize(this); + if (_disposed) + { + return; + } + + _indexedList?.Dispose(); + _sourceItemList?.Dispose(); + _indexedDictionary?.Dispose(); + _sourceCache?.Dispose(); + _indexedList = null; + _sourceItemList = null; + _indexedDictionary = null; + _sourceCache = null; + _disposed = true; } - /// Provides ReactiveList_AddRange. - /// The result. + /// Measures adding all numbers to a ReactiveList. + /// The resulting list count. [Benchmark(Baseline = true)] [BenchmarkCategory("List", "AddRange")] public int ReactiveList_AddRange() @@ -97,8 +146,8 @@ public int ReactiveList_AddRange() return list.Count; } - /// Provides SourceList_AddRange. - /// The result. + /// Measures adding all numbers to a DynamicData SourceList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("List", "AddRange")] public int SourceList_AddRange() @@ -108,8 +157,8 @@ public int SourceList_AddRange() return list.Count; } - /// Provides ReactiveList_RemoveRange. - /// The result. + /// Measures removing the first half of a ReactiveList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("List", "RemoveRange")] public int ReactiveList_RemoveRange() @@ -119,8 +168,8 @@ public int ReactiveList_RemoveRange() return list.Count; } - /// Provides SourceList_RemoveRange. - /// The result. + /// Measures removing the first half of a DynamicData SourceList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("List", "RemoveRange")] public int SourceList_RemoveRange() @@ -131,8 +180,8 @@ public int SourceList_RemoveRange() return list.Count; } - /// Provides ReactiveList_RemoveMany. - /// The result. + /// Measures removing all even numbers from a ReactiveList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("List", "RemoveMany")] public int ReactiveList_RemoveMany() @@ -142,8 +191,8 @@ public int ReactiveList_RemoveMany() return list.Count; } - /// Provides SourceList_RemoveMany. - /// The result. + /// Measures removing all even numbers from a DynamicData SourceList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("List", "RemoveMany")] public int SourceList_RemoveMany() @@ -154,8 +203,8 @@ public int SourceList_RemoveMany() return list.Count; } - /// Provides ReactiveList_ConnectAddRange. - /// The result. + /// Measures ReactiveList change delivery while adding all numbers. + /// The number of delivered changes. [Benchmark] [BenchmarkCategory("List", "Connect")] public int ReactiveList_ConnectAddRange() @@ -167,8 +216,8 @@ public int ReactiveList_ConnectAddRange() return total; } - /// Provides SourceList_ConnectAddRange. - /// The result. + /// Measures SourceList change delivery while adding all numbers. + /// The number of delivered changes. [Benchmark] [BenchmarkCategory("List", "Connect")] public int SourceList_ConnectAddRange() @@ -180,8 +229,8 @@ public int SourceList_ConnectAddRange() return total; } - /// Provides ReactiveList_FilteredView. - /// The result. + /// Measures creating a ReactiveList view that contains even numbers. + /// The filtered view count. [Benchmark] [BenchmarkCategory("List", "View")] public int ReactiveList_FilteredView() @@ -191,8 +240,8 @@ public int ReactiveList_FilteredView() return view.Count; } - /// Provides SourceList_FilteredBind. - /// The result. + /// Measures binding the even numbers from a SourceList. + /// The filtered view count. [Benchmark] [BenchmarkCategory("List", "View")] public int SourceList_FilteredBind() @@ -202,12 +251,12 @@ public int SourceList_FilteredBind() using var subscription = list.Connect() .Filter(static item => (item & 1) == 0) .Bind(out ReadOnlyObservableCollection view) - .SubscribeObserver(_ => { }); + .SubscribeObserver(static _ => { }); return view.Count; } - /// Provides ReactiveList_DynamicFilteredView. - /// The result. + /// Measures dynamically filtering a ReactiveList view to even numbers. + /// The filtered view count. [Benchmark] [BenchmarkCategory("List", "DynamicView")] public int ReactiveList_DynamicFilteredView() @@ -219,8 +268,8 @@ public int ReactiveList_DynamicFilteredView() return view.Count; } - /// Provides SourceList_DynamicFilteredBind. - /// The result. + /// Measures dynamically filtering a SourceList binding to even numbers. + /// The filtered view count. [Benchmark] [BenchmarkCategory("List", "DynamicView")] public int SourceList_DynamicFilteredBind() @@ -231,13 +280,13 @@ public int SourceList_DynamicFilteredBind() using var subscription = list.Connect() .Filter(filter) .Bind(out ReadOnlyObservableCollection view) - .SubscribeObserver(_ => { }); + .SubscribeObserver(static _ => { }); filter.OnNext(static item => (item & 1) == 0); return view.Count; } - /// Provides QuaternaryList_AddRange. - /// The result. + /// Measures adding all items to a QuaternaryList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("QuaternaryList", "AddRange")] public int QuaternaryList_AddRange() @@ -247,8 +296,8 @@ public int QuaternaryList_AddRange() return list.Count; } - /// Provides SourceList_ItemAddRange. - /// The result. + /// Measures adding all items to a DynamicData SourceList. + /// The resulting list count. [Benchmark] [BenchmarkCategory("QuaternaryList", "AddRange")] public int SourceList_ItemAddRange() @@ -258,26 +307,41 @@ public int SourceList_ItemAddRange() return list.Count; } - /// Provides QuaternaryList_SecondaryLookup. - /// The result. + /// Measures looking up the group-one items through a QuaternaryList secondary index. + /// The number of matching items. [Benchmark] [BenchmarkCategory("QuaternaryList", "Lookup")] public int QuaternaryList_SecondaryLookup() { - return _indexedList!.GetItemsBySecondaryIndex(GroupIndexName, 1).Count(); + var matches = 0; + foreach (var _ in _indexedList!.GetItemsBySecondaryIndex(GroupIndexName, 1)) + { + matches++; + } + + return matches; } - /// Provides SourceList_SecondaryScan. - /// The result. + /// Measures scanning a SourceList for group-one items. + /// The number of matching items. [Benchmark] [BenchmarkCategory("QuaternaryList", "Lookup")] public int SourceList_SecondaryScan() { - return _sourceItemList!.Items.Count(static item => item.Group == 1); + var matches = 0; + foreach (var item in _sourceItemList!.Items) + { + if (item.Group == 1) + { + matches++; + } + } + + return matches; } - /// Provides QuaternaryDictionary_AddRange. - /// The result. + /// Measures adding all pairs to a QuaternaryDictionary. + /// The resulting dictionary count. [Benchmark] [BenchmarkCategory("Dictionary", "AddRange")] public int QuaternaryDictionary_AddRange() @@ -287,8 +351,8 @@ public int QuaternaryDictionary_AddRange() return dictionary.Count; } - /// Provides SourceCache_AddOrUpdateRange. - /// The result. + /// Measures adding or updating all items in a DynamicData SourceCache. + /// The resulting cache count. [Benchmark] [BenchmarkCategory("Dictionary", "AddRange")] public int SourceCache_AddOrUpdateRange() @@ -298,71 +362,54 @@ public int SourceCache_AddOrUpdateRange() return cache.Count; } - /// Provides QuaternaryDictionary_TryGetValue. - /// The result. + /// Measures looking up the last item in a QuaternaryDictionary. + /// when the item is found; otherwise, . [Benchmark] [BenchmarkCategory("Dictionary", "Lookup")] - public bool QuaternaryDictionary_TryGetValue() - { - return _indexedDictionary!.TryGetValue(Count - 1, out _); - } + public bool QuaternaryDictionary_TryGetValue() => _indexedDictionary!.TryGetValue(Count - 1, out _); - /// Provides SourceCache_Lookup. - /// The result. + /// Measures looking up the last item in a DynamicData SourceCache. + /// when the item is found; otherwise, . [Benchmark] [BenchmarkCategory("Dictionary", "Lookup")] - public bool SourceCache_Lookup() - { - return _sourceCache!.Lookup(Count - 1).HasValue; - } + public bool SourceCache_Lookup() => _sourceCache!.Lookup(Count - 1).HasValue; - /// Provides QuaternaryDictionary_SecondaryLookup. - /// The result. + /// Measures looking up group-one values through a QuaternaryDictionary secondary index. + /// The number of matching values. [Benchmark] [BenchmarkCategory("Dictionary", "SecondaryLookup")] public int QuaternaryDictionary_SecondaryLookup() { - return _indexedDictionary!.GetValuesBySecondaryIndex(GroupIndexName, 1).Count(); + var matches = 0; + foreach (var _ in _indexedDictionary!.GetValuesBySecondaryIndex(GroupIndexName, 1)) + { + matches++; + } + + return matches; } - /// Provides SourceCache_SecondaryScan. - /// The result. + /// Measures scanning a SourceCache for group-one items. + /// The number of matching items. [Benchmark] [BenchmarkCategory("Dictionary", "SecondaryLookup")] public int SourceCache_SecondaryScan() { - return _sourceCache!.Items.Count(static item => item.Group == 1); - } - - /// Provides Dispose. - /// The disposing value. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (!disposing) + var matches = 0; + foreach (var item in _sourceCache!.Items) { - _disposed = true; - return; + if (item.Group == 1) + { + matches++; + } } - _indexedList?.Dispose(); - _sourceItemList?.Dispose(); - _indexedDictionary?.Dispose(); - _sourceCache?.Dispose(); - _indexedList = null; - _sourceItemList = null; - _indexedDictionary = null; - _sourceCache = null; - _disposed = true; + return matches; } - /// Provides MicroItem. - /// The Id value. - /// The Group value. - /// The Value. + /// Represents an item used by the micro benchmarks. + /// The item's identifier. + /// The item's secondary-index group. + /// The item's payload value. private readonly record struct MicroItem(int Id, int Group, int Value); } diff --git a/src/ReactiveList.Benchmarks/ObservableIsolationBenchmarks.cs b/src/ReactiveList.Benchmarks/ObservableIsolationBenchmarks.cs index 7743576..3d0e2bb 100644 --- a/src/ReactiveList.Benchmarks/ObservableIsolationBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/ObservableIsolationBenchmarks.cs @@ -9,45 +9,59 @@ namespace ReactiveList.Benchmarks; -/// Provides ObservableIsolationBenchmarks. +/// Benchmarks observable delivery and secondary-index isolation. [MemoryDiagnoser] [CategoriesColumn] [RankColumn] public sealed class ObservableIsolationBenchmarks : IDisposable { + /// Provides a bit mask that partitions items into eight groups. private const int GroupMask = 7; + /// Names the secondary index used by indexed lookup benchmarks. private const string GroupIndexName = "Group"; + /// Identifies the group queried by indexed lookup and scan benchmarks. private const int IndexedGroup = 3; + /// Scales the payload stored in generated benchmark items. private const int ValueMultiplier = 17; + /// Stores generated items used by the observable benchmarks. private BenchItem[] _items = []; + /// Stores the keyed form of the generated benchmark items. private KeyValuePair[] _pairs = []; + /// Holds the prepared indexed quaternary list. private QuaternaryList? _indexedList; + /// Holds the prepared indexed quaternary dictionary. private QuaternaryDictionary? _indexedDictionary; + /// Holds the prepared DynamicData source cache. private SourceCache? _sourceCache; + /// Tracks whether the prepared benchmark collections have been disposed. private bool _disposed; /// Gets or sets the item count. [Params(1024)] public int Count { get; set; } - /// Provides Setup. + /// Initializes the data and indexed collections used by the benchmarks. [GlobalSetup] public void Setup() { _disposed = false; - _items = Enumerable.Range(0, Count) - .Select(static index => new BenchItem(index, index & GroupMask, index * ValueMultiplier)) - .ToArray(); - _pairs = _items.Select(static item => KeyValuePair.Create(item.Id, item)).ToArray(); + _items = new BenchItem[Count]; + _pairs = new KeyValuePair[Count]; + for (var index = 0; index < Count; index++) + { + BenchItem item = new(index, index & GroupMask, index * ValueMultiplier); + _items[index] = item; + _pairs[index] = KeyValuePair.Create(item.Id, item); + } _indexedList = new(); _indexedList.AddRange(_items); @@ -61,7 +75,7 @@ public void Setup() _sourceCache.AddOrUpdate(_items); } - /// Provides Cleanup. + /// Releases the indexed collections created for the benchmarks. [GlobalCleanup] public void Cleanup() { @@ -79,15 +93,11 @@ public void Cleanup() _disposed = true; } - /// Disposes benchmark resources. - public void Dispose() - { - Cleanup(); - GC.SuppressFinalize(this); - } + /// Releases the indexed collections created for the benchmarks. + public void Dispose() => Cleanup(); - /// Provides ReactiveList_AddRange_NoSubscriber. - /// The result. + /// Measures adding all items to a ReactiveList with no subscriber. + /// The resulting list count. [Benchmark(Baseline = true)] [BenchmarkCategory("StreamIsolation")] public int ReactiveList_AddRange_NoSubscriber() @@ -97,8 +107,8 @@ public int ReactiveList_AddRange_NoSubscriber() return list.Count; } - /// Provides ReactiveList_AddRange_WithConnectSubscriber. - /// The result. + /// Measures adding all items to a ReactiveList with a Connect subscriber. + /// The combined list and observed change counts. [Benchmark] [BenchmarkCategory("StreamIsolation")] public int ReactiveList_AddRange_WithConnectSubscriber() @@ -110,8 +120,8 @@ public int ReactiveList_AddRange_WithConnectSubscriber() return list.Count + observed; } - /// Provides QuaternaryList_AddRange_NoSubscriber. - /// The result. + /// Measures adding all items to a QuaternaryList with no subscriber. + /// The resulting list count. [Benchmark] [BenchmarkCategory("StreamIsolation")] public int QuaternaryList_AddRange_NoSubscriber() @@ -121,8 +131,8 @@ public int QuaternaryList_AddRange_NoSubscriber() return list.Count; } - /// Provides QuaternaryDictionary_AddRange_NoSubscriber. - /// The result. + /// Measures adding all pairs to a QuaternaryDictionary with no subscriber. + /// The resulting dictionary count. [Benchmark] [BenchmarkCategory("StreamIsolation")] public int QuaternaryDictionary_AddRange_NoSubscriber() @@ -132,8 +142,8 @@ public int QuaternaryDictionary_AddRange_NoSubscriber() return dictionary.Count; } - /// Provides SourceCache_AddOrUpdate_WithConnectSubscriber. - /// The result. + /// Measures adding or updating all items in a SourceCache with a Connect subscriber. + /// The combined cache and observed change counts. [Benchmark] [BenchmarkCategory("StreamIsolation")] public int SourceCache_AddOrUpdate_WithConnectSubscriber() @@ -145,36 +155,57 @@ public int SourceCache_AddOrUpdate_WithConnectSubscriber() return cache.Count + observed; } - /// Provides QuaternaryList_SecondaryIndexLookup. - /// The result. + /// Measures looking up indexed items in a QuaternaryList. + /// The number of matching items. [Benchmark] [BenchmarkCategory("IndexedLookup")] public int QuaternaryList_SecondaryIndexLookup() { - return _indexedList!.GetItemsBySecondaryIndex(GroupIndexName, IndexedGroup).Count(); + var matches = 0; + foreach (var _ in _indexedList!.GetItemsBySecondaryIndex(GroupIndexName, IndexedGroup)) + { + matches++; + } + + return matches; } - /// Provides QuaternaryDictionary_SecondaryIndexLookup. - /// The result. + /// Measures looking up indexed values in a QuaternaryDictionary. + /// The number of matching values. [Benchmark] [BenchmarkCategory("IndexedLookup")] public int QuaternaryDictionary_SecondaryIndexLookup() { - return _indexedDictionary!.GetValuesBySecondaryIndex(GroupIndexName, IndexedGroup).Count(); + var matches = 0; + foreach (var _ in _indexedDictionary!.GetValuesBySecondaryIndex(GroupIndexName, IndexedGroup)) + { + matches++; + } + + return matches; } - /// Provides SourceCache_SecondaryScan. - /// The result. + /// Measures scanning a SourceCache for indexed values. + /// The number of matching values. [Benchmark] [BenchmarkCategory("IndexedLookup")] public int SourceCache_SecondaryScan() { - return _sourceCache!.Items.Count(static item => item.Group == IndexedGroup); + var matches = 0; + foreach (var item in _sourceCache!.Items) + { + if (item.Group == IndexedGroup) + { + matches++; + } + } + + return matches; } - /// Provides BenchItem. - /// The Id value. - /// The Group value. - /// The Value. + /// Represents an item used by the isolation benchmarks. + /// The item's identifier. + /// The item's secondary-index group. + /// The item's payload value. private readonly record struct BenchItem(int Id, int Group, int Value); } diff --git a/src/ReactiveList.Benchmarks/Program.cs b/src/ReactiveList.Benchmarks/Program.cs index 3759cf1..60c7124 100644 --- a/src/ReactiveList.Benchmarks/Program.cs +++ b/src/ReactiveList.Benchmarks/Program.cs @@ -8,21 +8,26 @@ using BenchmarkDotNet.Toolchains.InProcess.Emit; // Configure for in-process benchmarking with proper thread pool settings. -const int MinimumThreadCount = 4; - -ThreadPool.SetMinThreads(MinimumThreadCount, MinimumThreadCount); +_ = ThreadPool.SetMinThreads(Program.MinimumThreadCount, Program.MinimumThreadCount); var config = ManualConfig.Create(DefaultConfig.Instance); -var hasJobOverride = args.Any(arg => - arg.Equals("--job", StringComparison.OrdinalIgnoreCase) || - arg.StartsWith("--job=", StringComparison.OrdinalIgnoreCase)); +var hasJobOverride = Array.Exists(args, static arg => + arg.Equals("--job", StringComparison.OrdinalIgnoreCase) + || arg.StartsWith("--job=", StringComparison.OrdinalIgnoreCase)); if (!hasJobOverride) { - config.AddJob(Job.ShortRun + _ = config.AddJob(Job.ShortRun .WithToolchain(InProcessEmitToolchain.Instance) .WithEnvironmentVariable("DOTNET_SYSTEM_THREADING_THREADPOOL_MINWORKERS", "4")); } -BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); +_ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); + +/// Hosts the generated benchmark application entry point. +internal sealed partial class Program +{ + /// Specifies the minimum worker and completion-port thread counts used by benchmarks. + internal const int MinimumThreadCount = 4; +} diff --git a/src/ReactiveList.Benchmarks/QuaternaryDictionaryBenchmarks.cs b/src/ReactiveList.Benchmarks/QuaternaryDictionaryBenchmarks.cs index 45bf431..afc991c 100644 --- a/src/ReactiveList.Benchmarks/QuaternaryDictionaryBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/QuaternaryDictionaryBenchmarks.cs @@ -12,26 +12,37 @@ namespace ReactiveList.Benchmarks; [MemoryDiagnoser] public class QuaternaryDictionaryBenchmarks { + /// Divides a count in half for partial-removal benchmarks. private const int HalfCountDivisor = 2; + /// Identifies the value used to probe a secondary index. private const int IndexedProbeValue = 4; + /// Specifies the minimum size needed to exercise the parallel add path. private const int MinimumLargeDatasetCount = 500; + /// Provides the divisor used by modulo-four operations. private const int ModuloFourDivisor = 4; + /// Provides the divisor used by modulo-three operations. private const int ModuloThreeDivisor = 3; + /// Provides the divisor used by modulo-two operations. private const int ModuloTwoDivisor = 2; + /// Provides the divisor used by modulo-five operations. private const int ModuloFiveDivisor = 5; + /// Controls how frequently items are removed in mixed-operation benchmarks. private const int PeriodicRemovalDivisor = 10; + /// Provides the multiplier used when benchmark values are updated. private const int ValueMultiplier = 2; + /// Stores the key/value input shared by dictionary benchmarks. private KeyValuePair[] _kvps = []; + /// Stores the keyed items shared by source-cache benchmarks. private Item[] _items = []; /// Gets or sets the item count. @@ -42,10 +53,13 @@ public class QuaternaryDictionaryBenchmarks [GlobalSetup] public void Setup() { - _kvps = Enumerable.Range(0, Count) - .Select(i => new KeyValuePair(i, i)) - .ToArray(); - _items = _kvps.Select(k => new Item(k.Key, k.Value)).ToArray(); + _kvps = new KeyValuePair[Count]; + _items = new Item[Count]; + for (var i = 0; i < Count; i++) + { + _kvps[i] = new(i, i); + _items[i] = new(i, i); + } } /// Provides Dictionary_AddRange. @@ -77,7 +91,7 @@ public int QuaternaryDictionary_AddRange() [Benchmark] public int SourceCache_AddRange() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); return cache.Count; } @@ -87,10 +101,10 @@ public int SourceCache_AddRange() [Benchmark] public int Dictionary_Remove() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); for (var i = 0; i < Count / HalfCountDivisor; i++) { - dict.Remove(i); + _ = dict.Remove(i); } return dict.Count; @@ -105,7 +119,7 @@ public int QuaternaryDictionary_Remove() dict.AddRange(_kvps); for (var i = 0; i < Count / HalfCountDivisor; i++) { - dict.Remove(i); + _ = dict.Remove(i); } return dict.Count; @@ -116,7 +130,7 @@ public int QuaternaryDictionary_Remove() [Benchmark] public int SourceCache_Remove() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); cache.RemoveKeys(Enumerable.Range(0, Count / HalfCountDivisor)); return cache.Count; @@ -138,7 +152,7 @@ public int QuaternaryDictionary_RemoveKeys() [Benchmark] public int Dictionary_Clear() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); dict.Clear(); return dict.Count; } @@ -159,7 +173,7 @@ public int QuaternaryDictionary_Clear() [Benchmark] public int SourceCache_Clear() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); cache.Clear(); return cache.Count; @@ -170,7 +184,7 @@ public int SourceCache_Clear() [Benchmark] public bool Dictionary_TryGetValue() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); return dict.TryGetValue(Count - 1, out _); } @@ -199,7 +213,7 @@ public bool QuaternaryDictionary_Lookup() [Benchmark] public bool SourceCache_Lookup() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); return cache.Lookup(Count - 1).HasValue; } @@ -221,7 +235,7 @@ public int QuaternaryDictionary_Stream_Add() [Benchmark] public int SourceCache_Stream_Add() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); var events = 0; using var sub = cache.Connect().SubscribeObserver(_ => events++); cache.AddOrUpdate(_items); @@ -251,7 +265,7 @@ public int QuaternaryDictionary_Edit() [Benchmark] public int SourceCache_Edit() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); cache.Edit(innerCache => { @@ -271,7 +285,7 @@ public int QuaternaryDictionary_RemoveMany() { using var dict = new QuaternaryDictionary(); dict.AddRange(_kvps); - dict.RemoveMany(static kvp => kvp.Key % ModuloTwoDivisor == 0); + _ = dict.RemoveMany(static kvp => kvp.Key % ModuloTwoDivisor == 0); return dict.Count; } @@ -283,7 +297,7 @@ public long QuaternaryDictionary_VersionTracking() using var dict = new QuaternaryDictionary(); var initialVersion = dict.Version; dict.AddRange(_kvps); - dict.RemoveMany(static kvp => kvp.Key % ModuloTwoDivisor == 0); + _ = dict.RemoveMany(static kvp => kvp.Key % ModuloTwoDivisor == 0); dict.Clear(); return dict.Version - initialVersion; } @@ -296,7 +310,7 @@ public int QuaternaryDictionary_ValueIndex() using var dict = new QuaternaryDictionary(); dict.AddValueIndex("Mod2", static value => value % ModuloTwoDivisor); dict.AddRange(_kvps); - return dict.GetValuesBySecondaryIndex("Mod2", 0).Count(); + return CountItems(dict.GetValuesBySecondaryIndex("Mod2", 0)); } /// Provides QuaternaryDictionary_ParallelAdd. @@ -307,9 +321,12 @@ public int QuaternaryDictionary_ParallelAdd() using var dict = new QuaternaryDictionary(); // Large dataset to trigger parallel processing (threshold is 256) - var largeKvps = Enumerable.Range(0, Math.Max(Count, MinimumLargeDatasetCount)) - .Select(i => new KeyValuePair(i, i)) - .ToArray(); + var largeKvps = new KeyValuePair[Math.Max(Count, MinimumLargeDatasetCount)]; + for (var i = 0; i < largeKvps.Length; i++) + { + largeKvps[i] = new(i, i); + } + dict.AddRange(largeKvps); return dict.Count; } @@ -335,7 +352,7 @@ public int QuaternaryDictionary_IterateAll() [Benchmark] public int Dictionary_IterateAll() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); var sum = 0; foreach (var kvp in dict) { @@ -406,7 +423,7 @@ public int QuaternaryDictionary_Enumerate() [Benchmark] public bool Dictionary_ContainsKey() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); return dict.ContainsKey(Count - 1); } @@ -417,7 +434,7 @@ public bool QuaternaryDictionary_Contains() { using var dict = new QuaternaryDictionary(); dict.AddRange(_kvps); - return dict.Contains(new KeyValuePair(Count - 1, Count - 1)); + return dict.Contains(new(Count - 1, Count - 1)); } /// Provides Dictionary_IndexerGet. @@ -425,7 +442,7 @@ public bool QuaternaryDictionary_Contains() [Benchmark] public int Dictionary_IndexerGet() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); var sum = 0; for (var i = 0; i < Count; i++) { @@ -484,7 +501,7 @@ public int QuaternaryDictionary_IndexerSet() [Benchmark] public int Dictionary_Keys() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); return dict.Keys.Count; } @@ -493,9 +510,9 @@ public int Dictionary_Keys() [Benchmark] public int SourceCache_Keys() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); - return cache.Keys.ToList().Count; + return CountItems(cache.Keys); } /// Provides Dictionary_Values. @@ -503,7 +520,7 @@ public int SourceCache_Keys() [Benchmark] public int Dictionary_Values() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); return dict.Values.Count; } @@ -512,9 +529,9 @@ public int Dictionary_Values() [Benchmark] public int SourceCache_Values() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); - return cache.Items.ToList().Count; + return CountItems(cache.Items); } /// Provides SourceCache_IterateAll. @@ -522,7 +539,7 @@ public int SourceCache_Values() [Benchmark] public int SourceCache_IterateAll() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); var sum = 0; foreach (var item in cache.Items) @@ -551,7 +568,7 @@ public int QuaternaryDictionary_Stream_Remove() [Benchmark] public int SourceCache_Stream_Remove() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); var events = 0; using var sub = cache.Connect().SubscribeObserver(_ => events++); @@ -573,13 +590,7 @@ public int QuaternaryDictionary_AddValueIndex() /// Provides QuaternaryDictionary_QueryValueIndex. /// The result. [Benchmark] - public int QuaternaryDictionary_QueryValueIndex() - { - using var dict = new QuaternaryDictionary(); - dict.AddValueIndex("Mod2", static value => value % ModuloTwoDivisor); - dict.AddRange(_kvps); - return dict.GetValuesBySecondaryIndex("Mod2", 0).Count(); - } + public int QuaternaryDictionary_QueryValueIndex() => QuaternaryDictionary_ValueIndex(); /// Provides QuaternaryDictionary_ValueMatchesSecondaryIndex. /// The result. @@ -602,9 +613,9 @@ public int QuaternaryDictionary_MultipleValueIndices() dict.AddValueIndex("Mod3", static value => value % ModuloThreeDivisor); dict.AddValueIndex("Mod5", static value => value % ModuloFiveDivisor); dict.AddRange(_kvps); - return dict.GetValuesBySecondaryIndex("Mod2", 0).Count() + - dict.GetValuesBySecondaryIndex("Mod3", 0).Count() + - dict.GetValuesBySecondaryIndex("Mod5", 0).Count(); + return CountItems(dict.GetValuesBySecondaryIndex("Mod2", 0)) + + CountItems(dict.GetValuesBySecondaryIndex("Mod3", 0)) + + CountItems(dict.GetValuesBySecondaryIndex("Mod5", 0)); } /// Provides QuaternaryDictionary_IndexWithAddRemove. @@ -615,8 +626,8 @@ public int QuaternaryDictionary_IndexWithAddRemove() using var dict = new QuaternaryDictionary(); dict.AddValueIndex("Mod2", static value => value % ModuloTwoDivisor); dict.AddRange(_kvps); - dict.RemoveMany(static kvp => kvp.Key % ModuloFourDivisor == 0); - return dict.GetValuesBySecondaryIndex("Mod2", 0).Count(); + _ = dict.RemoveMany(static kvp => kvp.Key % ModuloFourDivisor == 0); + return CountItems(dict.GetValuesBySecondaryIndex("Mod2", 0)); } /// Provides Dictionary_Count. @@ -624,29 +635,19 @@ public int QuaternaryDictionary_IndexWithAddRemove() [Benchmark] public int Dictionary_Count() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); return dict.Count; } /// Provides QuaternaryDictionary_Count. /// The result. [Benchmark] - public int QuaternaryDictionary_Count() - { - using var dict = new QuaternaryDictionary(); - dict.AddRange(_kvps); - return dict.Count; - } + public int QuaternaryDictionary_Count() => QuaternaryDictionary_AddRange(); /// Provides SourceCache_Count. /// The result. [Benchmark] - public int SourceCache_Count() - { - using var cache = new SourceCache(x => x.Id); - cache.AddOrUpdate(_items); - return cache.Count; - } + public int SourceCache_Count() => SourceCache_AddRange(); /// Provides QuaternaryDictionary_MixedOperations. /// The result. @@ -656,8 +657,8 @@ public int QuaternaryDictionary_MixedOperations() using var dict = new QuaternaryDictionary(); dict.AddRange(_kvps); dict.AddOrUpdate(Count, Count); - dict.Remove(0); - dict.RemoveMany(static kvp => kvp.Key % PeriodicRemovalDivisor == 0); + _ = dict.Remove(0); + _ = dict.RemoveMany(static kvp => kvp.Key % PeriodicRemovalDivisor == 0); return dict.Count; } @@ -666,11 +667,20 @@ public int QuaternaryDictionary_MixedOperations() [Benchmark] public int SourceCache_MixedOperations() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); cache.AddOrUpdate(new Item(Count, Count)); cache.Remove(0); - cache.RemoveKeys(cache.Keys.Where(static key => key % PeriodicRemovalDivisor == 0)); + List keysToRemove = []; + foreach (var key in cache.Keys) + { + if (key % PeriodicRemovalDivisor == 0) + { + keysToRemove.Add(key); + } + } + + cache.RemoveKeys(keysToRemove); return cache.Count; } @@ -679,12 +689,21 @@ public int SourceCache_MixedOperations() [Benchmark] public int Dictionary_MixedOperations() { - var dict = _kvps.ToDictionary(k => k.Key, k => k.Value); + var dict = new Dictionary(_kvps); dict[Count] = Count; - dict.Remove(0); - foreach (var key in dict.Keys.Where(static key => key % PeriodicRemovalDivisor == 0).ToList()) + _ = dict.Remove(0); + List keysToRemove = []; + foreach (var key in dict.Keys) + { + if (key % PeriodicRemovalDivisor == 0) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) { - dict.Remove(key); + _ = dict.Remove(key); } return dict.Count; @@ -723,7 +742,7 @@ public int QuaternaryDictionary_Add() [Benchmark] public int SourceCache_Add() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); for (var i = 0; i < Count; i++) { cache.AddOrUpdate(new Item(i, i)); @@ -740,7 +759,7 @@ public int Dictionary_TryAdd() var dict = new Dictionary(); for (var i = 0; i < Count; i++) { - dict.TryAdd(i, i); + _ = dict.TryAdd(i, i); } return dict.Count; @@ -754,7 +773,7 @@ public int QuaternaryDictionary_TryAdd() using var dict = new QuaternaryDictionary(); for (var i = 0; i < Count; i++) { - dict.TryAdd(i, i); + _ = dict.TryAdd(i, i); } return dict.Count; @@ -785,7 +804,7 @@ public int Dictionary_AddOrUpdate() [Benchmark] public int SourceCache_AddOrUpdate() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); for (var i = 0; i < Count; i++) { cache.AddOrUpdate(new Item(i, i)); @@ -803,25 +822,44 @@ public int SourceCache_AddOrUpdate() /// Provides SourceCache_RemoveKeys. /// The result. [Benchmark] - public int SourceCache_RemoveKeys() - { - using var cache = new SourceCache(x => x.Id); - cache.AddOrUpdate(_items); - cache.RemoveKeys(Enumerable.Range(0, Count / HalfCountDivisor)); - return cache.Count; - } + public int SourceCache_RemoveKeys() => SourceCache_Remove(); /// Provides SourceCache_RemoveMany. /// The result. [Benchmark] public int SourceCache_RemoveMany() { - using var cache = new SourceCache(x => x.Id); + using var cache = new SourceCache(static x => x.Id); cache.AddOrUpdate(_items); - cache.RemoveKeys(cache.Keys.Where(static key => key % ModuloTwoDivisor == 0)); + List keysToRemove = []; + foreach (var key in cache.Keys) + { + if (key % ModuloTwoDivisor == 0) + { + keysToRemove.Add(key); + } + } + + cache.RemoveKeys(keysToRemove); return cache.Count; } + /// Counts an explicitly enumerated sequence without adding LINQ overhead to a benchmark. + /// The sequence element type. + /// The items to count. + /// The number of enumerated items. + private static int CountItems(IEnumerable items) + { + var count = 0; + foreach (var item in items) + { + _ = item; + count++; + } + + return count; + } + /// Provides Item. /// The Id value. /// The Value. diff --git a/src/ReactiveList.Benchmarks/QuaternaryListBenchmarks.cs b/src/ReactiveList.Benchmarks/QuaternaryListBenchmarks.cs index 3c5bc12..daff2fa 100644 --- a/src/ReactiveList.Benchmarks/QuaternaryListBenchmarks.cs +++ b/src/ReactiveList.Benchmarks/QuaternaryListBenchmarks.cs @@ -8,40 +8,50 @@ namespace ReactiveList.Benchmarks; -/// Provides QuaternaryListBenchmarks. +/// Benchmarks operations against comparable list implementations. [MemoryDiagnoser] -public class QuaternaryListBenchmarks +public sealed class QuaternaryListBenchmarks { + /// Divides a count in half for partial-removal benchmarks. private const int HalfCountDivisor = 2; + /// Identifies the value used to probe a secondary index. private const int IndexedProbeItem = 4; + /// Specifies the minimum size needed to exercise the parallel add path. private const int MinimumLargeDatasetCount = 500; + /// Provides the divisor used by modulo-four operations. private const int ModuloFourDivisor = 4; + /// Provides the divisor used by modulo-three operations. private const int ModuloThreeDivisor = 3; + /// Provides the divisor used by modulo-two operations. private const int ModuloTwoDivisor = 2; + /// Provides the divisor used by modulo-five operations. private const int ModuloFiveDivisor = 5; + /// Controls how frequently items are removed in mixed-operation benchmarks. private const int PeriodicRemovalDivisor = 10; + /// Provides the multiplier used when benchmark values are updated. private const int ValueMultiplier = 2; + /// Stores the sequential input shared by list benchmarks. private int[] _data = []; - /// Gets or sets the item count. + /// Gets or sets the number of input items used by each benchmark. [Params(100, 1_000, 10_000)] public int Count { get; set; } - /// Provides Setup. + /// Creates the benchmark input data. [GlobalSetup] - public void Setup() => _data = Enumerable.Range(0, Count).ToArray(); + public void Setup() => _data = CreateSequentialData(0, Count); - /// Provides List_Add. - /// The result. + /// Benchmarks adding items individually to . + /// The result produced by the benchmark operation. [Benchmark] public int List_Add() { @@ -54,8 +64,8 @@ public int List_Add() return list.Count; } - /// Provides QuaternaryList_Add. - /// The result. + /// Benchmarks adding items individually to . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Add() { @@ -68,8 +78,8 @@ public int QuaternaryList_Add() return list.Count; } - /// Provides SourceList_Add. - /// The result. + /// Benchmarks adding items individually to . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Add() { @@ -82,18 +92,18 @@ public int SourceList_Add() return list.Count; } - /// Provides List_AddRange. - /// The result. + /// Benchmarks adding the input data to in one operation. + /// The result produced by the benchmark operation. [Benchmark] public int List_AddRange() { - var list = new List(); + var list = new List(Count); list.AddRange(_data); return list.Count; } - /// Provides QuaternaryList_AddRange. - /// The result. + /// Benchmarks adding the input data to in one operation. + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_AddRange() { @@ -102,8 +112,8 @@ public int QuaternaryList_AddRange() return list.Count; } - /// Provides SourceList_AddRange. - /// The result. + /// Benchmarks adding the input data to in one operation. + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_AddRange() { @@ -112,8 +122,8 @@ public int SourceList_AddRange() return list.Count; } - /// Provides List_RemoveRange. - /// The result. + /// Benchmarks removing the first half of a . + /// The result produced by the benchmark operation. [Benchmark] public int List_RemoveRange() { @@ -122,30 +132,30 @@ public int List_RemoveRange() return list.Count; } - /// Provides QuaternaryList_RemoveRange. - /// The result. + /// Benchmarks removing the first half of a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_RemoveRange() { using var list = new QuaternaryList(); list.AddRange(_data); - list.RemoveRange(_data.Take(Count / HalfCountDivisor)); + list.RemoveRange(CreateFirstHalfData()); return list.Count; } - /// Provides SourceList_RemoveRange. - /// The result. + /// Benchmarks removing the first half of a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_RemoveRange() { using var list = new SourceList(); list.AddRange(_data); - list.RemoveMany(_data.Take(Count / HalfCountDivisor)); + list.RemoveMany(CreateFirstHalfData()); return list.Count; } - /// Provides List_Clear. - /// The result. + /// Benchmarks clearing a . + /// The result produced by the benchmark operation. [Benchmark] public int List_Clear() { @@ -154,8 +164,8 @@ public int List_Clear() return list.Count; } - /// Provides QuaternaryList_Clear. - /// The result. + /// Benchmarks clearing a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Clear() { @@ -165,8 +175,8 @@ public int QuaternaryList_Clear() return list.Count; } - /// Provides SourceList_Clear. - /// The result. + /// Benchmarks clearing a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Clear() { @@ -176,8 +186,8 @@ public int SourceList_Clear() return list.Count; } - /// Provides List_Contains. - /// The result. + /// Benchmarks finding the final input item in a . + /// The result produced by the benchmark operation. [Benchmark] public bool List_Contains() { @@ -185,8 +195,8 @@ public bool List_Contains() return list.Contains(Count - 1); } - /// Provides QuaternaryList_Contains. - /// The result. + /// Benchmarks finding the final input item in a . + /// The result produced by the benchmark operation. [Benchmark] public bool QuaternaryList_Contains() { @@ -195,29 +205,37 @@ public bool QuaternaryList_Contains() return list.Contains(Count - 1); } - /// Provides SourceList_Contains. - /// The result. + /// Benchmarks finding the final input item in a . + /// The result produced by the benchmark operation. [Benchmark] public bool SourceList_Contains() { using var list = new SourceList(); list.AddRange(_data); - return list.Items.Contains(Count - 1); + foreach (var item in list.Items) + { + if (item == Count - 1) + { + return true; + } + } + + return false; } - /// Provides QuaternaryList_QueryIndex. - /// The result. + /// Benchmarks querying a secondary index in a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_QueryIndex() { using var list = new QuaternaryList(); list.AddIndex("Mod2", static x => x % ModuloTwoDivisor); list.AddRange(_data); - return list.GetItemsBySecondaryIndex("Mod2", 0).Count(); + return CountItems(list.GetItemsBySecondaryIndex("Mod2", 0)); } - /// Provides QuaternaryList_Stream_Add. - /// The result. + /// Benchmarks stream notifications generated by adding input data to a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Stream_Add() { @@ -228,8 +246,8 @@ public int QuaternaryList_Stream_Add() return events; } - /// Provides SourceList_Stream_Add. - /// The result. + /// Benchmarks stream notifications generated by adding input data to a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Stream_Add() { @@ -240,79 +258,65 @@ public int SourceList_Stream_Add() return events; } - /// Provides QuaternaryList_Edit. - /// The result. + /// Benchmarks replacing all items through . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Edit() { using var list = new QuaternaryList(); list.AddRange(_data); - list.Edit(innerList => - { - innerList.Clear(); - for (var i = 0; i < Count; i++) - { - innerList.Add(i * ValueMultiplier); - } - }); + list.Edit(ReplaceWithDoubledValues); return list.Count; } - /// Provides SourceList_Edit. - /// The result. + /// Benchmarks replacing all items through . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Edit() { using var list = new SourceList(); list.AddRange(_data); - list.Edit(innerList => - { - innerList.Clear(); - for (var i = 0; i < Count; i++) - { - innerList.Add(i * ValueMultiplier); - } - }); + list.Edit(ReplaceWithDoubledValues); return list.Count; } - /// Provides QuaternaryList_RemoveMany. - /// The result. + /// Benchmarks removing even values from a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_RemoveMany() { using var list = new QuaternaryList(); list.AddRange(_data); - list.RemoveMany(static x => x % ModuloTwoDivisor == 0); + _ = list.RemoveMany(static x => x % ModuloTwoDivisor == 0); return list.Count; } - /// Provides SourceList_RemoveMany. - /// The result. + /// Benchmarks removing even values from a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_RemoveMany() { using var list = new SourceList(); list.AddRange(_data); - list.RemoveMany(list.Items.Where(static x => x % ModuloTwoDivisor == 0)); + list.RemoveMany(FilterItems(list.Items, static x => x % ModuloTwoDivisor == 0)); return list.Count; } - /// Provides QuaternaryList_VersionTracking. - /// The result. + /// Benchmarks version tracking across add, remove, and clear operations. + /// The result produced by the benchmark operation. [Benchmark] public long QuaternaryList_VersionTracking() { using var list = new QuaternaryList(); var initialVersion = list.Version; list.AddRange(_data); - list.RemoveMany(static x => x % ModuloTwoDivisor == 0); + _ = list.RemoveMany(static x => x % ModuloTwoDivisor == 0); list.Clear(); return list.Version - initialVersion; } - /// Provides QuaternaryList_MultipleIndices. - /// The result. + /// Benchmarks querying several secondary indices in a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_MultipleIndices() { @@ -321,26 +325,26 @@ public int QuaternaryList_MultipleIndices() list.AddIndex("Mod3", static x => x % ModuloThreeDivisor); list.AddIndex("Mod5", static x => x % ModuloFiveDivisor); list.AddRange(_data); - return list.GetItemsBySecondaryIndex("Mod2", 0).Count() + - list.GetItemsBySecondaryIndex("Mod3", 0).Count() + - list.GetItemsBySecondaryIndex("Mod5", 0).Count(); + return CountItems(list.GetItemsBySecondaryIndex("Mod2", 0)) + + CountItems(list.GetItemsBySecondaryIndex("Mod3", 0)) + + CountItems(list.GetItemsBySecondaryIndex("Mod5", 0)); } - /// Provides QuaternaryList_ParallelAdd. - /// The result. + /// Benchmarks adding enough items to exercise parallel processing. + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_ParallelAdd() { using var list = new QuaternaryList(); // Large dataset to trigger parallel processing (threshold is 256) - var largeData = Enumerable.Range(0, Math.Max(Count, MinimumLargeDatasetCount)).ToArray(); + var largeData = CreateSequentialData(0, Math.Max(Count, MinimumLargeDatasetCount)); list.AddRange(largeData); return list.Count; } - /// Provides QuaternaryList_IterateAll. - /// The result. + /// Benchmarks iterating every item in a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_IterateAll() { @@ -355,8 +359,8 @@ public int QuaternaryList_IterateAll() return sum; } - /// Provides List_IterateAll. - /// The result. + /// Benchmarks iterating every item in a . + /// The result produced by the benchmark operation. [Benchmark] public int List_IterateAll() { @@ -370,8 +374,8 @@ public int List_IterateAll() return sum; } - /// Provides SourceList_IterateAll. - /// The result. + /// Benchmarks iterating every item in a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_IterateAll() { @@ -386,8 +390,8 @@ public int SourceList_IterateAll() return sum; } - /// Provides QuaternaryList_CopyTo. - /// The result. + /// Benchmarks copying a to an array. + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_CopyTo() { @@ -398,26 +402,26 @@ public int QuaternaryList_CopyTo() return buffer.Length; } - /// Provides QuaternaryList_ReplaceAll. - /// The result. + /// Benchmarks replacing all items in a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_ReplaceAll() { using var list = new QuaternaryList(); list.AddRange(_data); - var newData = Enumerable.Range(Count, Count).ToArray(); + var newData = CreateSequentialData(Count, Count); list.ReplaceAll(newData); return list.Count; } - /// Provides SourceList_ReplaceAll. - /// The result. + /// Benchmarks replacing all items in a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_ReplaceAll() { using var list = new SourceList(); list.AddRange(_data); - var newData = Enumerable.Range(Count, Count).ToArray(); + var newData = CreateSequentialData(Count, Count); list.Edit(innerList => { innerList.Clear(); @@ -426,22 +430,22 @@ public int SourceList_ReplaceAll() return list.Count; } - /// Provides List_Remove. - /// The result. + /// Benchmarks removing the first half of values individually from a . + /// The result produced by the benchmark operation. [Benchmark] public int List_Remove() { var list = new List(_data); for (var i = 0; i < Count / HalfCountDivisor; i++) { - list.Remove(i); + _ = list.Remove(i); } return list.Count; } - /// Provides QuaternaryList_Remove. - /// The result. + /// Benchmarks removing the first half of values individually from a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Remove() { @@ -449,14 +453,14 @@ public int QuaternaryList_Remove() list.AddRange(_data); for (var i = 0; i < Count / HalfCountDivisor; i++) { - list.Remove(i); + _ = list.Remove(i); } return list.Count; } - /// Provides SourceList_Remove. - /// The result. + /// Benchmarks removing the first half of values individually from a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Remove() { @@ -464,24 +468,24 @@ public int SourceList_Remove() list.AddRange(_data); for (var i = 0; i < Count / HalfCountDivisor; i++) { - list.Remove(i); + _ = list.Remove(i); } return list.Count; } - /// Provides List_RemoveAll. - /// The result. + /// Benchmarks removing even values from a . + /// The result produced by the benchmark operation. [Benchmark] public int List_RemoveAll() { var list = new List(_data); - list.RemoveAll(static x => x % ModuloTwoDivisor == 0); + _ = list.RemoveAll(static x => x % ModuloTwoDivisor == 0); return list.Count; } - /// Provides List_IndexerAccess. - /// The result. + /// Benchmarks indexed access to every item in a . + /// The result produced by the benchmark operation. [Benchmark] public int List_IndexerAccess() { @@ -495,8 +499,8 @@ public int List_IndexerAccess() return sum; } - /// Provides QuaternaryList_IndexerAccess. - /// The result. + /// Benchmarks indexed access to every item in a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_IndexerAccess() { @@ -511,20 +515,20 @@ public int QuaternaryList_IndexerAccess() return sum; } - /// Provides List_ReplaceAll. - /// The result. + /// Benchmarks replacing all items in a . + /// The result produced by the benchmark operation. [Benchmark] public int List_ReplaceAll() { var list = new List(_data); - var newData = Enumerable.Range(Count, Count).ToArray(); + var newData = CreateSequentialData(Count, Count); list.Clear(); list.AddRange(newData); return list.Count; } - /// Provides QuaternaryList_Stream_Remove. - /// The result. + /// Benchmarks stream notifications generated by removing half of a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_Stream_Remove() { @@ -532,12 +536,12 @@ public int QuaternaryList_Stream_Remove() list.AddRange(_data); var events = 0; using var sub = list.Stream.SubscribeObserver(_ => events++); - list.RemoveRange(_data.Take(Count / HalfCountDivisor)); + list.RemoveRange(CreateFirstHalfData()); return events; } - /// Provides SourceList_Stream_Remove. - /// The result. + /// Benchmarks stream notifications generated by removing half of a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_Stream_Remove() { @@ -545,12 +549,12 @@ public int SourceList_Stream_Remove() list.AddRange(_data); var events = 0; using var sub = list.Connect().SubscribeObserver(_ => events++); - list.RemoveMany(_data.Take(Count / HalfCountDivisor)); + list.RemoveMany(CreateFirstHalfData()); return events; } - /// Provides QuaternaryList_AddIndex. - /// The result. + /// Benchmarks adding a secondary index to a populated . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_AddIndex() { @@ -560,8 +564,8 @@ public int QuaternaryList_AddIndex() return list.Count; } - /// Provides QuaternaryList_ItemMatchesSecondaryIndex. - /// The result. + /// Benchmarks testing an item against a secondary index. + /// The result produced by the benchmark operation. [Benchmark] public bool QuaternaryList_ItemMatchesSecondaryIndex() { @@ -571,20 +575,20 @@ public bool QuaternaryList_ItemMatchesSecondaryIndex() return list.ItemMatchesSecondaryIndex("Mod2", IndexedProbeItem, 0); } - /// Provides QuaternaryList_IndexWithAddRemove. - /// The result. + /// Benchmarks an indexed list after additions and removals. + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_IndexWithAddRemove() { using var list = new QuaternaryList(); list.AddIndex("Mod2", static x => x % ModuloTwoDivisor); list.AddRange(_data); - list.RemoveMany(static x => x % ModuloFourDivisor == 0); - return list.GetItemsBySecondaryIndex("Mod2", 0).Count(); + _ = list.RemoveMany(static x => x % ModuloFourDivisor == 0); + return CountItems(list.GetItemsBySecondaryIndex("Mod2", 0)); } - /// Provides List_CopyTo. - /// The result. + /// Benchmarks copying a to an array. + /// The result produced by the benchmark operation. [Benchmark] public int List_CopyTo() { @@ -594,8 +598,8 @@ public int List_CopyTo() return buffer.Length; } - /// Provides List_Count. - /// The result. + /// Benchmarks retrieving . + /// The result produced by the benchmark operation. [Benchmark] public int List_Count() { @@ -603,61 +607,120 @@ public int List_Count() return list.Count; } - /// Provides QuaternaryList_Count. - /// The result. + /// Benchmarks retrieving the count of a . + /// The result produced by the benchmark operation. [Benchmark] - public int QuaternaryList_Count() - { - using var list = new QuaternaryList(); - list.AddRange(_data); - return list.Count; - } + public int QuaternaryList_Count() => QuaternaryList_AddRange(); - /// Provides SourceList_Count. - /// The result. + /// Benchmarks retrieving . + /// The result produced by the benchmark operation. [Benchmark] - public int SourceList_Count() - { - using var list = new SourceList(); - list.AddRange(_data); - return list.Count; - } + public int SourceList_Count() => SourceList_AddRange(); - /// Provides QuaternaryList_MixedOperations. - /// The result. + /// Benchmarks mixed add, remove, and predicate-removal operations on a . + /// The result produced by the benchmark operation. [Benchmark] public int QuaternaryList_MixedOperations() { using var list = new QuaternaryList(); list.AddRange(_data); list.Add(Count); - list.Remove(0); - list.RemoveMany(static x => x % PeriodicRemovalDivisor == 0); + _ = list.Remove(0); + _ = list.RemoveMany(static x => x % PeriodicRemovalDivisor == 0); return list.Count; } - /// Provides SourceList_MixedOperations. - /// The result. + /// Benchmarks mixed add, remove, and predicate-removal operations on a . + /// The result produced by the benchmark operation. [Benchmark] public int SourceList_MixedOperations() { using var list = new SourceList(); list.AddRange(_data); list.Add(Count); - list.Remove(0); - list.RemoveMany(list.Items.Where(static x => x % PeriodicRemovalDivisor == 0)); + _ = list.Remove(0); + list.RemoveMany(FilterItems(list.Items, static x => x % PeriodicRemovalDivisor == 0)); return list.Count; } - /// Provides List_MixedOperations. - /// The result. + /// Benchmarks mixed add, remove, and predicate-removal operations on a . + /// The result produced by the benchmark operation. [Benchmark] public int List_MixedOperations() { var list = new List(_data); list.Add(Count); - list.Remove(0); - list.RemoveAll(static x => x % PeriodicRemovalDivisor == 0); + _ = list.Remove(0); + _ = list.RemoveAll(static x => x % PeriodicRemovalDivisor == 0); return list.Count; } + + /// Creates a sequential array without introducing LINQ overhead. + /// The first value in the sequence. + /// The number of values to create. + /// The generated sequence. + private static int[] CreateSequentialData(int start, int count) + { + var data = new int[count]; + for (var i = 0; i < data.Length; i++) + { + data[i] = start + i; + } + + return data; + } + + /// Materializes values that satisfy a predicate without introducing LINQ overhead. + /// The values to inspect. + /// The predicate used to select values. + /// The selected values. + private static List FilterItems(IEnumerable source, Func predicate) + { + var filteredItems = new List(); + foreach (var item in source) + { + if (predicate(item)) + { + filteredItems.Add(item); + } + } + + return filteredItems; + } + + /// Counts an explicitly enumerated sequence without introducing LINQ overhead. + /// The sequence element type. + /// The values to enumerate. + /// The number of enumerated values. + private static int CountItems(IEnumerable items) + { + var count = 0; + foreach (var _ in items) + { + count++; + } + + return count; + } + + /// Copies the first half of the configured input for partial-removal benchmarks. + /// The copied first-half input. + private int[] CreateFirstHalfData() + { + var count = Count / HalfCountDivisor; + var data = new int[count]; + Array.Copy(_data, data, count); + return data; + } + + /// Replaces a collection with the configured number of doubled values. + /// The collection to replace. + private void ReplaceWithDoubledValues(ICollection items) + { + items.Clear(); + for (var i = 0; i < Count; i++) + { + items.Add(i * ValueMultiplier); + } + } } diff --git a/src/ReactiveList.Test/Assert.cs b/src/ReactiveList.Test/Assert.cs index da0838a..5730863 100644 --- a/src/ReactiveList.Test/Assert.cs +++ b/src/ReactiveList.Test/Assert.cs @@ -2,10 +2,11 @@ // Chris Pulman and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; +global using CP.Primitives.Internal; +global using ReactiveUI.Primitives; +global using ReactiveUI.Primitives.Concurrency; +global using ReactiveUI.Primitives.Signals; + using TUnit.Assertions.Exceptions; namespace ReactiveList.Test; @@ -17,7 +18,9 @@ internal static class Assert /// The T type. /// The collection value. /// The assertion value. - public static void All(IEnumerable collection, Action assertion) + internal static void All( + System.Collections.Generic.IEnumerable collection, + System.Action assertion) { foreach (var item in collection) { @@ -29,11 +32,16 @@ public static void All(IEnumerable collection, Action assertion) /// The T type. /// The expected value. /// The collection value. - public static void Contains(T expected, IEnumerable collection) + internal static void Contains( + T expected, + System.Collections.Generic.IEnumerable collection) { - if (collection.Contains(expected)) + foreach (var item in collection) { - return; + if (System.Collections.Generic.EqualityComparer.Default.Equals(item, expected)) + { + return; + } } Fail($"Expected collection to contain {expected}."); @@ -43,11 +51,16 @@ public static void Contains(T expected, IEnumerable collection) /// The T type. /// The collection value. /// The predicate value. - public static void Contains(IEnumerable collection, Predicate predicate) + internal static void Contains( + System.Collections.Generic.IEnumerable collection, + System.Predicate predicate) { - if (collection.Any(item => predicate(item))) + foreach (var item in collection) { - return; + if (predicate(item)) + { + return; + } } Fail("Expected collection to contain a matching item."); @@ -57,33 +70,39 @@ public static void Contains(IEnumerable collection, Predicate predicate /// The T type. /// The expected value. /// The collection value. - public static void DoesNotContain(T expected, IEnumerable collection) + internal static void DoesNotContain( + T expected, + System.Collections.Generic.IEnumerable collection) { - if (!collection.Contains(expected)) + foreach (var item in collection) { - return; + if (System.Collections.Generic.EqualityComparer.Default.Equals(item, expected)) + { + Fail($"Expected collection not to contain {expected}."); + } } - - Fail($"Expected collection not to contain {expected}."); } /// Provides DoesNotContain. /// The T type. /// The collection value. /// The predicate value. - public static void DoesNotContain(IEnumerable collection, Predicate predicate) + internal static void DoesNotContain( + System.Collections.Generic.IEnumerable collection, + System.Predicate predicate) { - if (!collection.Any(item => predicate(item))) + foreach (var item in collection) { - return; + if (predicate(item)) + { + Fail("Expected collection not to contain a matching item."); + } } - - Fail("Expected collection not to contain a matching item."); } /// Provides Empty. /// The collection value. - public static void Empty(IEnumerable collection) + internal static void Empty(System.Collections.IEnumerable collection) { if (!collection.GetEnumerator().MoveNext()) { @@ -97,9 +116,9 @@ public static void Empty(IEnumerable collection) /// The T type. /// The expected value. /// The actual value. - public static void Equal(T expected, T actual) + internal static void Equal(T expected, T actual) { - if (EqualityComparer.Default.Equals(expected, actual)) + if (System.Collections.Generic.EqualityComparer.Default.Equals(expected, actual)) { return; } @@ -109,7 +128,7 @@ public static void Equal(T expected, T actual) /// Provides False. /// The condition value. - public static void False(bool condition) + internal static void False(bool condition) { if (!condition) { @@ -123,12 +142,12 @@ public static void False(bool condition) /// The T type. /// The result. /// The value. - public static T NotNull(T? value) + internal static T NotNull(T? value) { if (value is null) { Fail("Expected value not to be null."); - throw new InvalidOperationException("The assertion failure did not throw."); + throw new System.InvalidOperationException("The assertion failure did not throw."); } return value; @@ -138,7 +157,7 @@ public static T NotNull(T? value) /// The T type. /// The result. /// The collection value. - public static T Single(IEnumerable collection) + internal static T Single(System.Collections.Generic.IEnumerable collection) { using var enumerator = collection.GetEnumerator(); if (!enumerator.MoveNext()) @@ -159,8 +178,8 @@ public static T Single(IEnumerable collection) /// The T type. /// The result. /// The action value. - public static T Throws(Action action) - where T : Exception + internal static T Throws(System.Action action) + where T : System.Exception { try { @@ -172,12 +191,12 @@ public static T Throws(Action action) } Fail($"Expected exception of type {typeof(T)}."); - throw new InvalidOperationException("The assertion failure did not throw."); + throw new System.InvalidOperationException("The assertion failure did not throw."); } /// Provides True. /// The condition value. - public static void True(bool condition) + internal static void True(bool condition) { if (condition) { diff --git a/src/ReactiveList.Test/AssertionExtensions.cs b/src/ReactiveList.Test/AssertionExtensions.cs index d7945f7..8b3be2e 100644 --- a/src/ReactiveList.Test/AssertionExtensions.cs +++ b/src/ReactiveList.Test/AssertionExtensions.cs @@ -6,7 +6,6 @@ using System.Collections; using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Threading.Tasks; using CP.Primitives.Core; using TUnit.Assertions.Exceptions; @@ -22,7 +21,7 @@ internal static class AssertionExtensions { /// Creates assertions for the action. /// The action assertions. - public ActionAssertions Should() => new(subject); + internal ActionAssertions Should() => new(subject); } /// Provides change-set assertions. @@ -32,7 +31,7 @@ internal static class AssertionExtensions { /// Creates assertions for the change set. /// The enumerable assertions. - public EnumerableAssertions> Should() => new(subject); + internal EnumerableAssertions> Should() => new(subject); } /// Provides function assertions. @@ -42,7 +41,7 @@ internal static class AssertionExtensions { /// Creates assertions for the function. /// The action assertions. - public ActionAssertions Should() => new(() => _ = subject()); + internal ActionAssertions Should() => new(() => _ = subject()); } /// Provides task-producing function assertions. @@ -51,7 +50,7 @@ internal static class AssertionExtensions { /// Creates assertions for the task-producing function. /// The function assertions. - public FuncTaskAssertions Should() => new(subject); + internal FuncTaskAssertions Should() => new(subject); } /// Provides enumerable assertions. @@ -61,7 +60,7 @@ internal static class AssertionExtensions { /// Creates assertions for the enumerable. /// The enumerable assertions. - public EnumerableAssertions Should() => new(subject); + internal EnumerableAssertions Should() => new(subject); } /// Provides value-type object assertions. @@ -72,7 +71,7 @@ internal static class AssertionExtensions { /// Creates assertions for the value. /// The object assertions. - public ObjectAssertions Should() => new(subject); + internal ObjectAssertions Should() => new(subject); } /// Provides Boolean assertions. @@ -81,7 +80,7 @@ internal static class AssertionExtensions { /// Creates assertions for the Boolean value. /// The Boolean assertions. - public BooleanAssertions Should() => new(subject); + internal BooleanAssertions Should() => new(subject); } /// Provides object assertions. @@ -90,7 +89,7 @@ internal static class AssertionExtensions { /// Creates assertions for the object. /// The object assertions. - public ObjectAssertions Should() => new(subject); + internal ObjectAssertions Should() => new(subject); } /// Provides string assertions. @@ -99,7 +98,7 @@ internal static class AssertionExtensions { /// Creates assertions for the string. /// The string assertions. - public StringAssertions Should() => new(subject); + internal StringAssertions Should() => new(subject); } /// Provides shared assertion helper methods. @@ -157,7 +156,7 @@ internal static void AssertEquivalentSequence(IReadOnlyList actual, IRe return; } - var unmatched = expected.ToList(); + var unmatched = new List(expected); foreach (var item in actual) { var index = unmatched.FindIndex(expectedItem => Equals(item, expectedItem)); @@ -182,7 +181,13 @@ internal static bool TryGetEnumerable(object? value, out IReadOnlyList return false; } - items = enumerable.Cast().ToArray(); + var snapshot = new List(); + foreach (var item in enumerable) + { + snapshot.Add(item); + } + + items = snapshot; return true; } @@ -197,11 +202,13 @@ internal static bool AreEqual(object? actual, object? expected) return true; } - return actual is not IConvertible actualConvertible || - expected is not IConvertible expectedConvertible || - !IsNumeric(actualConvertible.GetTypeCode()) || - !IsNumeric(expectedConvertible.GetTypeCode()) ? false : Convert.ToDecimal(actualConvertible, CultureInfo.InvariantCulture) == - Convert.ToDecimal(expectedConvertible, CultureInfo.InvariantCulture); + return actual is not IConvertible actualConvertible + || expected is not IConvertible expectedConvertible + || !IsNumeric(actualConvertible.GetTypeCode()) + || !IsNumeric(expectedConvertible.GetTypeCode()) + ? false + : Convert.ToDecimal(actualConvertible, CultureInfo.InvariantCulture) + == Convert.ToDecimal(expectedConvertible, CultureInfo.InvariantCulture); } /// Compares two values. @@ -236,7 +243,7 @@ internal static string Because(string because, object[] args) return string.Empty; } - return " because " + (args.Length == 0 ? because : string.Format(because, args)); + return $" because {(args.Length == 0 ? because : string.Format(because, args))}"; } /// Determines whether the type code represents a numeric value. @@ -249,11 +256,11 @@ internal static string Because(string because, object[] args) internal sealed class EquivalencyAssertionOptions { /// Gets StrictOrdering. - public bool StrictOrdering { get; private set; } + internal bool StrictOrdering { get; private set; } /// Provides WithStrictOrdering. /// The result. - public EquivalencyAssertionOptions WithStrictOrdering() + internal EquivalencyAssertionOptions WithStrictOrdering() { StrictOrdering = true; return this; @@ -266,24 +273,25 @@ internal sealed class AndWhichConstraint { /// Initializes a new instance of the AndWhichConstraint class. /// The which value. - public AndWhichConstraint(T which) => Which = which; + internal AndWhichConstraint(T which) => Which = which; /// Gets Which. - public T Which { get; } + internal T Which { get; } /// Gets And. - public AndWhichConstraint And => this; + internal AndWhichConstraint And => this; } /// Provides ObjectAssertions. /// The TSubject type. internal sealed class ObjectAssertions { + /// The assertion subject. private readonly TSubject _subject; /// Initializes a new instance of the ObjectAssertions class. /// The subject value. - public ObjectAssertions(TSubject subject) => _subject = subject; + internal ObjectAssertions(TSubject subject) => _subject = subject; /// Provides Be. /// The TExpected type. @@ -291,7 +299,7 @@ internal sealed class ObjectAssertions /// The because value. /// The result. /// The becauseArgs value. - public ObjectAssertions Be(TExpected expected, string because = "", params object[] becauseArgs) + internal ObjectAssertions Be(TExpected expected, string because = "", params object[] becauseArgs) { if (!AssertionHelpers.AreEqual(_subject, expected)) { @@ -305,7 +313,7 @@ public ObjectAssertions Be(TExpected expected, string becau /// The TExpected type. /// The expected value. /// The result. - public ObjectAssertions NotBe(TExpected expected) + internal ObjectAssertions NotBe(TExpected expected) { if (Equals(_subject, expected)) { @@ -318,7 +326,7 @@ public ObjectAssertions NotBe(TExpected expected) /// Provides BeSameAs. /// The expected value. /// The result. - public ObjectAssertions BeSameAs(object? expected) + internal ObjectAssertions BeSameAs(object? expected) { if (!ReferenceEquals(_subject, expected)) { @@ -330,7 +338,7 @@ public ObjectAssertions BeSameAs(object? expected) /// Provides BeNull. /// The result. - public ObjectAssertions BeNull() + internal ObjectAssertions BeNull() { if (_subject is not null) { @@ -342,7 +350,7 @@ public ObjectAssertions BeNull() /// Provides NotBeNull. /// The result. - public ObjectAssertions NotBeNull() + internal ObjectAssertions NotBeNull() { if (_subject is null) { @@ -355,11 +363,11 @@ public ObjectAssertions NotBeNull() /// Provides BeOfType. /// The result. /// The TExpected type. - public AndWhichConstraint BeOfType() + internal AndWhichConstraint BeOfType() { if (_subject is TExpected expected) { - return new AndWhichConstraint(expected); + return new(expected); } AssertionHelpers.Fail($"Expected value to be of type {typeof(TExpected).FullName}, but found {_subject?.GetType().FullName ?? AssertionHelpers.NullDisplayValue}."); @@ -369,11 +377,11 @@ public AndWhichConstraint BeOfType() /// Provides BeAssignableTo. /// The result. /// The TExpected type. - public AndWhichConstraint BeAssignableTo() + internal AndWhichConstraint BeAssignableTo() { if (_subject is TExpected expected) { - return new AndWhichConstraint(expected); + return new(expected); } AssertionHelpers.Fail($"Expected value to be assignable to {typeof(TExpected).FullName}, but found {_subject?.GetType().FullName ?? AssertionHelpers.NullDisplayValue}."); @@ -385,7 +393,7 @@ public AndWhichConstraint BeAssignableTo() /// The expected value. /// The result. /// The configure value. - public ObjectAssertions BeEquivalentTo( + internal ObjectAssertions BeEquivalentTo( TExpected expected, Func? configure = null) { @@ -407,7 +415,7 @@ public ObjectAssertions BeEquivalentTo( /// Provides HaveCount. /// The expected value. /// The result. - public ObjectAssertions HaveCount(int expected) + internal ObjectAssertions HaveCount(int expected) { var actual = SnapshotEnumerable().Count; if (actual != expected) @@ -420,7 +428,7 @@ public ObjectAssertions HaveCount(int expected) /// Provides ContainSingle. /// The result. - public AndWhichConstraint ContainSingle() + internal AndWhichConstraint ContainSingle() { var actual = SnapshotEnumerable(); if (actual.Count != 1) @@ -428,14 +436,14 @@ public ObjectAssertions HaveCount(int expected) AssertionHelpers.Fail($"Expected collection to contain a single item, but found {actual.Count}."); } - return new AndWhichConstraint(actual[0]); + return new(actual[0]); } /// Provides BeGreaterThan. /// The TExpected type. /// The result. /// The expected value. - public ObjectAssertions BeGreaterThan(TExpected expected) + internal ObjectAssertions BeGreaterThan(TExpected expected) { if (AssertionHelpers.Compare(_subject, expected) <= 0) { @@ -449,7 +457,7 @@ public ObjectAssertions BeGreaterThan(TExpected expected) /// The TExpected type. /// The result. /// The expected value. - public ObjectAssertions BeGreaterThanOrEqualTo(TExpected expected) + internal ObjectAssertions BeGreaterThanOrEqualTo(TExpected expected) { if (AssertionHelpers.Compare(_subject, expected) < 0) { @@ -463,7 +471,7 @@ public ObjectAssertions BeGreaterThanOrEqualTo(TExpected ex /// The TExpected type. /// The result. /// The expected value. - public ObjectAssertions BeLessThanOrEqualTo(TExpected expected) + internal ObjectAssertions BeLessThanOrEqualTo(TExpected expected) { if (AssertionHelpers.Compare(_subject, expected) > 0) { @@ -478,7 +486,7 @@ public ObjectAssertions BeLessThanOrEqualTo(TExpected expec /// The minimum value. /// The maximum value. /// The result. - public ObjectAssertions BeInRange(TExpected minimum, TExpected maximum) + internal ObjectAssertions BeInRange(TExpected minimum, TExpected maximum) { if (AssertionHelpers.Compare(_subject, minimum) < 0 || AssertionHelpers.Compare(_subject, maximum) > 0) { @@ -504,18 +512,19 @@ public ObjectAssertions BeInRange(TExpected minimum, TExpec /// Provides BooleanAssertions. internal sealed class BooleanAssertions { + /// The assertion subject. private readonly bool _subject; /// Initializes a new instance of the class. /// The subject value. - public BooleanAssertions(bool subject) => _subject = subject; + internal BooleanAssertions(bool subject) => _subject = subject; /// Provides Be. /// The expected value. /// The because value. /// The becauseArgs value. /// The result. - public BooleanAssertions Be(bool expected, string because = "", params object[] becauseArgs) + internal BooleanAssertions Be(bool expected, string because = "", params object[] becauseArgs) { if (_subject != expected) { @@ -529,33 +538,34 @@ public BooleanAssertions Be(bool expected, string because = "", params object[] /// The because value. /// The becauseArgs value. /// The result. - public BooleanAssertions BeTrue(string because = "", params object[] becauseArgs) => Be(true, because, becauseArgs); + internal BooleanAssertions BeTrue(string because = "", params object[] becauseArgs) => Be(true, because, becauseArgs); /// Provides BeFalse. /// The because value. /// The becauseArgs value. /// The result. - public BooleanAssertions BeFalse(string because = "", params object[] becauseArgs) => Be(false, because, becauseArgs); + internal BooleanAssertions BeFalse(string because = "", params object[] becauseArgs) => Be(false, because, becauseArgs); } /// Provides StringAssertions. internal sealed class StringAssertions { + /// The assertion subject. private readonly string? _subject; /// Initializes a new instance of the class. /// The subject value. - public StringAssertions(string? subject) => _subject = subject; + internal StringAssertions(string? subject) => _subject = subject; /// Gets And. - public StringAssertions And => this; + internal StringAssertions And => this; /// Provides Be. /// The expected value. /// The because value. /// The becauseArgs value. /// The result. - public StringAssertions Be(string? expected, string because = "", params object[] becauseArgs) + internal StringAssertions Be(string? expected, string because = "", params object[] becauseArgs) { if (!string.Equals(_subject, expected, StringComparison.Ordinal)) { @@ -567,7 +577,7 @@ public StringAssertions Be(string? expected, string because = "", params object[ /// Provides NotBeNullOrEmpty. /// The result. - public StringAssertions NotBeNullOrEmpty() + internal StringAssertions NotBeNullOrEmpty() { if (string.IsNullOrEmpty(_subject)) { @@ -579,7 +589,7 @@ public StringAssertions NotBeNullOrEmpty() /// Provides BeNull. /// The result. - public StringAssertions BeNull() + internal StringAssertions BeNull() { if (_subject is not null) { @@ -591,7 +601,7 @@ public StringAssertions BeNull() /// Provides NotBeNull. /// The result. - public StringAssertions NotBeNull() + internal StringAssertions NotBeNull() { if (_subject is null) { @@ -603,7 +613,7 @@ public StringAssertions NotBeNull() /// Provides BeEmpty. /// The result. - public StringAssertions BeEmpty() + internal StringAssertions BeEmpty() { if (_subject?.Length > 0) { @@ -615,7 +625,7 @@ public StringAssertions BeEmpty() /// Provides NotBeEmpty. /// The result. - public StringAssertions NotBeEmpty() + internal StringAssertions NotBeEmpty() { if (_subject?.Length == 0) { @@ -628,7 +638,7 @@ public StringAssertions NotBeEmpty() /// Provides Contain. /// The expected value. /// The result. - public StringAssertions Contain(string expected) + internal StringAssertions Contain(string expected) { if (_subject?.Contains(expected, StringComparison.Ordinal) != true) { @@ -641,7 +651,7 @@ public StringAssertions Contain(string expected) /// Provides NotContain. /// The expected value. /// The result. - public StringAssertions NotContain(string expected) + internal StringAssertions NotContain(string expected) { if (_subject?.Contains(expected, StringComparison.Ordinal) == true) { @@ -654,7 +664,7 @@ public StringAssertions NotContain(string expected) /// Provides StartWith. /// The expected value. /// The result. - public StringAssertions StartWith(string expected) + internal StringAssertions StartWith(string expected) { if (_subject?.StartsWith(expected, StringComparison.Ordinal) != true) { @@ -667,7 +677,7 @@ public StringAssertions StartWith(string expected) /// Provides EndWith. /// The expected value. /// The result. - public StringAssertions EndWith(string expected) + internal StringAssertions EndWith(string expected) { if (_subject?.EndsWith(expected, StringComparison.Ordinal) != true) { @@ -682,19 +692,20 @@ public StringAssertions EndWith(string expected) /// The TItem type. internal sealed class EnumerableAssertions { + /// The assertion subject. private readonly IEnumerable? _subject; /// Initializes a new instance of the EnumerableAssertions class. /// The subject value. - public EnumerableAssertions(IEnumerable? subject) => _subject = subject; + internal EnumerableAssertions(IEnumerable? subject) => _subject = subject; /// Gets And. - public EnumerableAssertions And => this; + internal EnumerableAssertions And => this; /// Provides BeSameAs. /// The expected value. /// The result. - public EnumerableAssertions BeSameAs(object? expected) + internal EnumerableAssertions BeSameAs(object? expected) { if (!ReferenceEquals(_subject, expected)) { @@ -708,35 +719,41 @@ public EnumerableAssertions BeSameAs(object? expected) /// The expected value. /// The configure value. /// The result. - public EnumerableAssertions BeEquivalentTo( + internal EnumerableAssertions BeEquivalentTo( IEnumerable expected, Func? configure = null) { var options = AssertionHelpers.ApplyOptions(configure); - AssertionHelpers.AssertEquivalentSequence(SnapshotObjects(), expected.Cast().ToArray(), options.StrictOrdering); + var expectedItems = new List(); + foreach (var item in expected) + { + expectedItems.Add(item); + } + + AssertionHelpers.AssertEquivalentSequence(SnapshotObjects(), expectedItems, options.StrictOrdering); return this; } /// Provides Equal. /// The expected value. /// The result. - public EnumerableAssertions Equal(params TItem[] expected) => Equal((IEnumerable)expected); + internal EnumerableAssertions Equal(params TItem[] expected) => Equal((IEnumerable)expected); /// Provides Equal. /// The expected value. /// The result. - public EnumerableAssertions Equal(IEnumerable expected) + internal EnumerableAssertions Equal(IEnumerable expected) { var actual = Snapshot(); - var expectedItems = expected.ToArray(); - if (actual.Count != expectedItems.Length) + var expectedItems = new List(expected); + if (actual.Count != expectedItems.Count) { - AssertionHelpers.Fail($"Expected {expectedItems.Length} item(s), but found {actual.Count} item(s)."); + AssertionHelpers.Fail($"Expected {expectedItems.Count} item(s), but found {actual.Count} item(s)."); } for (var i = 0; i < actual.Count; i++) { - if (!Equals(actual[i], expectedItems[i])) + if (!EqualityComparer.Default.Equals(actual[i], expectedItems[i])) { AssertionHelpers.Fail($"Expected item at index {i} to be {AssertionHelpers.Format(expectedItems[i])}, but found {AssertionHelpers.Format(actual[i])}."); } @@ -748,11 +765,11 @@ public EnumerableAssertions Equal(IEnumerable expected) /// Provides AllBeEquivalentTo. /// The expected value. /// The result. - public EnumerableAssertions AllBeEquivalentTo(TItem expected) + internal EnumerableAssertions AllBeEquivalentTo(TItem expected) { foreach (var item in Snapshot()) { - if (!Equals(item, expected)) + if (!EqualityComparer.Default.Equals(item, expected)) { AssertionHelpers.Fail($"Expected all items to be {AssertionHelpers.Format(expected)}, but found {AssertionHelpers.Format(item)}."); } @@ -763,7 +780,7 @@ public EnumerableAssertions AllBeEquivalentTo(TItem expected) /// Provides BeEmpty. /// The result. - public EnumerableAssertions BeEmpty() + internal EnumerableAssertions BeEmpty() { if (Snapshot().Count != 0) { @@ -775,7 +792,7 @@ public EnumerableAssertions BeEmpty() /// Provides NotBeEmpty. /// The result. - public EnumerableAssertions NotBeEmpty() + internal EnumerableAssertions NotBeEmpty() { if (Snapshot().Count == 0) { @@ -787,7 +804,7 @@ public EnumerableAssertions NotBeEmpty() /// Provides NotBeNull. /// The result. - public EnumerableAssertions NotBeNull() + internal EnumerableAssertions NotBeNull() { if (_subject is null) { @@ -801,7 +818,7 @@ public EnumerableAssertions NotBeNull() /// Provides HaveCount. /// The expected value. /// The result. - public EnumerableAssertions HaveCount(int expected) + internal EnumerableAssertions HaveCount(int expected) { var actual = Count(); if (actual != expected) @@ -815,7 +832,7 @@ public EnumerableAssertions HaveCount(int expected) /// Provides HaveCountGreaterThan. /// The expected value. /// The result. - public EnumerableAssertions HaveCountGreaterThan(int expected) + internal EnumerableAssertions HaveCountGreaterThan(int expected) { var actual = Count(); if (actual <= expected) @@ -829,7 +846,7 @@ public EnumerableAssertions HaveCountGreaterThan(int expected) /// Provides HaveCountGreaterThanOrEqualTo. /// The expected value. /// The result. - public EnumerableAssertions HaveCountGreaterThanOrEqualTo(int expected) + internal EnumerableAssertions HaveCountGreaterThanOrEqualTo(int expected) { var actual = Count(); if (actual < expected) @@ -843,7 +860,7 @@ public EnumerableAssertions HaveCountGreaterThanOrEqualTo(int expected) /// Provides Contain. /// The expected value. /// The result. - public EnumerableAssertions Contain(TItem expected) + internal EnumerableAssertions Contain(TItem expected) { if (!Snapshot().Contains(expected)) { @@ -856,7 +873,7 @@ public EnumerableAssertions Contain(TItem expected) /// Provides Contain. /// The expected value. /// The result. - public EnumerableAssertions Contain(IEnumerable expected) + internal EnumerableAssertions Contain(IEnumerable expected) { var actual = Snapshot(); foreach (var expectedItem in expected) @@ -873,9 +890,9 @@ public EnumerableAssertions Contain(IEnumerable expected) /// Provides Contain. /// The predicate value. /// The result. - public EnumerableAssertions Contain(Func predicate) + internal EnumerableAssertions Contain(Func predicate) { - if (!Snapshot().Any(predicate)) + if (!Snapshot().Exists(item => predicate(item))) { AssertionHelpers.Fail("Expected collection to contain a matching item."); } @@ -886,7 +903,7 @@ public EnumerableAssertions Contain(Func predicate) /// Provides NotContain. /// The expected value. /// The result. - public EnumerableAssertions NotContain(TItem expected) + internal EnumerableAssertions NotContain(TItem expected) { if (Snapshot().Contains(expected)) { @@ -899,9 +916,9 @@ public EnumerableAssertions NotContain(TItem expected) /// Provides NotContain. /// The predicate value. /// The result. - public EnumerableAssertions NotContain(Func predicate) + internal EnumerableAssertions NotContain(Func predicate) { - if (Snapshot().Any(predicate)) + if (Snapshot().Exists(item => predicate(item))) { AssertionHelpers.Fail("Expected collection not to contain a matching item."); } @@ -911,7 +928,7 @@ public EnumerableAssertions NotContain(Func predicate) /// Provides ContainSingle. /// The result. - public AndWhichConstraint ContainSingle() + internal AndWhichConstraint ContainSingle() { var actual = Snapshot(); if (actual.Count != 1) @@ -919,13 +936,13 @@ public AndWhichConstraint ContainSingle() AssertionHelpers.Fail($"Expected collection to contain a single item, but found {actual.Count}."); } - return new AndWhichConstraint(actual[0]); + return new(actual[0]); } /// Provides ContainInOrder. /// The expected value. /// The result. - public EnumerableAssertions ContainInOrder(params TItem[] expected) + internal EnumerableAssertions ContainInOrder(params TItem[] expected) { var actual = Snapshot(); var searchIndex = 0; @@ -934,7 +951,9 @@ public EnumerableAssertions ContainInOrder(params TItem[] expected) var found = false; while (searchIndex < actual.Count) { - if (Equals(actual[searchIndex++], expectedItem)) + var actualItem = actual[searchIndex]; + searchIndex++; + if (EqualityComparer.Default.Equals(actualItem, expectedItem)) { found = true; break; @@ -954,7 +973,7 @@ public EnumerableAssertions ContainInOrder(params TItem[] expected) /// The TKey type. /// The result. /// The expected value. - public EnumerableAssertions ContainKey(TKey expected) + internal EnumerableAssertions ContainKey(TKey expected) { if (_subject is IDictionary dictionary) { @@ -967,7 +986,20 @@ public EnumerableAssertions ContainKey(TKey expected) } var keyProperty = typeof(TItem).GetProperty("Key"); - if (keyProperty is null || !Snapshot().Any(item => Equals(keyProperty.GetValue(item), expected))) + var containsKey = false; + if (keyProperty is not null) + { + foreach (var item in Snapshot()) + { + if (Equals(keyProperty.GetValue(item), expected)) + { + containsKey = true; + break; + } + } + } + + if (!containsKey) { AssertionHelpers.Fail($"Expected dictionary to contain key {AssertionHelpers.Format(expected)}."); } @@ -977,7 +1009,7 @@ public EnumerableAssertions ContainKey(TKey expected) /// Provides BeInAscendingOrder. /// The result. - public EnumerableAssertions BeInAscendingOrder() + internal EnumerableAssertions BeInAscendingOrder() { var actual = Snapshot(); var comparer = Comparer.Default; @@ -995,10 +1027,10 @@ public EnumerableAssertions BeInAscendingOrder() /// Provides StartWith. /// The expected value. /// The result. - public EnumerableAssertions StartWith(TItem expected) + internal EnumerableAssertions StartWith(TItem expected) { var actual = Snapshot(); - if (actual.Count == 0 || !Equals(actual[0], expected)) + if (actual.Count == 0 || !EqualityComparer.Default.Equals(actual[0], expected)) { AssertionHelpers.Fail($"Expected collection to start with {AssertionHelpers.Format(expected)}."); } @@ -1009,10 +1041,10 @@ public EnumerableAssertions StartWith(TItem expected) /// Provides EndWith. /// The expected value. /// The result. - public EnumerableAssertions EndWith(TItem expected) + internal EnumerableAssertions EndWith(TItem expected) { var actual = Snapshot(); - if (actual.Count == 0 || !Equals(actual[actual.Count - 1], expected)) + if (actual.Count == 0 || !EqualityComparer.Default.Equals(actual[actual.Count - 1], expected)) { AssertionHelpers.Fail($"Expected collection to end with {AssertionHelpers.Format(expected)}."); } @@ -1023,11 +1055,11 @@ public EnumerableAssertions EndWith(TItem expected) /// Provides BeOfType. /// The result. /// The TExpected type. - public AndWhichConstraint BeOfType() + internal AndWhichConstraint BeOfType() { if (_subject is TExpected expected) { - return new AndWhichConstraint(expected); + return new(expected); } AssertionHelpers.Fail($"Expected value to be of type {typeof(TExpected).FullName}, but found {_subject?.GetType().FullName ?? AssertionHelpers.NullDisplayValue}."); @@ -1037,11 +1069,11 @@ public AndWhichConstraint BeOfType() /// Provides BeAssignableTo. /// The result. /// The TExpected type. - public AndWhichConstraint BeAssignableTo() + internal AndWhichConstraint BeAssignableTo() { if (_subject is TExpected expected) { - return new AndWhichConstraint(expected); + return new(expected); } AssertionHelpers.Fail($"Expected value to be assignable to {typeof(TExpected).FullName}, but found {_subject?.GetType().FullName ?? AssertionHelpers.NullDisplayValue}."); @@ -1051,10 +1083,10 @@ public AndWhichConstraint BeAssignableTo() /// Provides Be. /// The expected value. /// The result. - public EnumerableAssertions Be(TItem expected) + internal EnumerableAssertions Be(TItem expected) { var actual = Snapshot(); - if (actual.Count != 1 || !Equals(actual[0], expected)) + if (actual.Count != 1 || !EqualityComparer.Default.Equals(actual[0], expected)) { AssertionHelpers.Fail($"Expected single collection item {AssertionHelpers.Format(expected)}."); } @@ -1077,7 +1109,18 @@ private int Count() return collection.Count; } - return _subject is IReadOnlyCollection readOnlyCollection ? readOnlyCollection.Count : _subject.Count(); + if (_subject is IReadOnlyCollection readOnlyCollection) + { + return readOnlyCollection.Count; + } + + var count = 0; + foreach (var _ in _subject) + { + count++; + } + + return count; } /// Provides Snapshot. @@ -1090,27 +1133,38 @@ private List Snapshot() throw new InvalidOperationException(AssertionHelpers.AssertionFailureDidNotThrow); } - return _subject.ToList(); + return new(_subject); } /// Provides SnapshotObjects. /// The result. - private object?[] SnapshotObjects() => Snapshot().Cast().ToArray(); + private object?[] SnapshotObjects() + { + var snapshot = Snapshot(); + var objects = new object?[snapshot.Count]; + for (var i = 0; i < snapshot.Count; i++) + { + objects[i] = snapshot[i]; + } + + return objects; + } } /// Provides ActionAssertions. internal sealed class ActionAssertions { + /// The assertion subject. private readonly Action _subject; /// Initializes a new instance of the class. /// The subject value. - public ActionAssertions(Action subject) => _subject = subject; + internal ActionAssertions(Action subject) => _subject = subject; /// Provides Throw. /// The TException type. /// The result. - public ExceptionAssertions Throw() + internal ExceptionAssertions Throw() where TException : Exception { try @@ -1119,7 +1173,7 @@ public ExceptionAssertions Throw() } catch (Exception exception) when (exception is TException typed) { - return new ExceptionAssertions(typed); + return new(typed); } catch (Exception exception) { @@ -1132,7 +1186,7 @@ public ExceptionAssertions Throw() /// Provides NotThrow. /// The result. - public ActionAssertions NotThrow() + internal ActionAssertions NotThrow() { try { @@ -1150,15 +1204,16 @@ public ActionAssertions NotThrow() /// Provides FuncTaskAssertions. internal sealed class FuncTaskAssertions { + /// The assertion subject. private readonly Func _subject; /// Initializes a new instance of the class. /// The subject value. - public FuncTaskAssertions(Func subject) => _subject = subject; + internal FuncTaskAssertions(Func subject) => _subject = subject; /// Provides NotThrowAsync. /// The result. - public async Task NotThrowAsync() + internal async Task NotThrowAsync() { try { @@ -1180,15 +1235,15 @@ internal sealed class ExceptionAssertions { /// Initializes a new instance of the ExceptionAssertions class. /// The exception value. - public ExceptionAssertions(TException exception) => Which = exception; + internal ExceptionAssertions(TException exception) => Which = exception; /// Gets Which. - public TException Which { get; } + internal TException Which { get; } /// Provides WithParameterName. /// The expected value. /// The result. - public ExceptionAssertions WithParameterName(string expected) + internal ExceptionAssertions WithParameterName(string expected) { if (Which is ArgumentException argumentException) { @@ -1207,7 +1262,7 @@ public ExceptionAssertions WithParameterName(string expected) /// Provides WithInnerException. /// The result. /// The TInnerException type. - public ExceptionAssertions WithInnerException() + internal ExceptionAssertions WithInnerException() where TInnerException : Exception { if (Which.InnerException is not TInnerException) diff --git a/src/ReactiveList.Test/CacheActionTests.cs b/src/ReactiveList.Test/CacheActionTests.cs index 432e0df..ac00b78 100644 --- a/src/ReactiveList.Test/CacheActionTests.cs +++ b/src/ReactiveList.Test/CacheActionTests.cs @@ -4,9 +4,6 @@ #if NET6_0_OR_GREATER || NETFRAMEWORK using System; -#if NETFRAMEWORK -using System.Linq; -#endif using CP.Primitives.Core; using FluentAssertions; using TUnit.Core; @@ -44,15 +41,15 @@ public class CacheActionTests [Test] public void CacheAction_ShouldHaveCorrectValues() { - ((int)CacheAction.Added).Should().Be(0); - ((int)CacheAction.Removed).Should().Be(1); - ((int)CacheAction.Updated).Should().Be(UpdatedActionValue); - ((int)CacheAction.Moved).Should().Be(MovedActionValue); - ((int)CacheAction.Refreshed).Should().Be(RefreshedActionValue); - ((int)CacheAction.Cleared).Should().Be(ClearedActionValue); - ((int)CacheAction.BatchOperation).Should().Be(BatchOperationActionValue); - ((int)CacheAction.BatchAdded).Should().Be(BatchAddedActionValue); - ((int)CacheAction.BatchRemoved).Should().Be(BatchRemovedActionValue); + _ = ((int)CacheAction.Added).Should().Be(0); + _ = ((int)CacheAction.Removed).Should().Be(1); + _ = ((int)CacheAction.Updated).Should().Be(UpdatedActionValue); + _ = ((int)CacheAction.Moved).Should().Be(MovedActionValue); + _ = ((int)CacheAction.Refreshed).Should().Be(RefreshedActionValue); + _ = ((int)CacheAction.Cleared).Should().Be(ClearedActionValue); + _ = ((int)CacheAction.BatchOperation).Should().Be(BatchOperationActionValue); + _ = ((int)CacheAction.BatchAdded).Should().Be(BatchAddedActionValue); + _ = ((int)CacheAction.BatchRemoved).Should().Be(BatchRemovedActionValue); } /// All CacheAction values should be defined. @@ -63,19 +60,35 @@ public void CacheAction_AllValuesShouldBeDefined() #if NET6_0_OR_GREATER Enum.GetValues(); #else - Enum.GetValues(typeof(CacheAction)).Cast().ToArray(); + CreateCacheActionValues(); #endif - values.Should().HaveCount(DefinedActionCount); - values.Should().Contain(CacheAction.Added); - values.Should().Contain(CacheAction.Removed); - values.Should().Contain(CacheAction.Updated); - values.Should().Contain(CacheAction.Moved); - values.Should().Contain(CacheAction.Refreshed); - values.Should().Contain(CacheAction.Cleared); - values.Should().Contain(CacheAction.BatchOperation); - values.Should().Contain(CacheAction.BatchAdded); - values.Should().Contain(CacheAction.BatchRemoved); + _ = values.Should().HaveCount(DefinedActionCount); + _ = values.Should().Contain(CacheAction.Added); + _ = values.Should().Contain(CacheAction.Removed); + _ = values.Should().Contain(CacheAction.Updated); + _ = values.Should().Contain(CacheAction.Moved); + _ = values.Should().Contain(CacheAction.Refreshed); + _ = values.Should().Contain(CacheAction.Cleared); + _ = values.Should().Contain(CacheAction.BatchOperation); + _ = values.Should().Contain(CacheAction.BatchAdded); + _ = values.Should().Contain(CacheAction.BatchRemoved); + } + +#if NETFRAMEWORK + /// Gets the cache-action values on .NET Framework. + /// The defined cache-action values. + private static CacheAction[] CreateCacheActionValues() + { + var rawValues = Enum.GetValues(typeof(CacheAction)); + var values = new CacheAction[rawValues.Length]; + for (var index = 0; index < rawValues.Length; index++) + { + values[index] = (CacheAction)rawValues.GetValue(index)!; + } + + return values; } +#endif } #endif diff --git a/src/ReactiveList.Test/CacheNotifyTests.cs b/src/ReactiveList.Test/CacheNotifyTests.cs index abb0d5a..6dfc96a 100644 --- a/src/ReactiveList.Test/CacheNotifyTests.cs +++ b/src/ReactiveList.Test/CacheNotifyTests.cs @@ -28,9 +28,9 @@ public void Constructor_WithActionAndItem_ShouldInitialize() { var notify = new CacheNotify(CacheAction.Added, "test"); - notify.Action.Should().Be(CacheAction.Added); - notify.Item.Should().Be("test"); - notify.Batch.Should().BeNull(); + _ = notify.Action.Should().Be(CacheAction.Added); + _ = notify.Item.Should().Be("test"); + _ = notify.Batch.Should().BeNull(); } /// Constructor should initialize with batch. @@ -44,9 +44,9 @@ public void Constructor_WithBatch_ShouldInitialize() var notify = new CacheNotify(CacheAction.BatchOperation, default, batch); - notify.Action.Should().Be(CacheAction.BatchOperation); - notify.Item.Should().Be(default(int)); - notify.Batch.Should().BeSameAs(batch); + _ = notify.Action.Should().Be(CacheAction.BatchOperation); + _ = notify.Item.Should().Be(default(int)); + _ = notify.Batch.Should().BeSameAs(batch); batch.Dispose(); } @@ -57,8 +57,8 @@ public void Constructor_WithNullItem_ShouldBeValid() { var notify = new CacheNotify(CacheAction.Cleared, null); - notify.Action.Should().Be(CacheAction.Cleared); - notify.Item.Should().BeNull(); + _ = notify.Action.Should().Be(CacheAction.Cleared); + _ = notify.Item.Should().BeNull(); } /// Record equality should work correctly. @@ -68,8 +68,8 @@ public void RecordEquality_ShouldWorkCorrectly() var notify1 = new CacheNotify(CacheAction.Added, "test"); var notify2 = new CacheNotify(CacheAction.Added, "test"); - notify1.Should().Be(notify2); - (notify1 == notify2).Should().BeTrue(); + _ = notify1.Should().Be(notify2); + _ = (notify1 == notify2).Should().BeTrue(); } /// Record inequality for different actions. @@ -79,8 +79,8 @@ public void RecordInequality_DifferentActions_ShouldNotBeEqual() var notify1 = new CacheNotify(CacheAction.Added, "test"); var notify2 = new CacheNotify(CacheAction.Removed, "test"); - notify1.Should().NotBe(notify2); - (notify1 != notify2).Should().BeTrue(); + _ = notify1.Should().NotBe(notify2); + _ = (notify1 != notify2).Should().BeTrue(); } /// Record inequality for different items. @@ -90,7 +90,7 @@ public void RecordInequality_DifferentItems_ShouldNotBeEqual() var notify1 = new CacheNotify(CacheAction.Added, "test1"); var notify2 = new CacheNotify(CacheAction.Added, "test2"); - notify1.Should().NotBe(notify2); + _ = notify1.Should().NotBe(notify2); } /// CacheNotify for Added action. @@ -99,8 +99,8 @@ public void CacheNotify_AddedAction_ShouldHaveCorrectState() { var notify = new CacheNotify(CacheAction.Added, NotificationItemValue); - notify.Action.Should().Be(CacheAction.Added); - notify.Item.Should().Be(NotificationItemValue); + _ = notify.Action.Should().Be(CacheAction.Added); + _ = notify.Item.Should().Be(NotificationItemValue); } /// CacheNotify for Removed action. @@ -109,8 +109,8 @@ public void CacheNotify_RemovedAction_ShouldHaveCorrectState() { var notify = new CacheNotify(CacheAction.Removed, NotificationItemValue); - notify.Action.Should().Be(CacheAction.Removed); - notify.Item.Should().Be(NotificationItemValue); + _ = notify.Action.Should().Be(CacheAction.Removed); + _ = notify.Item.Should().Be(NotificationItemValue); } /// CacheNotify for Updated action. @@ -119,19 +119,13 @@ public void CacheNotify_UpdatedAction_ShouldHaveCorrectState() { var notify = new CacheNotify(CacheAction.Updated, "updated"); - notify.Action.Should().Be(CacheAction.Updated); - notify.Item.Should().Be("updated"); + _ = notify.Action.Should().Be(CacheAction.Updated); + _ = notify.Item.Should().Be("updated"); } /// CacheNotify for Cleared action. [Test] - public void CacheNotify_ClearedAction_ShouldHaveCorrectState() - { - var notify = new CacheNotify(CacheAction.Cleared, null); - - notify.Action.Should().Be(CacheAction.Cleared); - notify.Item.Should().BeNull(); - } + public void CacheNotify_ClearedAction_ShouldHaveCorrectState() => Constructor_WithNullItem_ShouldBeValid(); /// CacheNotify for BatchOperation action. [Test] @@ -144,9 +138,9 @@ public void CacheNotify_BatchOperationAction_ShouldHaveCorrectState() var notify = new CacheNotify(CacheAction.BatchOperation, null, batch); - notify.Action.Should().Be(CacheAction.BatchOperation); - notify.Batch.Should().NotBeNull(); - notify.Batch!.Count.Should().Be(BatchItemCount); + _ = notify.Action.Should().Be(CacheAction.BatchOperation); + _ = notify.Batch.Should().NotBeNull(); + _ = notify.Batch!.Count.Should().Be(BatchItemCount); batch.Dispose(); } @@ -159,8 +153,8 @@ public void CacheNotify_WithExpression_ShouldWork() var notify2 = notify1 with { Action = CacheAction.Removed }; - notify2.Action.Should().Be(CacheAction.Removed); - notify2.Item.Should().Be(BatchCapacity); + _ = notify2.Action.Should().Be(CacheAction.Removed); + _ = notify2.Item.Should().Be(BatchCapacity); } /// GetHashCode should be consistent. @@ -172,7 +166,7 @@ public void GetHashCode_ShouldBeConsistent() var hash1 = notify.GetHashCode(); var hash2 = notify.GetHashCode(); - hash1.Should().Be(hash2); + _ = hash1.Should().Be(hash2); } /// ToString should return meaningful representation. @@ -183,9 +177,9 @@ public void ToString_ShouldReturnMeaningfulRepresentation() var result = notify.ToString(); - result.Should().Contain("CacheNotify"); - result.Should().Contain("Added"); - result.Should().Contain("test"); + _ = result.Should().Contain("CacheNotify"); + _ = result.Should().Contain("Added"); + _ = result.Should().Contain("test"); } /// CacheNotify with value types. @@ -194,16 +188,16 @@ public void CacheNotify_WithValueTypes_ShouldWork() { var notify = new CacheNotify(CacheAction.Added, DateTime.Today); - notify.Item.Should().Be(DateTime.Today); + _ = notify.Item.Should().Be(DateTime.Today); } /// CacheNotify with complex types. [Test] public void CacheNotify_WithComplexTypes_ShouldWork() { - var person = new { Id = 1, Name = "John" }; + var person = (Id: 1, Name: "John"); var notify = new CacheNotify(CacheAction.Added, person); - notify.Item.Should().Be(person); + _ = notify.Item.Should().Be(person); } } diff --git a/src/ReactiveList.Test/CoreCoverageTests.cs b/src/ReactiveList.Test/CoreCoverageTests.cs index 8a3ced2..be45f90 100644 --- a/src/ReactiveList.Test/CoreCoverageTests.cs +++ b/src/ReactiveList.Test/CoreCoverageTests.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; -using System.Linq; using System.Threading.Tasks; using CP.Primitives; using CP.Primitives.Core; @@ -84,6 +83,7 @@ public class CoreCoverageTests /// The numbers group key. private const string NumbersGroupKey = "numbers"; + /// The values emitted by observable factory coverage tests. private static readonly int[] ObservableFactoryValues = [1, 2]; /// Cache notification stream extensions should filter, project, and count notifications. @@ -104,6 +104,11 @@ public void CacheNotifyExtensions_ShouldFilterProjectAndCountNotifications() var transformed = new List(); var filtered = new List(); var counts = new List<(CacheAction Action, int Count)>(); +#if NETFRAMEWORK + Func containsLowercaseO = static item => item.IndexOf('o') >= 0; +#else + Func containsLowercaseO = static item => item.Contains('o'); +#endif using var whereActionSubscription = subject.WhereAction(CacheAction.Added).Subscribe(whereAction.Add); using var whereAddedSubscription = subject.WhereAdded().Subscribe(whereAdded.Add); @@ -115,36 +120,36 @@ public void CacheNotifyExtensions_ShouldFilterProjectAndCountNotifications() using var updatedSubscription = subject.OnItemUpdated().Subscribe(updatedItems.Add); using var movedSubscription = subject.OnItemMoved().Subscribe(movedItems.Add); using var clearedSubscription = subject.OnCleared().Subscribe(cleared.Add); - using var transformedSubscription = subject.TransformItems(item => item.ToUpperInvariant()).Subscribe(transformed.Add); - using var filteredSubscription = subject.FilterItems(item => item.Contains("o")).Subscribe(filtered.Add); + using var transformedSubscription = subject.TransformItems(static item => item.ToUpperInvariant()).Subscribe(transformed.Add); + using var filteredSubscription = subject.FilterItems(containsLowercaseO).Subscribe(filtered.Add); using var countSubscription = subject.CountByAction().Subscribe(counts.Add); var addedBatch = CreateStringBatch("two", "four"); var removedBatch = CreateStringBatch(RemovedBatchFirstItem, "ten"); - subject.OnNext(new CacheNotify(CacheAction.Added, "one", CurrentIndex: 0)); - subject.OnNext(new CacheNotify(CacheAction.BatchAdded, default, addedBatch, CurrentIndex: 1)); - subject.OnNext(new CacheNotify(CacheAction.Removed, "six", CurrentIndex: 2)); - subject.OnNext(new CacheNotify(CacheAction.BatchRemoved, default, removedBatch)); - subject.OnNext(new CacheNotify(CacheAction.Updated, UpdatedTextItem, CurrentIndex: 3, Previous: "eleven")); - subject.OnNext(new CacheNotify(CacheAction.Moved, MovedTextItem, CurrentIndex: 4, PreviousIndex: 2)); - subject.OnNext(new CacheNotify(CacheAction.Cleared, default)); - subject.OnNext(new CacheNotify(CacheAction.BatchOperation, default)); + subject.OnNext(new(CacheAction.Added, "one", CurrentIndex: 0)); + subject.OnNext(new(CacheAction.BatchAdded, default, addedBatch, CurrentIndex: 1)); + subject.OnNext(new(CacheAction.Removed, "six", CurrentIndex: 2)); + subject.OnNext(new(CacheAction.BatchRemoved, default, removedBatch)); + subject.OnNext(new(CacheAction.Updated, UpdatedTextItem, CurrentIndex: 3, Previous: "eleven")); + subject.OnNext(new(CacheAction.Moved, MovedTextItem, CurrentIndex: 4, PreviousIndex: 2)); + subject.OnNext(new(CacheAction.Cleared, default)); + subject.OnNext(new(CacheAction.BatchOperation, default)); - whereAction.Should().ContainSingle() + _ = whereAction.Should().ContainSingle() .Which.Item.Should().Be("one"); - whereAdded.Select(notification => notification.Action).Should().Equal(CacheAction.Added, CacheAction.BatchAdded); - whereRemoved.Select(notification => notification.Action).Should().Equal(CacheAction.Removed, CacheAction.BatchRemoved); - selectedItems.Should().Equal("one", "six", UpdatedTextItem, MovedTextItem); - allItems.Should().Equal("one", "two", "four", "six", RemovedBatchFirstItem, "ten", UpdatedTextItem, MovedTextItem); - addedItems.Should().Equal("one", "two", "four"); - removedItems.Should().Equal("six", RemovedBatchFirstItem, "ten"); - updatedItems.Should().Equal(UpdatedTextItem); - movedItems.Should().Equal((MovedTextItem, CoverageValueTwo, CoverageValueFour)); - cleared.Should().ContainSingle(); - transformed.Should().Equal("ONE", "TWO", "FOUR", "SIX", "EIGHT", "TEN", "TWELVE", "FOURTEEN"); - filtered.Should().Equal("one", "two", "four", MovedTextItem); - counts.Select(item => item.Count).Should().Equal(1, CoverageValueTwo, 1, CoverageValueTwo, 1, 1, 0, 0); + _ = Project(whereAdded, static notification => notification.Action).Should().Equal(CacheAction.Added, CacheAction.BatchAdded); + _ = Project(whereRemoved, static notification => notification.Action).Should().Equal(CacheAction.Removed, CacheAction.BatchRemoved); + _ = selectedItems.Should().Equal("one", "six", UpdatedTextItem, MovedTextItem); + _ = allItems.Should().Equal("one", "two", "four", "six", RemovedBatchFirstItem, "ten", UpdatedTextItem, MovedTextItem); + _ = addedItems.Should().Equal("one", "two", "four"); + _ = removedItems.Should().Equal("six", RemovedBatchFirstItem, "ten"); + _ = updatedItems.Should().Equal(UpdatedTextItem); + _ = movedItems.Should().Equal((MovedTextItem, CoverageValueTwo, CoverageValueFour)); + _ = cleared.Should().ContainSingle(); + _ = transformed.Should().Equal("ONE", "TWO", "FOUR", "SIX", "EIGHT", "TEN", "TWELVE", "FOURTEEN"); + _ = filtered.Should().Equal("one", "two", "four", MovedTextItem); + _ = Project(counts, static item => item.Count).Should().Equal(1, CoverageValueTwo, 1, CoverageValueTwo, 1, 1, 0, 0); addedBatch.Dispose(); removedBatch.Dispose(); @@ -155,26 +160,22 @@ public void CacheNotifyExtensions_ShouldFilterProjectAndCountNotifications() public void CacheNotifyExtensions_ShouldBufferThrottleObserveAndDisposeBatches() { var notification = new CacheNotify(CacheAction.Added, 1); - var buffered = ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() - .BufferNotifications(TimeSpan.FromMilliseconds(1))) - .ToList(); - var emptyBuffered = ObservableMixins.ToEnumerable(Array.Empty>().ToObservable() - .BufferNotifications(TimeSpan.FromMilliseconds(1))) - .ToList(); - var throttled = ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() - .ThrottleNotifications(TimeSpan.Zero)) - .ToList(); - var observed = ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() - .ObserveOnScheduler(Sequencer.Immediate)) - .ToList(); - - buffered.Should().ContainSingle() + var buffered = Collect(ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() + .BufferNotifications(TimeSpan.FromMilliseconds(1)))); + var emptyBuffered = Collect(ObservableMixins.ToEnumerable(Array.Empty>().ToObservable() + .BufferNotifications(TimeSpan.FromMilliseconds(1)))); + var throttled = Collect(ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() + .ThrottleNotifications(TimeSpan.Zero))); + var observed = Collect(ObservableMixins.ToEnumerable(new[] { notification }.ToObservable() + .ObserveOnScheduler(Sequencer.Immediate))); + + _ = buffered.Should().ContainSingle() .Which.Should().ContainSingle() .Which.Should().BeSameAs(notification); - emptyBuffered.Should().BeEmpty(); - throttled.Should().ContainSingle() + _ = emptyBuffered.Should().BeEmpty(); + _ = throttled.Should().ContainSingle() .Which.Should().BeSameAs(notification); - observed.Should().ContainSingle() + _ = observed.Should().ContainSingle() .Which.Should().BeSameAs(notification); using var subject = new Signal>(); @@ -182,11 +183,11 @@ public void CacheNotifyExtensions_ShouldBufferThrottleObserveAndDisposeBatches() using var subscription = subject.AutoDisposeBatches().Subscribe(autoDisposed.Add); var batch = CreateBatch(CoverageValueFortyTwo); - subject.OnNext(new CacheNotify(CacheAction.BatchAdded, default, batch)); + subject.OnNext(new(CacheAction.BatchAdded, default, batch)); Action disposeAgain = batch.Dispose; - autoDisposed.Should().ContainSingle(); - disposeAgain.Should().NotThrow(); + _ = autoDisposed.Should().ContainSingle(); + _ = disposeAgain.Should().NotThrow(); } /// CacheNotifyExtensions should validate null arguments. @@ -207,8 +208,8 @@ public void CacheNotifyExtensions_ShouldValidateNullArguments() () => source.BufferNotifications(TimeSpan.FromMilliseconds(1)), () => source.ThrottleNotifications(TimeSpan.FromMilliseconds(1)), () => source.ObserveOnScheduler(Sequencer.Immediate), - () => source.TransformItems(item => item), - () => source.FilterItems(item => true), + () => source.TransformItems(static item => item), + () => source.FilterItems(static item => true), () => source.AutoDisposeBatches(), () => source.CountByAction(), () => source.ToChangeSets() @@ -216,7 +217,7 @@ public void CacheNotifyExtensions_ShouldValidateNullArguments() foreach (var action in sourceActions) { - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(SourceParameterName); } @@ -224,11 +225,11 @@ public void CacheNotifyExtensions_ShouldValidateNullArguments() Action transformNullSelector = () => valid.TransformItems(null!); Action filterNullPredicate = () => valid.FilterItems(null!); - observeNullScheduler.Should().Throw() + _ = observeNullScheduler.Should().Throw() .WithParameterName("scheduler"); - transformNullSelector.Should().Throw() + _ = transformNullSelector.Should().Throw() .WithParameterName("selector"); - filterNullPredicate.Should().Throw() + _ = filterNullPredicate.Should().Throw() .WithParameterName("predicate"); } @@ -238,24 +239,24 @@ public void ToChange_ShouldMapSingleNotifications() { CacheNotify nullNotification = null!; - nullNotification.ToChange().Should().BeNull(); - new CacheNotify(CacheAction.Added, 1, CurrentIndex: 2).ToChange().Should().Be( + _ = nullNotification.ToChange().Should().BeNull(); + _ = new CacheNotify(CacheAction.Added, 1, CurrentIndex: 2).ToChange().Should().Be( Change.CreateAdd(1, CoverageValueTwo)); - new CacheNotify(CacheAction.Removed, CoverageValueThree, CurrentIndex: 4).ToChange().Should().Be( + _ = new CacheNotify(CacheAction.Removed, CoverageValueThree, CurrentIndex: 4).ToChange().Should().Be( Change.CreateRemove(CoverageValueThree, CoverageValueFour)); - new CacheNotify(CacheAction.Updated, CoverageValueFive, CurrentIndex: 6, Previous: 4).ToChange().Should().Be( + _ = new CacheNotify(CacheAction.Updated, CoverageValueFive, CurrentIndex: 6, Previous: 4).ToChange().Should().Be( Change.CreateUpdate(CoverageValueFive, CoverageValueFour, CoverageValueSix)); - new CacheNotify(CacheAction.Moved, CoverageValueSeven, CurrentIndex: 8, PreviousIndex: 9).ToChange().Should().Be( + _ = new CacheNotify(CacheAction.Moved, CoverageValueSeven, CurrentIndex: 8, PreviousIndex: 9).ToChange().Should().Be( Change.CreateMove(CoverageValueSeven, CoverageValueEight, CoverageValueNine)); - new CacheNotify(CacheAction.Refreshed, CoverageValueTen, CurrentIndex: 11).ToChange().Should().Be( + _ = new CacheNotify(CacheAction.Refreshed, CoverageValueTen, CurrentIndex: 11).ToChange().Should().Be( Change.CreateRefresh(CoverageValueTen, CoverageValueEleven)); var clearChange = new CacheNotify(CacheAction.Cleared, default).ToChange(); - clearChange.Should().NotBeNull(); - clearChange!.Value.Reason.Should().Be(ChangeReason.Clear); + _ = clearChange.Should().NotBeNull(); + _ = clearChange!.Value.Reason.Should().Be(ChangeReason.Clear); - new CacheNotify(CacheAction.BatchAdded, default).ToChange().Should().BeNull(); - new CacheNotify(CacheAction.Added, default).ToChange().Should().BeNull(); + _ = new CacheNotify(CacheAction.BatchAdded, default).ToChange().Should().BeNull(); + _ = new CacheNotify(CacheAction.Added, default).ToChange().Should().BeNull(); } /// ToChangeSets should expand batches, singles, and filter empty changes. @@ -280,20 +281,19 @@ public void ToChangeSets_ShouldExpandBatchesSinglesAndFilterEmptyChanges() new CacheNotify(CacheAction.BatchRemoved, default) }; - var changeSets = ObservableMixins.ToEnumerable(notifications.ToObservable() - .ToChangeSets()) - .ToList(); + var changeSets = Collect(ObservableMixins.ToEnumerable(notifications.ToObservable() + .ToChangeSets())); - changeSets.Should().HaveCount(CoverageValueFive); - changeSets[0].Should().HaveCount(CoverageValueTwo); - changeSets[0].Select(change => change.Reason).Should().AllBeEquivalentTo(ChangeReason.Add); - changeSets[0][0].CurrentIndex.Should().Be(CoverageValueTen); - changeSets[1].Select(change => change.Reason).Should().AllBeEquivalentTo(ChangeReason.Remove); - changeSets[CoverageValueTwo].Should().ContainSingle() + _ = changeSets.Should().HaveCount(CoverageValueFive); + _ = changeSets[0].Should().HaveCount(CoverageValueTwo); + _ = Project(changeSets[0], static change => change.Reason).Should().AllBeEquivalentTo(ChangeReason.Add); + _ = changeSets[0][0].CurrentIndex.Should().Be(CoverageValueTen); + _ = Project(changeSets[1], static change => change.Reason).Should().AllBeEquivalentTo(ChangeReason.Remove); + _ = changeSets[CoverageValueTwo].Should().ContainSingle() .Which.Reason.Should().Be(ChangeReason.Remove); - changeSets[CoverageValueThree].Should().ContainSingle() + _ = changeSets[CoverageValueThree].Should().ContainSingle() .Which.Reason.Should().Be(ChangeReason.Refresh); - changeSets[CoverageValueFour].Should().ContainSingle() + _ = changeSets[CoverageValueFour].Should().ContainSingle() .Which.Should().Be(Change.CreateAdd(CoverageValueSeven, CoverageValueTwenty)); foreach (var changeSet in changeSets) @@ -325,44 +325,44 @@ public void ChangeSet_ShouldExposeCountsEnumeratorsAndValidation() using var comparison = new ChangeSet([.. changes]); var defaultSet = default(ChangeSet); - set.Count.Should().Be(CoverageValueFour); - set.Adds.Should().Be(1); - set.Removes.Should().Be(1); - set.Updates.Should().Be(1); - set.Moves.Should().Be(1); - set[0].Should().Be(changes[0]); - single.Should().ContainSingle() + _ = set.Count.Should().Be(CoverageValueFour); + _ = set.Adds.Should().Be(1); + _ = set.Removes.Should().Be(1); + _ = set.Updates.Should().Be(1); + _ = set.Moves.Should().Be(1); + _ = set[0].Should().Be(changes[0]); + _ = single.Should().ContainSingle() .Which.Should().Be(Change.CreateRefresh(CoverageValueFive, CoverageValueFour)); - defaultSet.Adds.Should().Be(0); + _ = defaultSet.Adds.Should().Be(0); defaultSet.Dispose(); - set.Equals(set).Should().BeTrue(); - set.Equals(comparison).Should().BeFalse(); - set.GetHashCode().Should().NotBe(0); + _ = set.Equals(set).Should().BeTrue(); + _ = set.Equals(comparison).Should().BeFalse(); + _ = set.GetHashCode().Should().NotBe(0); - ((IEnumerable>)set).Select(change => change.Current).Should().Equal(1, CoverageValueTwo, CoverageValueThree, CoverageValueFour); + _ = Project((IEnumerable>)set, static change => change.Current).Should().Equal(1, CoverageValueTwo, CoverageValueThree, CoverageValueFour); var enumerator = ((IEnumerable)set).GetEnumerator(); - enumerator.MoveNext().Should().BeTrue(); - enumerator.Current.Should().Be(changes[0]); + _ = enumerator.MoveNext().Should().BeTrue(); + _ = enumerator.Current.Should().Be(changes[0]); enumerator.Reset(); - enumerator.MoveNext().Should().BeTrue(); - enumerator.Current.Should().Be(changes[0]); + _ = enumerator.MoveNext().Should().BeTrue(); + _ = enumerator.Current.Should().Be(changes[0]); Action getNegative = () => _ = set[-1]; Action getPastEnd = () => _ = set[set.Count]; - Action createNull = () => _ = new ChangeSet(null!); + Action createNull = static () => _ = new ChangeSet(null!); - getNegative.Should().Throw() + _ = getNegative.Should().Throw() .WithParameterName("index"); - getPastEnd.Should().Throw() + _ = getPastEnd.Should().Throw() .WithParameterName("index"); - createNull.Should().Throw() + _ = createNull.Should().Throw() .WithParameterName("changes"); using var spanSet = new ChangeSet(changes.AsSpan()); - spanSet.AsSpan().ToArray().Should().Equal(changes); + _ = spanSet.AsSpan().ToArray().Should().Equal(changes); - ChangeSet.Empty.AsSpan().IsEmpty.Should().BeTrue(); + _ = ChangeSet.Empty.AsSpan().IsEmpty.Should().BeTrue(); } /// Disposing one struct copy should not invalidate another copy. @@ -374,7 +374,7 @@ public void ChangeSet_DisposedCopy_ShouldRetainSharedValues() changeSet.Dispose(); - copy.Should().ContainSingle() + _ = copy.Should().ContainSingle() .Which.Current.Should().Be(UpdatedTextItem); copy.Dispose(); } @@ -393,69 +393,69 @@ public async Task ArrayPoolClearHelper_ShouldDetectNestedReferences() [Test] public void PooledEditableListWrapper_ShouldSynchronizeOperationsAndValidateMoves() { - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); var list = new List { "one", "two" }; var observable = new ObservableCollection(list); using var wrapper = new PooledEditableListWrapper(list, observable); - wrapper.IsReadOnly.Should().BeFalse(); - wrapper[1].Should().Be("two"); + _ = wrapper.IsReadOnly.Should().BeFalse(); + _ = wrapper[1].Should().Be("two"); wrapper[1] = "deux"; wrapper.AddRange([ThirdTextItem, "four"]); wrapper.Insert(1, InsertedTextItem); wrapper.Move(0, CoverageValueTwo); wrapper.Move(CoverageValueTwo, CoverageValueTwo); - list.Should().Equal(InsertedTextItem, "deux", "one", ThirdTextItem, "four"); - observable.Should().Equal(list); - wrapper.Contains(ThirdTextItem).Should().BeTrue(); - wrapper.IndexOf(ThirdTextItem).Should().Be(CoverageValueThree); + _ = list.Should().Equal(InsertedTextItem, "deux", "one", ThirdTextItem, "four"); + _ = observable.Should().Equal(list); + _ = wrapper.Contains(ThirdTextItem).Should().BeTrue(); + _ = wrapper.IndexOf(ThirdTextItem).Should().Be(CoverageValueThree); var copied = new string[wrapper.Count]; wrapper.CopyTo(copied, 0); - copied.Should().Equal(list); - wrapper.ToArray().Should().Equal(list); - ((IEnumerable)wrapper).Cast().Should().Equal(list); + _ = copied.Should().Equal(list); + _ = Collect(wrapper).Should().Equal(list); + _ = CollectNonGeneric(wrapper).Should().Equal(list); var wrapperEnumerator = ((IEnumerable)wrapper).GetEnumerator(); - wrapperEnumerator.MoveNext().Should().BeTrue(); - wrapperEnumerator.Current.Should().Be(InsertedTextItem); + _ = wrapperEnumerator.MoveNext().Should().BeTrue(); + _ = wrapperEnumerator.Current.Should().Be(InsertedTextItem); - wrapper.Remove("missing").Should().BeFalse(); - wrapper.Remove("deux").Should().BeTrue(); + _ = wrapper.Remove("missing").Should().BeFalse(); + _ = wrapper.Remove("deux").Should().BeTrue(); wrapper.RemoveAt(0); wrapper.Clear(); - list.Should().BeEmpty(); - observable.Should().BeEmpty(); + _ = list.Should().BeEmpty(); + _ = observable.Should().BeEmpty(); wrapper.Initialize(["a", "b"], null); - wrapper.Count.Should().Be(CoverageValueTwo); + _ = wrapper.Count.Should().Be(CoverageValueTwo); Action badOldIndex = () => wrapper.Move(-1, 0); Action badNewIndex = () => wrapper.Move(0, CoverageValueTwo); - badOldIndex.Should().Throw() + _ = badOldIndex.Should().Throw() .WithParameterName("oldIndex"); - badNewIndex.Should().Throw() + _ = badNewIndex.Should().Throw() .WithParameterName("newIndex"); - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); } /// Returned pooled wrappers should reject future access and dispose idempotently. [Test] public void PooledEditableListWrapper_WhenReturned_ShouldRejectAccessAndDisposeIdempotently() { - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); var wrapper = new PooledEditableListWrapper([]); ((IResettable)wrapper).Reset(); wrapper.Dispose(); - wrapper.Count.Should().Be(0); + _ = wrapper.Count.Should().Be(0); Action useReturned = () => wrapper.Add(1); - useReturned.Should().Throw(); + _ = useReturned.Should().Throw(); } /// ReactiveGroup should expose grouping data and forward collection change events. @@ -465,37 +465,72 @@ public void ReactiveGroup_ShouldExposeItemsAndForwardCollectionChanges() var source = new ObservableCollection(["one"]); var group = new ReactiveGroup("letters", source); var events = new List(); - group.CollectionChanged += (sender, args) => events.Add(args); + var collectionSenders = new List(); + var propertySenders = new List(); + var properties = new List(); + NotifyCollectionChangedEventHandler collectionHandler = (sender, args) => + { + collectionSenders.Add(sender); + events.Add(args); + }; + System.ComponentModel.PropertyChangedEventHandler propertyHandler = (sender, args) => + { + propertySenders.Add(sender); + properties.Add(args.PropertyName); + }; + group.CollectionChanged += collectionHandler; + group.CollectionChanged += collectionHandler; + group.PropertyChanged += propertyHandler; + group.PropertyChanged += propertyHandler; source.Add("two"); - group.Key.Should().Be("letters"); - group.Count.Should().Be(CoverageValueTwo); - group.Items.Should().Equal("one", "two"); - group.Should().Equal("one", "two"); - ((IEnumerable)group).Cast().Should().Equal("one", "two"); + _ = group.Key.Should().Be("letters"); + _ = group.Count.Should().Be(CoverageValueTwo); + _ = group.Items.Should().Equal("one", "two"); + _ = group.Should().Equal("one", "two"); + _ = CollectNonGeneric(group).Should().Equal("one", "two"); var groupEnumerator = ((IEnumerable)group).GetEnumerator(); - groupEnumerator.MoveNext().Should().BeTrue(); - groupEnumerator.Current.Should().Be("one"); - events.Should().ContainSingle() - .Which.Action.Should().Be(NotifyCollectionChangedAction.Add); + _ = groupEnumerator.MoveNext().Should().BeTrue(); + _ = groupEnumerator.Current.Should().Be("one"); + _ = events.Should().HaveCount(CoverageValueTwo); + _ = Project(events, static args => args.Action).Should().Equal( + NotifyCollectionChangedAction.Add, + NotifyCollectionChangedAction.Add); + _ = Project(collectionSenders, sender => ReferenceEquals(sender, group)).Should().Equal(true, true); + _ = properties.Should().Equal(nameof(group.Count), nameof(group.Count), "Item[]", "Item[]"); + _ = Project(propertySenders, sender => ReferenceEquals(sender, group)).Should().Equal(true, true, true, true); + + group.CollectionChanged -= collectionHandler; + group.PropertyChanged -= propertyHandler; + source.Add("three"); + + _ = events.Should().HaveCount(CoverageValueThree); + _ = properties.Should().HaveCount(CoverageValueSix); + + group.CollectionChanged -= collectionHandler; + group.PropertyChanged -= propertyHandler; + source.Add("four"); + + _ = events.Should().HaveCount(CoverageValueThree); + _ = properties.Should().HaveCount(CoverageValueSix); var silentSource = new ObservableCollection(); _ = new ReactiveGroup(NumbersGroupKey, silentSource); Action addWithoutSubscriber = () => silentSource.Add(1); - addWithoutSubscriber.Should().NotThrow(); + _ = addWithoutSubscriber.Should().NotThrow(); } /// SecondaryIndex should reject keys of the wrong type in MatchesKey. [Test] public void SecondaryIndex_MatchesKey_ShouldRejectWrongKeyType() { - var index = new SecondaryIndex(person => person.Department); + var index = new SecondaryIndex(static person => person.Department); var person = new Person(1, "Ada", "Engineering"); - index.MatchesKey(person, "Engineering").Should().BeTrue(); - index.MatchesKey(person, CoverageValueOneHundredTwentyThree).Should().BeFalse(); + _ = index.MatchesKey(person, "Engineering").Should().BeTrue(); + _ = index.MatchesKey(person, CoverageValueOneHundredTwentyThree).Should().BeFalse(); } /// Internal grouping should expose its non-generic enumerator. @@ -505,10 +540,10 @@ public void ChangeGrouping_ShouldExposeNonGenericEnumerator() var grouping = new ChangeGrouping(NumbersGroupKey, [1, CoverageValueTwo]); var enumerator = ((IEnumerable)grouping).GetEnumerator(); - grouping.Key.Should().Be(NumbersGroupKey); - enumerator.MoveNext().Should().BeTrue(); - enumerator.Current.Should().Be(1); - ((IEnumerable)grouping).Cast().Should().Equal(1, CoverageValueTwo); + _ = grouping.Key.Should().Be(NumbersGroupKey); + _ = enumerator.MoveNext().Should().BeTrue(); + _ = enumerator.Current.Should().Be(1); + _ = CollectNonGeneric(grouping).Should().Equal(1, CoverageValueTwo); } /// Internal observable factories should surface factory errors and event handler variants. @@ -521,14 +556,14 @@ public void InternalObservableFactories_ShouldCoverErrorAndEventBranches() .Defer(() => throw factoryError) .Subscribe(deferredObserver); - deferredObserver.Error.Should().BeSameAs(factoryError); + _ = deferredObserver.Error.Should().BeSameAs(factoryError); var successValues = new List(); using var successSubscription = Observable .Defer(ObservableFactoryValues.ToObservable) .Subscribe(successValues.Add); - successValues.Should().Equal(1, CoverageValueTwo); + _ = successValues.Should().Equal(1, CoverageValueTwo); var eventSource = new EventSource(); var events = new List>(); @@ -539,18 +574,19 @@ public void InternalObservableFactories_ShouldCoverErrorAndEventBranches() .Subscribe(events.Add); eventSource.Raise(); - events.Should().ContainSingle(); + _ = events.Should().ContainSingle(); - Action unsupported = () => Observable - .FromEventPattern(_ => { }, _ => { }) + Action unsupported = static () => Observable + .FromEventPattern(static _ => { }, static _ => { }) .Subscribe(new RecordingObserver>()); - unsupported.Should().Throw(); + _ = unsupported.Should().Throw(); } /// Internal observable operators should cover error and completion branches. + /// A task that completes when the asynchronous assertions finish. [Test] - public void ObservableMixins_ShouldCoverErrorAndCompletionBranches() + public async Task ObservableMixins_ShouldCoverErrorAndCompletionBranches() { var toEnumerableError = new InvalidOperationException("enumerable"); var throwing = Signal.Create(observer => @@ -559,8 +595,8 @@ public void ObservableMixins_ShouldCoverErrorAndCompletionBranches() return ReactiveUI.Primitives.Disposables.Scope.Empty; }); - Action enumerate = () => _ = ObservableMixins.ToEnumerable(throwing).ToList(); - enumerate.Should().Throw(); + Action enumerate = () => _ = Collect(ObservableMixins.ToEnumerable(throwing)); + _ = enumerate.Should().Throw(); using var bufferSource = new Signal(); var buffered = new RecordingObserver>(); @@ -569,8 +605,8 @@ public void ObservableMixins_ShouldCoverErrorAndCompletionBranches() bufferSource.OnNext(1); bufferSource.OnCompleted(); - buffered.Values.SelectMany(static item => item).Should().Contain(1); - buffered.Completed.Should().BeTrue(); + _ = Flatten(buffered.Values).Should().Contain(1); + _ = buffered.Completed.Should().BeTrue(); using var bufferErrorSource = new Signal(); var bufferErrorObserver = new RecordingObserver>(); @@ -578,84 +614,10 @@ public void ObservableMixins_ShouldCoverErrorAndCompletionBranches() using var bufferErrorSubscription = bufferErrorSource.Buffer(TimeSpan.FromMilliseconds(1), Sequencer.Immediate).Subscribe(bufferErrorObserver); bufferErrorSource.OnError(bufferError); - bufferErrorObserver.Error.Should().BeSameAs(bufferError); - - using var throttleSource = new Signal(); - var throttled = new RecordingObserver(); - using var throttleSubscription = throttleSource.Throttle(TimeSpan.FromMilliseconds(1), Sequencer.Immediate).Subscribe(throttled); - - throttleSource.OnNext(CoverageValueFortyTwo); - throttleSource.OnCompleted(); - - throttled.Values.Should().Contain(CoverageValueFortyTwo); - throttled.Completed.Should().BeTrue(); - - using var throttleErrorSource = new Signal(); - var throttleErrorObserver = new RecordingObserver(); - var throttleError = new InvalidOperationException("throttle"); - using var throttleErrorSubscription = throttleErrorSource.Throttle(TimeSpan.FromMilliseconds(1), Sequencer.Immediate).Subscribe(throttleErrorObserver); - - throttleErrorSource.OnError(throttleError); - throttleErrorObserver.Error.Should().BeSameAs(throttleError); - - var manualBufferSequencer = new ManualSequencer(); - var completedBufferObserver = new RecordingObserver>(); - var completedThenValue = Signal.Create(observer => - { - observer.OnNext(CoverageValueSeven); - observer.OnCompleted(); - observer.OnNext(CoverageValueEight); - return ReactiveUI.Primitives.Disposables.Scope.Empty; - }); - - using var completedBufferSubscription = completedThenValue - .Buffer(TimeSpan.FromMilliseconds(1), manualBufferSequencer) - .Subscribe(completedBufferObserver); - - completedBufferObserver.Values.Should().ContainSingle() - .Which.Should().Equal(CoverageValueSeven); - completedBufferObserver.Completed.Should().BeTrue(); - manualBufferSequencer.RunAll(); - - using var emptyFlushSource = new Signal(); - var duplicateBufferSequencer = new DuplicateSequencer(); - var emptyFlushObserver = new RecordingObserver>(); - using var emptyFlushSubscription = emptyFlushSource - .Buffer(TimeSpan.FromMilliseconds(1), duplicateBufferSequencer) - .Subscribe(emptyFlushObserver); + _ = bufferErrorObserver.Error.Should().BeSameAs(bufferError); - emptyFlushSource.OnNext(CoverageValueEleven); - duplicateBufferSequencer.RunAll(); - - emptyFlushObserver.Values.Should().ContainSingle() - .Which.Should().Equal(CoverageValueEleven); - - var manualThrottleSequencer = new ManualSequencer(); - var completedThrottleObserver = new RecordingObserver(); - - using var completedThrottleSubscription = completedThenValue - .Throttle(TimeSpan.FromMilliseconds(1), manualThrottleSequencer) - .Subscribe(completedThrottleObserver); - - manualThrottleSequencer.RunAll(); - completedThrottleObserver.Values.Should().BeEmpty(); - completedThrottleObserver.Completed.Should().BeTrue(); - - using var postStopBufferSubscription = new ScriptedObservable(observer => - { - observer.OnCompleted(); - observer.OnNext(CoverageValueNine); - }) - .Buffer(TimeSpan.FromMilliseconds(1), new ManualSequencer()) - .Subscribe(new RecordingObserver>()); - - using var postStopThrottleSubscription = new ScriptedObservable(observer => - { - observer.OnCompleted(); - observer.OnNext(CoverageValueNine); - }) - .Throttle(TimeSpan.FromMilliseconds(1), new ManualSequencer()) - .Subscribe(new RecordingObserver()); + VerifyBufferCompletionBranches(); + await VerifyThrottleBranches(); } /// ReactiveList extension guards and default dynamic filters should be covered. @@ -665,46 +627,46 @@ public void ReactiveListExtensions_ShouldCoverGuardAndDefaultFilterBranches() IObservable> nullChangeSets = null!; Action nullGroupSource = () => nullChangeSets.GroupByChanges(static item => item); Action nullGroupingSource = () => nullChangeSets.GroupingByChanges(static item => item); - Action nullRefreshSource = () => ReactiveListExtensions.AutoRefresh(null!, propertyName: null); + Action nullRefreshSource = static () => ReactiveListExtensions.AutoRefresh(null!, propertyName: null); var notifyItem = new NotifyItem(1); notifyItem.Raise(nameof(NotifyItem.Value)); - notifyItem.Value.Should().Be(1); + _ = notifyItem.Value.Should().Be(1); - nullGroupSource.Should().Throw().WithParameterName(SourceParameterName); - nullGroupingSource.Should().Throw().WithParameterName(SourceParameterName); - nullRefreshSource.Should().Throw().WithParameterName(SourceParameterName); + _ = nullGroupSource.Should().Throw().WithParameterName(SourceParameterName); + _ = nullGroupingSource.Should().Throw().WithParameterName(SourceParameterName); + _ = nullRefreshSource.Should().Throw().WithParameterName(SourceParameterName); using var changeSets = new Signal>(); Action nullGroupSelector = () => changeSets.GroupByChanges(null!); Action nullGroupingSelector = () => changeSets.GroupingByChanges(null!); - nullGroupSelector.Should().Throw().WithParameterName("keySelector"); - nullGroupingSelector.Should().Throw().WithParameterName("keySelector"); + _ = nullGroupSelector.Should().Throw().WithParameterName("keySelector"); + _ = nullGroupingSelector.Should().Throw().WithParameterName("keySelector"); using var stream = new Signal>(); using var filters = new Signal>(); var received = new List>(); using var subscription = stream.FilterDynamic(filters).Subscribe(received.Add); - stream.OnNext(new CacheNotify(CacheAction.Added, CoverageValueTen)); - stream.OnNext(new CacheNotify(CacheAction.Removed, CoverageValueTwenty)); + stream.OnNext(new(CacheAction.Added, CoverageValueTen)); + stream.OnNext(new(CacheAction.Removed, CoverageValueTwenty)); - received.Select(static item => item.Item).Should().Equal(CoverageValueTen, CoverageValueTwenty); + _ = received.ConvertAll(static item => item.Item).Should().Equal(CoverageValueTen, CoverageValueTwenty); using var pairStream = new Signal>>(); using var pairFilters = new Signal, bool>>(); var pairReceived = new List>>(); using var pairSubscription = pairStream.FilterDynamic(pairFilters).Subscribe(pairReceived.Add); - pairStream.OnNext(new CacheNotify>(CacheAction.Added, new KeyValuePair(1, "one"))); - pairStream.OnNext(new CacheNotify>(CacheAction.Removed, new KeyValuePair(CoverageValueTwo, "two"))); + pairStream.OnNext(new(CacheAction.Added, new(1, "one"))); + pairStream.OnNext(new(CacheAction.Removed, new(CoverageValueTwo, "two"))); - pairReceived.Select(static item => item.Item.Key).Should().Equal(1, CoverageValueTwo); + _ = pairReceived.ConvertAll(static item => item.Item.Key).Should().Equal(1, CoverageValueTwo); using var noMatchBatch = CreateBatch(1, CoverageValueTwo); var noMatchNotification = new CacheNotify(CacheAction.BatchAdded, default, noMatchBatch); - ReactiveListExtensions.FilterBatchByPredicate(noMatchNotification, static item => item > CoverageValueTen).Should().BeNull(); - ReactiveListExtensions.FilterBatch(noMatchNotification, [CoverageValueNinetyNine]).Should().BeNull(); + _ = ReactiveListExtensions.FilterBatchByPredicate(noMatchNotification, static item => item > CoverageValueTen).Should().BeNull(); + _ = ReactiveListExtensions.FilterBatch(noMatchNotification, [CoverageValueNinetyNine]).Should().BeNull(); } /// GroupBy should propagate upstream errors to active groups and to the outer subscriber. @@ -721,8 +683,8 @@ public void GroupBy_ShouldPropagateErrorsToGroupsAndOuterSubscriber() .Subscribe( group => { - group.Key.Should().Be(1); - group.Subscribe(_ => { }, groupErrors.Add, () => { }); + _ = group.Key.Should().Be(1); + _ = group.Subscribe(static _ => { }, groupErrors.Add, static () => { }); }, outerObserver.OnError, outerObserver.OnCompleted); @@ -731,9 +693,9 @@ public void GroupBy_ShouldPropagateErrorsToGroupsAndOuterSubscriber() source.OnNext(changes); source.OnError(upstreamError); - groupErrors.Should().ContainSingle() + _ = groupErrors.Should().ContainSingle() .Which.Should().BeSameAs(upstreamError); - outerObserver.Error.Should().BeSameAs(upstreamError); + _ = outerObserver.Error.Should().BeSameAs(upstreamError); } /// SelectChanges should return the shared empty changeset when the input contains no changes. @@ -741,13 +703,12 @@ public void GroupBy_ShouldPropagateErrorsToGroupsAndOuterSubscriber() public void SelectChanges_ShouldReturnEmptyChangeSetForEmptyInput() { var source = new[] { ChangeSet.Empty }.ToObservable(); - var results = ObservableMixins.ToEnumerable( - ReactiveListExtensions.SelectChanges( - source, - (Func)(static value => value.ToString()))) - .ToList(); + var results = Collect(ObservableMixins.ToEnumerable( + ReactiveListExtensions.SelectChanges( + source, + (Func)(static value => value.ToString())))); - results.Should().ContainSingle() + _ = results.Should().ContainSingle() .Which.Count.Should().Be(0); } @@ -769,7 +730,7 @@ private static PooledBatch CreateBatch(params int[] values) { var array = ArrayPool.Shared.Rent(Math.Max(1, values.Length)); Array.Copy(values, array, values.Length); - return new PooledBatch(array, values.Length); + return new(array, values.Length); } /// Provides CreateStringBatch. @@ -779,7 +740,165 @@ private static PooledBatch CreateStringBatch(params string[] values) { var array = ArrayPool.Shared.Rent(Math.Max(1, values.Length)); Array.Copy(values, array, values.Length); - return new PooledBatch(array, values.Length); + return new(array, values.Length); + } + + /// Materializes a sequence through explicit iteration. + /// The sequence item type. + /// The sequence to materialize. + /// The materialized items. + private static List Collect(IEnumerable source) => new(source); + + /// Materializes a non-generic sequence through explicit iteration. + /// The expected sequence item type. + /// The sequence to materialize. + /// The materialized items. + private static List CollectNonGeneric(IEnumerable source) + { + var result = new List(); + foreach (var item in source) + { + result.Add((T)item); + } + + return result; + } + + /// Projects a sequence through explicit iteration. + /// The source item type. + /// The result item type. + /// The source sequence. + /// The projection applied to each item. + /// The projected items. + private static List Project(IEnumerable source, Func selector) + { + var result = new List(); + foreach (var item in source) + { + result.Add(selector(item)); + } + + return result; + } + + /// Flattens nested sequences through explicit iteration. + /// The nested item type. + /// The nested sequences. + /// The flattened items. + private static List Flatten(IEnumerable> source) + { + var result = new List(); + foreach (var sequence in source) + { + result.AddRange(sequence); + } + + return result; + } + + /// Exercises the completion, delayed flush, and post-stop buffer branches. + private static void VerifyBufferCompletionBranches() + { + var manualBufferSequencer = new ManualSequencer(); + var completedBufferObserver = new RecordingObserver>(); + var completedThenValue = Signal.Create(static observer => + { + observer.OnNext(CoverageValueSeven); + observer.OnCompleted(); + observer.OnNext(CoverageValueEight); + return ReactiveUI.Primitives.Disposables.Scope.Empty; + }); + + using var completedBufferSubscription = completedThenValue + .Buffer(TimeSpan.FromMilliseconds(1), manualBufferSequencer) + .Subscribe(completedBufferObserver); + + _ = completedBufferObserver.Values.Should().ContainSingle() + .Which.Should().Equal(CoverageValueSeven); + _ = completedBufferObserver.Completed.Should().BeTrue(); + manualBufferSequencer.RunAll(); + + using var emptyFlushSource = new Signal(); + var duplicateBufferSequencer = new DuplicateSequencer(); + var emptyFlushObserver = new RecordingObserver>(); + using var emptyFlushSubscription = emptyFlushSource + .Buffer(TimeSpan.FromMilliseconds(1), duplicateBufferSequencer) + .Subscribe(emptyFlushObserver); + + emptyFlushSource.OnNext(CoverageValueEleven); + duplicateBufferSequencer.RunAll(); + + _ = emptyFlushObserver.Values.Should().ContainSingle() + .Which.Should().Equal(CoverageValueEleven); + + using var postStopBufferSubscription = new ScriptedObservable(static observer => + { + observer.OnCompleted(); + observer.OnNext(CoverageValueNine); + }) + .Buffer(TimeSpan.FromMilliseconds(1), new ManualSequencer()) + .Subscribe(new RecordingObserver>()); + } + + /// Exercises the regular, error, completion, and post-stop throttle branches. + /// A task that completes when the asynchronous assertions finish. + private static async Task VerifyThrottleBranches() + { + using var throttleSource = new Signal(); + var throttled = new RecordingObserver(); + using var throttleSubscription = throttleSource.Throttle(TimeSpan.FromMilliseconds(1), Sequencer.Immediate).Subscribe(throttled); + + throttleSource.OnNext(CoverageValueFortyTwo); + throttleSource.OnCompleted(); + + _ = throttled.Values.Should().Contain(CoverageValueFortyTwo); + _ = throttled.Completed.Should().BeTrue(); + + using var throttleErrorSource = new Signal(); + var throttleErrorObserver = new RecordingObserver(); + var throttleError = new InvalidOperationException("throttle"); + using var throttleErrorSubscription = throttleErrorSource.Throttle(TimeSpan.FromMilliseconds(1), Sequencer.Immediate).Subscribe(throttleErrorObserver); + + throttleErrorSource.OnError(throttleError); + _ = throttleErrorObserver.Error.Should().BeSameAs(throttleError); + + await VerifyThrottleCompletionBranches(); + } + + /// Exercises the completion flush and post-stop throttle branches. + /// A task that completes when the asynchronous assertions finish. + private static async Task VerifyThrottleCompletionBranches() + { + var completedThenValue = Signal.Create(static observer => + { + observer.OnNext(CoverageValueSeven); + observer.OnCompleted(); + observer.OnNext(CoverageValueEight); + return ReactiveUI.Primitives.Disposables.Scope.Empty; + }); + var manualThrottleSequencer = new ManualSequencer(); + var completedThrottleObserver = new RecordingObserver(); + + using var completedThrottleSubscription = completedThenValue + .Throttle(TimeSpan.FromMilliseconds(1), manualThrottleSequencer) + .Subscribe(completedThrottleObserver); + + await TUnit.Assertions.Assert.That(completedThrottleObserver.Values.Count).IsEqualTo(1); + await TUnit.Assertions.Assert.That(completedThrottleObserver.Values[0]).IsEqualTo(CoverageValueSeven); + await TUnit.Assertions.Assert.That(completedThrottleObserver.Completed).IsTrue(); + + manualThrottleSequencer.RunAll(); + + await TUnit.Assertions.Assert.That(completedThrottleObserver.Values.Count).IsEqualTo(1); + await TUnit.Assertions.Assert.That(completedThrottleObserver.Values[0]).IsEqualTo(CoverageValueSeven); + + using var postStopThrottleSubscription = new ScriptedObservable(static observer => + { + observer.OnCompleted(); + observer.OnNext(CoverageValueNine); + }) + .Throttle(TimeSpan.FromMilliseconds(1), new ManualSequencer()) + .Subscribe(new RecordingObserver()); } /// Represents a value type that contains a managed reference. @@ -789,6 +908,7 @@ private static PooledBatch CreateStringBatch(params string[] values) /// Provides EventSource. private sealed class EventSource { + /// Raised when the event source is triggered. public event EventHandler? Raised; /// Provides Raise. @@ -838,13 +958,14 @@ public IDisposable Subscribe(IObserver observer) /// Provides ManualSequencer. private sealed class ManualSequencer : ISequencer { + /// The scheduled work items awaiting execution. private readonly Queue _workItems = new(); /// Gets Now. - public DateTimeOffset Now => DateTimeOffset.UtcNow; + public DateTimeOffset Now => Sequencer.Immediate.Now; /// Gets Timestamp. - public long Timestamp => DateTimeOffset.UtcNow.Ticks; + public long Timestamp => Sequencer.Immediate.Timestamp; /// Provides Schedule. /// The item value. @@ -868,13 +989,14 @@ public void RunAll() /// Provides DuplicateSequencer. private sealed class DuplicateSequencer : ISequencer { + /// The scheduled work items awaiting execution. private readonly Queue _workItems = new(); /// Gets Now. - public DateTimeOffset Now => DateTimeOffset.UtcNow; + public DateTimeOffset Now => Sequencer.Immediate.Now; /// Gets Timestamp. - public long Timestamp => DateTimeOffset.UtcNow.Ticks; + public long Timestamp => Sequencer.Immediate.Timestamp; /// Provides Schedule. /// The item value. @@ -909,6 +1031,7 @@ private sealed record Person(int Id, string Name, string Department); /// The Value. private sealed record NotifyItem(int Value) : System.ComponentModel.INotifyPropertyChanged { + /// Raised when a property value changes. public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; /// Provides Raise. diff --git a/src/ReactiveList.Test/DynamicSearchTests.cs b/src/ReactiveList.Test/DynamicSearchTests.cs index 692af13..80673c2 100644 --- a/src/ReactiveList.Test/DynamicSearchTests.cs +++ b/src/ReactiveList.Test/DynamicSearchTests.cs @@ -5,10 +5,8 @@ #if NET8_0_OR_GREATER || NETFRAMEWORK using System; using System.Collections.ObjectModel; -using System.Linq; using System.Threading.Tasks; using CP.Primitives.Collections; -using FluentAssertions; using TUnit.Core; namespace ReactiveList.Test; @@ -67,13 +65,13 @@ public class DynamicSearchTests public async Task SearchByLastName_WhenQueryMatchesShouldReturnMatchingItemsAsync() { // Arrange - var searchText = new BehaviorSignal(string.Empty); + using var searchText = new BehaviorSignal(string.Empty); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedQuery = CreateCompletion(); // Setup search pipeline that rebuilds when search text changes - searchText + using var subscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -87,7 +85,12 @@ public async Task SearchByLastName_WhenQueryMatchesShouldReturnMatchingItemsAsyn } } - processedQuery.TrySetResult(query); + if (!string.Equals(query, SecondaryLastName, StringComparison.Ordinal)) + { + return; + } + + _ = processedQuery.TrySetResult(query); }); // Add test contacts @@ -104,9 +107,9 @@ public async Task SearchByLastName_WhenQueryMatchesShouldReturnMatchingItemsAsyn await processedQuery.Task; // Assert - should find Smith1 and Smith10 - searchResults.Should().HaveCount(ExpectedFilteredMatchCount); - searchResults.Select(c => c.LastName).Should().Contain(SecondaryLastName); - searchResults.Select(c => c.LastName).Should().Contain(ExtendedLastName); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(ExpectedFilteredMatchCount); + await TUnit.Assertions.Assert.That(ContainsLastName(searchResults, SecondaryLastName)).IsTrue(); + await TUnit.Assertions.Assert.That(ContainsLastName(searchResults, ExtendedLastName)).IsTrue(); } /// Search should return matching items when query matches Email. @@ -115,12 +118,12 @@ public async Task SearchByLastName_WhenQueryMatchesShouldReturnMatchingItemsAsyn public async Task SearchByEmail_WhenQueryMatchesShouldReturnMatchingItemsAsync() { // Arrange - var searchText = new BehaviorSignal(string.Empty); + using var searchText = new BehaviorSignal(string.Empty); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedQuery = CreateCompletion(); - searchText + using var subscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -134,7 +137,7 @@ public async Task SearchByEmail_WhenQueryMatchesShouldReturnMatchingItemsAsync() } } - processedQuery.TrySetResult(query); + _ = processedQuery.TrySetResult(query); }); contacts.AddRange( @@ -150,9 +153,9 @@ public async Task SearchByEmail_WhenQueryMatchesShouldReturnMatchingItemsAsync() await processedQuery.Task; // Assert - should find user1@company.com and user10@company.com - searchResults.Should().HaveCount(ExpectedFilteredMatchCount); - searchResults.Select(c => c.Email).Should().Contain(SecondaryEmail); - searchResults.Select(c => c.Email).Should().Contain(ExtendedEmail); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(ExpectedFilteredMatchCount); + await TUnit.Assertions.Assert.That(ContainsEmail(searchResults, SecondaryEmail)).IsTrue(); + await TUnit.Assertions.Assert.That(ContainsEmail(searchResults, ExtendedEmail)).IsTrue(); } /// Empty search query should return all items. @@ -161,12 +164,12 @@ public async Task SearchByEmail_WhenQueryMatchesShouldReturnMatchingItemsAsync() public async Task EmptyQuery_ShouldReturnAllItemsAsync() { // Arrange - var searchText = new BehaviorSignal(string.Empty); + using var searchText = new BehaviorSignal(string.Empty); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedQuery = CreateCompletion(); - searchText + using var subscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -180,7 +183,7 @@ public async Task EmptyQuery_ShouldReturnAllItemsAsync() } } - processedQuery.TrySetResult(query); + _ = processedQuery.TrySetResult(query); }); contacts.AddRange( @@ -195,7 +198,7 @@ public async Task EmptyQuery_ShouldReturnAllItemsAsync() await processedQuery.Task; // Assert - should return all - searchResults.Should().HaveCount(ExpectedCompleteResultCount); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(ExpectedCompleteResultCount); } /// Search should be case insensitive. @@ -204,12 +207,12 @@ public async Task EmptyQuery_ShouldReturnAllItemsAsync() public async Task Search_ShouldBeCaseInsensitiveAsync() { // Arrange - var searchText = new BehaviorSignal(string.Empty); + using var searchText = new BehaviorSignal(string.Empty); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedQuery = CreateCompletion(); - searchText + using var subscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -223,7 +226,7 @@ public async Task Search_ShouldBeCaseInsensitiveAsync() } } - processedQuery.TrySetResult(query); + _ = processedQuery.TrySetResult(query); }); contacts.AddRange( @@ -236,7 +239,7 @@ public async Task Search_ShouldBeCaseInsensitiveAsync() await processedQuery.Task; // Assert - searchResults.Should().HaveCount(1); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(1); // Act - lowercase processedQuery = CreateCompletion(); @@ -244,7 +247,7 @@ public async Task Search_ShouldBeCaseInsensitiveAsync() await processedQuery.Task; // Assert - searchResults.Should().HaveCount(1); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(1); } /// Search results should update when contacts are added after search. @@ -253,13 +256,13 @@ public async Task Search_ShouldBeCaseInsensitiveAsync() public async Task SearchResults_ShouldUpdateWhenContactsAddedAsync() { // Arrange - var searchText = new BehaviorSignal("user1"); + using var searchText = new BehaviorSignal("user1"); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedContactChange = CreateCompletion(); // Subscribe to search text changes - searchText + using var searchSubscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -275,10 +278,10 @@ public async Task SearchResults_ShouldUpdateWhenContactsAddedAsync() }); // Subscribe to contact changes - contacts.Stream + using var contactSubscription = contacts.Stream .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) - .Subscribe(_ => + .Subscribe(notification => { var query = searchText.Value; searchResults.Clear(); @@ -290,23 +293,23 @@ public async Task SearchResults_ShouldUpdateWhenContactsAddedAsync() } } - processedContactChange.TrySetResult(true); + _ = processedContactChange.TrySetResult(true); }); - contacts.Add(new TestContact(PrimaryFirstName, PrimaryLastName, PrimaryEmail)); + contacts.Add(new(PrimaryFirstName, PrimaryLastName, PrimaryEmail)); await processedContactChange.Task; // Assert - no matches yet - searchResults.Should().HaveCount(0); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(0); // Act - add matching contact processedContactChange = CreateCompletion(); - contacts.Add(new TestContact(SecondaryFirstName, SecondaryLastName, SecondaryEmail)); + contacts.Add(new(SecondaryFirstName, SecondaryLastName, SecondaryEmail)); await processedContactChange.Task; // Assert - should now have match - searchResults.Should().HaveCount(1); - searchResults.First().Email.Should().Be(SecondaryEmail); + await TUnit.Assertions.Assert.That(searchResults.Count).IsEqualTo(1); + await TUnit.Assertions.Assert.That(searchResults[0].Email).IsEqualTo(SecondaryEmail); } /// Non-matching query should return empty results. @@ -315,12 +318,12 @@ public async Task SearchResults_ShouldUpdateWhenContactsAddedAsync() public async Task NonMatchingQuery_ShouldReturnEmptyResultsAsync() { // Arrange - var searchText = new BehaviorSignal(string.Empty); + using var searchText = new BehaviorSignal(string.Empty); var contacts = new QuaternaryList(); var searchResults = new ObservableCollection(); var processedQuery = CreateCompletion(); - searchText + using var subscription = searchText .Throttle(TimeSpan.FromMilliseconds(SearchThrottleMilliseconds)) .ObserveOn(Sequencer.Immediate) .Subscribe(query => @@ -339,7 +342,7 @@ public async Task NonMatchingQuery_ShouldReturnEmptyResultsAsync() return; } - processedQuery.TrySetResult(query); + _ = processedQuery.TrySetResult(query); }); contacts.AddRange( @@ -367,8 +370,42 @@ private static bool Matches(TestContact? c, string query) return false; } - return string.IsNullOrWhiteSpace(query) ? true : c.LastName.Contains(query, StringComparison.OrdinalIgnoreCase) || - c.Email.Contains(query, StringComparison.OrdinalIgnoreCase); + return string.IsNullOrWhiteSpace(query) ? true : c.LastName.Contains(query, StringComparison.OrdinalIgnoreCase) + || c.Email.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + /// Determines whether the collection contains a contact with the requested last name. + /// The contacts to inspect. + /// The last name to find. + /// when a matching contact exists; otherwise, . + private static bool ContainsLastName(ObservableCollection contacts, string lastName) + { + foreach (var contact in contacts) + { + if (string.Equals(contact.LastName, lastName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// Determines whether the collection contains a contact with the requested email address. + /// The contacts to inspect. + /// The email address to find. + /// when a matching contact exists; otherwise, . + private static bool ContainsEmail(ObservableCollection contacts, string email) + { + foreach (var contact in contacts) + { + if (string.Equals(contact.Email, email, StringComparison.Ordinal)) + { + return true; + } + } + + return false; } /// Creates an asynchronously completing pipeline signal. diff --git a/src/ReactiveList.Test/EditableListWrapperPoolTests.cs b/src/ReactiveList.Test/EditableListWrapperPoolTests.cs index f860180..2ea8382 100644 --- a/src/ReactiveList.Test/EditableListWrapperPoolTests.cs +++ b/src/ReactiveList.Test/EditableListWrapperPoolTests.cs @@ -32,15 +32,15 @@ public class EditableListWrapperPoolTests public void Rent_ReturnsNewWrapperWhenPoolEmpty() { // Arrange - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); var list = new List { 1, SecondFixtureValue, ThirdFixtureValue }; // Act - using var wrapper = EditableListWrapperPool.Rent(list); + using var wrapper = EditableListWrapperPool.Rent(list); // Assert - wrapper.Should().NotBeNull(); - wrapper.Count.Should().Be(ThirdFixtureValue); + _ = wrapper.Should().NotBeNull(); + _ = wrapper.Count.Should().Be(ThirdFixtureValue); } /// Tests that Return adds wrapper to pool. @@ -48,15 +48,15 @@ public void Rent_ReturnsNewWrapperWhenPoolEmpty() public void Return_AddsWrapperToPool() { // Arrange - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); var list = new List { 1, SecondFixtureValue, ThirdFixtureValue }; - var wrapper = EditableListWrapperPool.Rent(list); + var wrapper = EditableListWrapperPool.Rent(list); // Act wrapper.Dispose(); // Assert - EditableListWrapperPool.GetCurrentPoolSize().Should().Be(1); + _ = EditableListWrapperPool.CurrentPoolSize.Should().Be(1); } /// Tests that Rent reuses wrapper from pool. @@ -64,20 +64,20 @@ public void Return_AddsWrapperToPool() public void Rent_ReusesWrapperFromPool() { // Arrange - EditableListWrapperPool.Clear(); + EditableListWrapperPool.Clear(); var list1 = new List { 1, SecondFixtureValue, ThirdFixtureValue }; var list2 = new List { FourthFixtureValue, FifthFixtureValue }; - var wrapper1 = EditableListWrapperPool.Rent(list1); + var wrapper1 = EditableListWrapperPool.Rent(list1); wrapper1.Dispose(); // Act - var wrapper2 = EditableListWrapperPool.Rent(list2); + var wrapper2 = EditableListWrapperPool.Rent(list2); // Assert - wrapper2.Should().BeSameAs(wrapper1); - wrapper2.Count.Should().Be(SecondFixtureValue); - EditableListWrapperPool.GetCurrentPoolSize().Should().Be(0); + _ = wrapper2.Should().BeSameAs(wrapper1); + _ = wrapper2.Count.Should().Be(SecondFixtureValue); + _ = EditableListWrapperPool.CurrentPoolSize.Should().Be(0); wrapper2.Dispose(); } @@ -88,26 +88,26 @@ public void PooledWrapper_OperationsWork() { // Arrange var list = new List(); - using var wrapper = EditableListWrapperPool.Rent(list); + using var wrapper = EditableListWrapperPool.Rent(list); // Act & Assert wrapper.Add(1); - wrapper.Count.Should().Be(1); + _ = wrapper.Count.Should().Be(1); wrapper.AddRange([SecondFixtureValue, ThirdFixtureValue, FourthFixtureValue]); - wrapper.Count.Should().Be(FourthFixtureValue); + _ = wrapper.Count.Should().Be(FourthFixtureValue); wrapper.Insert(0, 0); - wrapper[0].Should().Be(0); + _ = wrapper[0].Should().Be(0); - wrapper.Remove(SecondFixtureValue); - wrapper.Contains(SecondFixtureValue).Should().BeFalse(); + _ = wrapper.Remove(SecondFixtureValue); + _ = wrapper.Contains(SecondFixtureValue).Should().BeFalse(); wrapper.RemoveAt(0); - wrapper.Count.Should().Be(ThirdFixtureValue); + _ = wrapper.Count.Should().Be(ThirdFixtureValue); wrapper.Clear(); - wrapper.Count.Should().Be(0); + _ = wrapper.Count.Should().Be(0); } /// Tests that wrapper syncs with observable collection. @@ -117,7 +117,7 @@ public void PooledWrapper_SyncsWithObservableCollection() // Arrange var list = new List(); var observable = new ObservableCollection(); - using var wrapper = EditableListWrapperPool.Rent(list, observable); + using var wrapper = EditableListWrapperPool.Rent(list, observable); // Act wrapper.Add(1); @@ -125,7 +125,7 @@ public void PooledWrapper_SyncsWithObservableCollection() wrapper.Add(ThirdFixtureValue); // Assert - observable.Should().BeEquivalentTo([1, SecondFixtureValue, ThirdFixtureValue]); + _ = observable.Should().BeEquivalentTo([1, SecondFixtureValue, ThirdFixtureValue]); } /// Tests that disposed wrapper throws when used. @@ -134,12 +134,12 @@ public void PooledWrapper_ThrowsAfterDispose() { // Arrange var list = new List { 1, SecondFixtureValue, ThirdFixtureValue }; - var wrapper = EditableListWrapperPool.Rent(list); + var wrapper = EditableListWrapperPool.Rent(list); wrapper.Dispose(); // Act & Assert var action = () => wrapper.Add(FourthFixtureValue); - action.Should().Throw(); + _ = action.Should().Throw(); } /// Tests that MaxPoolSize limits pool growth. @@ -147,31 +147,31 @@ public void PooledWrapper_ThrowsAfterDispose() public void MaxPoolSize_LimitsPoolGrowth() { // Arrange - EditableListWrapperPool.Clear(); - var originalMax = EditableListWrapperPool.GetMaxPoolSize(); - EditableListWrapperPool.SetMaxPoolSize(SecondFixtureValue); + EditableListWrapperPool.Clear(); + var originalMax = EditableListWrapperPool.MaxPoolSize; + EditableListWrapperPool.MaxPoolSize = SecondFixtureValue; try { var list = new List(); // Act - create and return 3 wrappers - var w1 = EditableListWrapperPool.Rent(list); - var w2 = EditableListWrapperPool.Rent(list); - var w3 = EditableListWrapperPool.Rent(list); + var w1 = EditableListWrapperPool.Rent(list); + var w2 = EditableListWrapperPool.Rent(list); + var w3 = EditableListWrapperPool.Rent(list); w1.Dispose(); w2.Dispose(); w3.Dispose(); // Assert - only 2 should be pooled - EditableListWrapperPool.GetCurrentPoolSize().Should().BeGreaterThanOrEqualTo(0); - EditableListWrapperPool.GetCurrentPoolSize().Should().BeLessThanOrEqualTo(SecondFixtureValue); + _ = EditableListWrapperPool.CurrentPoolSize.Should().BeGreaterThanOrEqualTo(0); + _ = EditableListWrapperPool.CurrentPoolSize.Should().BeLessThanOrEqualTo(SecondFixtureValue); } finally { - EditableListWrapperPool.SetMaxPoolSize(originalMax); - EditableListWrapperPool.Clear(); + EditableListWrapperPool.MaxPoolSize = originalMax; + EditableListWrapperPool.Clear(); } } @@ -181,13 +181,13 @@ public void IResettable_Reset_ClearsState() { // Arrange var list = new List { 1, SecondFixtureValue, ThirdFixtureValue }; - var wrapper = EditableListWrapperPool.Rent(list); + var wrapper = EditableListWrapperPool.Rent(list); // Act ((IResettable)wrapper).Reset(); // Assert - wrapper.Count.Should().Be(0); + _ = wrapper.Count.Should().Be(0); } } #endif diff --git a/src/ReactiveList.Test/EditableListWrapperTests.cs b/src/ReactiveList.Test/EditableListWrapperTests.cs index 55f1156..4a6900e 100644 --- a/src/ReactiveList.Test/EditableListWrapperTests.cs +++ b/src/ReactiveList.Test/EditableListWrapperTests.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; using FluentAssertions; using TUnit.Core; @@ -36,9 +35,9 @@ public void Constructor_WithListOnly_ShouldInitialize() var list = new List { "one", "two" }; var wrapper = new EditableListWrapper(list); - wrapper.Count.Should().Be(SecondOrdinal); - wrapper[0].Should().Be("one"); - wrapper[1].Should().Be("two"); + _ = wrapper.Count.Should().Be(SecondOrdinal); + _ = wrapper[0].Should().Be("one"); + _ = wrapper[1].Should().Be("two"); } /// Constructor should initialize with list and observable collection. @@ -49,8 +48,8 @@ public void Constructor_WithListAndObservableCollection_ShouldInitialize() var observable = new ObservableCollection(list); var wrapper = new EditableListWrapper(list, observable); - wrapper.Count.Should().Be(SecondOrdinal); - observable.Count.Should().Be(SecondOrdinal); + _ = wrapper.Count.Should().Be(SecondOrdinal); + _ = observable.Count.Should().Be(SecondOrdinal); } /// IsReadOnly should return false. @@ -58,7 +57,7 @@ public void Constructor_WithListAndObservableCollection_ShouldInitialize() public void IsReadOnly_ShouldReturnFalse() { var wrapper = new EditableListWrapper([]); - wrapper.IsReadOnly.Should().BeFalse(); + _ = wrapper.IsReadOnly.Should().BeFalse(); } /// Indexer get should return correct item. @@ -68,7 +67,7 @@ public void Indexer_Get_ShouldReturnCorrectItem() var list = new List { "one", "two", ThirdItem }; var wrapper = new EditableListWrapper(list); - wrapper[1].Should().Be("two"); + _ = wrapper[1].Should().Be("two"); } /// Indexer set should update list only when no observable collection. @@ -80,7 +79,7 @@ public void Indexer_Set_WithoutObservable_ShouldUpdateList() wrapper[0] = UpdatedItem; - list[0].Should().Be(UpdatedItem); + _ = list[0].Should().Be(UpdatedItem); } /// Indexer set should update both list and observable collection. @@ -93,8 +92,8 @@ public void Indexer_Set_WithObservable_ShouldUpdateBoth() wrapper[0] = UpdatedItem; - list[0].Should().Be(UpdatedItem); - observable[0].Should().Be(UpdatedItem); + _ = list[0].Should().Be(UpdatedItem); + _ = observable[0].Should().Be(UpdatedItem); } /// Add should add to list only when no observable collection. @@ -106,7 +105,7 @@ public void Add_WithoutObservable_ShouldAddToList() wrapper.Add("item"); - list.Should().Contain("item"); + _ = list.Should().Contain("item"); } /// Add should add to both list and observable collection. @@ -119,8 +118,8 @@ public void Add_WithObservable_ShouldAddToBoth() wrapper.Add("item"); - list.Should().Contain("item"); - observable.Should().Contain("item"); + _ = list.Should().Contain("item"); + _ = observable.Should().Contain("item"); } /// AddRange should add array items to list. @@ -132,7 +131,7 @@ public void AddRange_WithArray_ShouldAddItems() wrapper.AddRange(["one", "two", ThirdItem]); - list.Should().BeEquivalentTo(["one", "two", ThirdItem]); + _ = list.Should().BeEquivalentTo(["one", "two", ThirdItem]); } /// AddRange should add items to both list and observable collection. @@ -145,8 +144,8 @@ public void AddRange_WithObservable_ShouldAddToBoth() wrapper.AddRange(["one", "two"]); - list.Should().BeEquivalentTo(["one", "two"]); - observable.Should().BeEquivalentTo(["one", "two"]); + _ = list.Should().BeEquivalentTo(["one", "two"]); + _ = observable.Should().BeEquivalentTo(["one", "two"]); } /// AddRange should handle enumerable that is not array. @@ -156,9 +155,10 @@ public void AddRange_WithEnumerable_ShouldAddItems() var list = new List(); var wrapper = new EditableListWrapper(list); - wrapper.AddRange(Enumerable.Range(1, RangeItemCount).Select(i => $"item{i}")); + var items = EnumerateRangeItems(); + wrapper.AddRange(items); - list.Should().BeEquivalentTo(["item1", "item2", "item3"]); + _ = list.Should().BeEquivalentTo(["item1", "item2", "item3"]); } /// Clear should clear list only when no observable collection. @@ -170,7 +170,7 @@ public void Clear_WithoutObservable_ShouldClearList() wrapper.Clear(); - list.Should().BeEmpty(); + _ = list.Should().BeEmpty(); } /// Clear should clear both list and observable collection. @@ -183,8 +183,8 @@ public void Clear_WithObservable_ShouldClearBoth() wrapper.Clear(); - list.Should().BeEmpty(); - observable.Should().BeEmpty(); + _ = list.Should().BeEmpty(); + _ = observable.Should().BeEmpty(); } /// Contains should return true for existing item. @@ -194,7 +194,7 @@ public void Contains_WithExistingItem_ShouldReturnTrue() var list = new List { "one", "two" }; var wrapper = new EditableListWrapper(list); - wrapper.Contains("one").Should().BeTrue(); + _ = wrapper.Contains("one").Should().BeTrue(); } /// Contains should return false for non-existing item. @@ -204,7 +204,7 @@ public void Contains_WithNonExistingItem_ShouldReturnFalse() var list = new List { "one", "two" }; var wrapper = new EditableListWrapper(list); - wrapper.Contains(ThirdItem).Should().BeFalse(); + _ = wrapper.Contains(ThirdItem).Should().BeFalse(); } /// CopyTo should copy items to array. @@ -217,9 +217,9 @@ public void CopyTo_ShouldCopyItemsToArray() wrapper.CopyTo(array, 1); - array[0].Should().BeNull(); - array[1].Should().Be("one"); - array[SecondOrdinal].Should().Be("two"); + _ = array[0].Should().BeNull(); + _ = array[1].Should().Be("one"); + _ = array[SecondOrdinal].Should().Be("two"); } /// GetEnumerator should enumerate items. @@ -229,9 +229,9 @@ public void GetEnumerator_ShouldEnumerateItems() var list = new List { "one", "two", ThirdItem }; var wrapper = new EditableListWrapper(list); - var items = wrapper.ToList(); + var items = new List(wrapper); - items.Should().BeEquivalentTo(["one", "two", ThirdItem]); + _ = items.Should().BeEquivalentTo(["one", "two", ThirdItem]); } /// IndexOf should return correct index. @@ -241,7 +241,7 @@ public void IndexOf_ShouldReturnCorrectIndex() var list = new List { "one", "two", ThirdItem }; var wrapper = new EditableListWrapper(list); - wrapper.IndexOf("two").Should().Be(1); + _ = wrapper.IndexOf("two").Should().Be(1); } /// IndexOf should return -1 for non-existing item. @@ -251,7 +251,7 @@ public void IndexOf_WithNonExistingItem_ShouldReturnNegativeOne() var list = new List { "one", "two" }; var wrapper = new EditableListWrapper(list); - wrapper.IndexOf(ThirdItem).Should().Be(-1); + _ = wrapper.IndexOf(ThirdItem).Should().Be(-1); } /// Insert should insert at correct position without observable. @@ -263,7 +263,7 @@ public void Insert_WithoutObservable_ShouldInsertAtPosition() wrapper.Insert(1, "two"); - list.Should().BeEquivalentTo(["one", "two", ThirdItem], options => options.WithStrictOrdering()); + _ = list.Should().BeEquivalentTo(["one", "two", ThirdItem], static options => options.WithStrictOrdering()); } /// Insert should insert at correct position with observable. @@ -276,8 +276,8 @@ public void Insert_WithObservable_ShouldInsertInBoth() wrapper.Insert(1, "two"); - list.Should().BeEquivalentTo(["one", "two", ThirdItem], options => options.WithStrictOrdering()); - observable.Should().BeEquivalentTo(["one", "two", ThirdItem], options => options.WithStrictOrdering()); + _ = list.Should().BeEquivalentTo(["one", "two", ThirdItem], static options => options.WithStrictOrdering()); + _ = observable.Should().BeEquivalentTo(["one", "two", ThirdItem], static options => options.WithStrictOrdering()); } /// Move should move item to new position. @@ -289,7 +289,7 @@ public void Move_ShouldMoveItemToNewPosition() wrapper.Move(0, SecondOrdinal); - list.Should().BeEquivalentTo(["two", ThirdItem, "one"], options => options.WithStrictOrdering()); + _ = list.Should().BeEquivalentTo(["two", ThirdItem, "one"], static options => options.WithStrictOrdering()); } /// Move should move item in both list and observable collection. @@ -302,8 +302,8 @@ public void Move_WithObservable_ShouldMoveInBoth() wrapper.Move(0, SecondOrdinal); - list.Should().BeEquivalentTo(["two", ThirdItem, "one"], options => options.WithStrictOrdering()); - observable.Should().BeEquivalentTo(["two", ThirdItem, "one"], options => options.WithStrictOrdering()); + _ = list.Should().BeEquivalentTo(["two", ThirdItem, "one"], static options => options.WithStrictOrdering()); + _ = observable.Should().BeEquivalentTo(["two", ThirdItem, "one"], static options => options.WithStrictOrdering()); } /// Move should do nothing when old and new index are same. @@ -315,7 +315,7 @@ public void Move_WhenSameIndex_ShouldDoNothing() wrapper.Move(1, 1); - list.Should().BeEquivalentTo(["one", "two", ThirdItem], options => options.WithStrictOrdering()); + _ = list.Should().BeEquivalentTo(["one", "two", ThirdItem], static options => options.WithStrictOrdering()); } /// Move should throw when old index is out of range. @@ -327,7 +327,7 @@ public void Move_WhenOldIndexOutOfRange_ShouldThrow() var act = () => wrapper.Move(-1, 0); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName("oldIndex"); } @@ -340,7 +340,7 @@ public void Move_WhenNewIndexOutOfRange_ShouldThrow() var act = () => wrapper.Move(0, OutOfRangeIndex); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName("newIndex"); } @@ -353,8 +353,8 @@ public void Remove_ExistingItem_ShouldRemoveAndReturnTrue() var result = wrapper.Remove("two"); - result.Should().BeTrue(); - list.Should().BeEquivalentTo(["one", ThirdItem]); + _ = result.Should().BeTrue(); + _ = list.Should().BeEquivalentTo(["one", ThirdItem]); } /// Remove should return false for non-existing item. @@ -366,8 +366,8 @@ public void Remove_NonExistingItem_ShouldReturnFalse() var result = wrapper.Remove(ThirdItem); - result.Should().BeFalse(); - list.Count.Should().Be(SecondOrdinal); + _ = result.Should().BeFalse(); + _ = list.Count.Should().Be(SecondOrdinal); } /// Remove should remove from both list and observable collection. @@ -378,10 +378,10 @@ public void Remove_WithObservable_ShouldRemoveFromBoth() var observable = new ObservableCollection(list); var wrapper = new EditableListWrapper(list, observable); - wrapper.Remove("two"); + _ = wrapper.Remove("two"); - list.Should().BeEquivalentTo(["one", ThirdItem]); - observable.Should().BeEquivalentTo(["one", ThirdItem]); + _ = list.Should().BeEquivalentTo(["one", ThirdItem]); + _ = observable.Should().BeEquivalentTo(["one", ThirdItem]); } /// RemoveAt should remove item at index without observable. @@ -393,7 +393,7 @@ public void RemoveAt_WithoutObservable_ShouldRemoveAtIndex() wrapper.RemoveAt(1); - list.Should().BeEquivalentTo(["one", ThirdItem]); + _ = list.Should().BeEquivalentTo(["one", ThirdItem]); } /// RemoveAt should remove from both list and observable collection. @@ -406,8 +406,8 @@ public void RemoveAt_WithObservable_ShouldRemoveFromBoth() wrapper.RemoveAt(1); - list.Should().BeEquivalentTo(["one", ThirdItem]); - observable.Should().BeEquivalentTo(["one", ThirdItem]); + _ = list.Should().BeEquivalentTo(["one", ThirdItem]); + _ = observable.Should().BeEquivalentTo(["one", ThirdItem]); } /// Non-generic GetEnumerator should enumerate items. @@ -423,6 +423,16 @@ public void NonGenericGetEnumerator_ShouldEnumerateItems() items.Add(item); } - items.Should().BeEquivalentTo(["one", "two"]); + _ = items.Should().BeEquivalentTo(["one", "two"]); + } + + /// Produces a non-array enumerable for AddRange coverage. + /// The range fixture items. + private static IEnumerable EnumerateRangeItems() + { + for (var item = 1; item <= RangeItemCount; item++) + { + yield return $"item{item}"; + } } } diff --git a/src/ReactiveList.Test/ExtensionCoverageTests.cs b/src/ReactiveList.Test/ExtensionCoverageTests.cs index 2676860..4651492 100644 --- a/src/ReactiveList.Test/ExtensionCoverageTests.cs +++ b/src/ReactiveList.Test/ExtensionCoverageTests.cs @@ -62,7 +62,7 @@ public class ExtensionCoverageTests [Test] public void ChangeSetOperators_ShouldHandleEmptyNoMatchPartialAllAndPreviousValues() { - var source = new Signal>(); + using var source = new Signal>(); var filtered = new List>(); using var filterSubscription = source @@ -70,14 +70,14 @@ public void ChangeSetOperators_ShouldHandleEmptyNoMatchPartialAllAndPreviousValu .Subscribe(filtered.Add); source.OnNext(ChangeSet.Empty); - source.OnNext(new ChangeSet([Change.CreateAdd(1), Change.CreateAdd(CoverageValueThree)])); - source.OnNext(new ChangeSet([Change.CreateAdd(1), Change.CreateAdd(CoverageValueTwo), Change.CreateAdd(CoverageValueFour)])); + source.OnNext(new([Change.CreateAdd(1), Change.CreateAdd(CoverageValueThree)])); + source.OnNext(new([Change.CreateAdd(1), Change.CreateAdd(CoverageValueTwo), Change.CreateAdd(CoverageValueFour)])); var allMatch = new ChangeSet([Change.CreateAdd(CoverageValueSix), Change.CreateAdd(CoverageValueEight)]); source.OnNext(allMatch); - filtered.Should().HaveCount(CoverageValueTwo); - filtered[0].Select(static change => change.Current).Should().Equal(CoverageValueTwo, CoverageValueFour); - filtered[1].Equals(allMatch).Should().BeTrue(); + _ = filtered.Should().HaveCount(CoverageValueTwo); + _ = GetCurrentValues(filtered[0]).Should().Equal(CoverageValueTwo, CoverageValueFour); + _ = filtered[1].Equals(allMatch).Should().BeTrue(); Func itemSelector = static item => $"value-{item}"; var projectedSets = new List>(); @@ -88,10 +88,10 @@ public void ChangeSetOperators_ShouldHandleEmptyNoMatchPartialAllAndPreviousValu .SelectChanges(itemSelector) .Subscribe(projectedSets.Add); - projectedSets.Should().ContainSingle(); - projectedSets[0][0].Previous.Should().Be("value-ten"); - projectedSets[0][0].Current.Should().Be("value-twenty"); - projectedSets[0][1].Previous.Should().BeNull(); + _ = projectedSets.Should().ContainSingle(); + _ = projectedSets[0][0].Previous.Should().Be("value-ten"); + _ = projectedSets[0][0].Current.Should().Be("value-twenty"); + _ = projectedSets[0][1].Previous.Should().BeNull(); Func, string> changeSelector = static change => $"{change.Reason}:{change.Current}"; var flattened = new List(); @@ -102,14 +102,14 @@ public void ChangeSetOperators_ShouldHandleEmptyNoMatchPartialAllAndPreviousValu .SelectChanges(changeSelector) .Subscribe(flattened.Add); - flattened.Should().Equal("Remove:5", "Move:6"); + _ = flattened.Should().Equal("Remove:5", "Move:6"); var emptyFlattened = new List(); using var emptySubscription = Signal.Emit(ChangeSet.Empty) .SelectChanges(static change => change.Current) .Subscribe(emptyFlattened.Add); - emptyFlattened.Should().BeEmpty(); + _ = emptyFlattened.Should().BeEmpty(); } /// Change-set operators should reject null arguments. @@ -119,16 +119,16 @@ public void ChangeSetOperators_WithNullArguments_ShouldThrow() IObservable> nullSource = null!; var whereSource = () => nullSource.WhereChanges(static _ => true); - var wherePredicate = () => ReactiveListExtensions.WhereChanges(Signal.None>(), null!); + var wherePredicate = static () => ReactiveListExtensions.WhereChanges(Signal.None>(), null!); var selectSource = () => ReactiveListExtensions.SelectChanges(nullSource, (Func)(static item => item.ToString())); - var selectItemSelector = () => ReactiveListExtensions.SelectChanges(Signal.None>(), (Func)null!); - var selectChangeSelector = () => ReactiveListExtensions.SelectChanges(Signal.None>(), (Func, string>)null!); - - whereSource.Should().Throw().WithParameterName("source"); - wherePredicate.Should().Throw().WithParameterName("predicate"); - selectSource.Should().Throw().WithParameterName("source"); - selectItemSelector.Should().Throw().WithParameterName("selector"); - selectChangeSelector.Should().Throw().WithParameterName("selector"); + var selectItemSelector = static () => ReactiveListExtensions.SelectChanges(Signal.None>(), (Func)null!); + var selectChangeSelector = static () => ReactiveListExtensions.SelectChanges(Signal.None>(), (Func, string>)null!); + + _ = whereSource.Should().Throw().WithParameterName("source"); + _ = wherePredicate.Should().Throw().WithParameterName("predicate"); + _ = selectSource.Should().Throw().WithParameterName("source"); + _ = selectItemSelector.Should().Throw().WithParameterName("selector"); + _ = selectChangeSelector.Should().Throw().WithParameterName("selector"); } /// Generic dynamic stream filters should handle single, batch, remove, and clear notifications. @@ -143,21 +143,20 @@ public void FilterDynamic_GenericStream_ShouldFilterAddsBatchesAndPassRemovesAnd .FilterDynamic(filters) .Subscribe(received.Add); - stream.OnNext(new CacheNotify(CacheAction.Added, CoverageValueTwo)); - stream.OnNext(new CacheNotify(CacheAction.Added, CoverageValueThree)); - stream.OnNext(new CacheNotify(CacheAction.Removed, CoverageValueThree)); - stream.OnNext(new CacheNotify(CacheAction.BatchOperation, default, CreateBatch(CoverageValueFour, CoverageValueFive, CoverageValueSix))); - stream.OnNext(new CacheNotify(CacheAction.BatchOperation, default, CreateBatch(CoverageValueFive, CoverageValueSeven))); - stream.OnNext(new CacheNotify(CacheAction.Cleared, default)); - - received.Select(static notification => notification.Action) - .Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation, CacheAction.Cleared); - received[0].Item.Should().Be(CoverageValueTwo); - received[1].Item.Should().Be(CoverageValueThree); - received[CoverageValueTwo].Batch.Should().NotBeNull(); + stream.OnNext(new(CacheAction.Added, CoverageValueTwo)); + stream.OnNext(new(CacheAction.Added, CoverageValueThree)); + stream.OnNext(new(CacheAction.Removed, CoverageValueThree)); + stream.OnNext(new(CacheAction.BatchOperation, default, CreateBatch(CoverageValueFour, CoverageValueFive, CoverageValueSix))); + stream.OnNext(new(CacheAction.BatchOperation, default, CreateBatch(CoverageValueFive, CoverageValueSeven))); + stream.OnNext(new(CacheAction.Cleared, default)); + + _ = GetActions(received).Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation, CacheAction.Cleared); + _ = received[0].Item.Should().Be(CoverageValueTwo); + _ = received[1].Item.Should().Be(CoverageValueThree); + _ = received[CoverageValueTwo].Batch.Should().NotBeNull(); var genericBatch = received[CoverageValueTwo].Batch!; - genericBatch.Items.Take(genericBatch.Count).Should().Equal(CoverageValueFour, CoverageValueSix); - received[CoverageValueThree].Action.Should().Be(CacheAction.Cleared); + _ = CopyBatchItems(genericBatch).Should().Equal(CoverageValueFour, CoverageValueSix); + _ = received[CoverageValueThree].Action.Should().Be(CacheAction.Cleared); DisposeBatches(received); } @@ -167,32 +166,36 @@ public void FilterDynamic_GenericStream_ShouldFilterAddsBatchesAndPassRemovesAnd public void FilterDynamic_DictionaryStream_ShouldFilterAddsBatchesAndPassRemoves() { using var stream = new Signal>>(); - using var filters = new BehaviorSignal, bool>>(static item => item.Value.StartsWith("a", StringComparison.Ordinal)); + using var filters = new BehaviorSignal, bool>>(static item => item.Value.Length > 0 && item.Value[0] == 'a'); var received = new List>>(); using var subscription = stream .FilterDynamic(filters) .Subscribe(received.Add); - stream.OnNext(new CacheNotify>(CacheAction.Added, new KeyValuePair(1, AlphaItem))); - stream.OnNext(new CacheNotify>(CacheAction.Added, new KeyValuePair(CoverageValueTwo, "beta"))); - stream.OnNext(new CacheNotify>(CacheAction.Removed, new KeyValuePair(CoverageValueTwo, "beta"))); - stream.OnNext(new CacheNotify>(CacheAction.BatchAdded, default, CreateBatch( - new KeyValuePair(CoverageValueThree, "atlas"), - new KeyValuePair(CoverageValueFour, "beta")))); - stream.OnNext(new CacheNotify>(CacheAction.BatchRemoved, default, CreateBatch( - new KeyValuePair(CoverageValueFive, "apex"), - new KeyValuePair(CoverageValueSix, "cedar")))); - stream.OnNext(new CacheNotify>(CacheAction.Cleared, default)); - - received.Select(static notification => notification.Action) - .Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation, CacheAction.BatchOperation, CacheAction.Cleared); - received[0].Item.Value.Should().Be(AlphaItem); - received[1].Item.Value.Should().Be("beta"); + stream.OnNext(new(CacheAction.Added, new(1, AlphaItem))); + stream.OnNext(new(CacheAction.Added, new(CoverageValueTwo, "beta"))); + stream.OnNext(new(CacheAction.Removed, new(CoverageValueTwo, "beta"))); + stream.OnNext(new(CacheAction.BatchAdded, default, CreateBatch>( + new(CoverageValueThree, "atlas"), + new(CoverageValueFour, "beta")))); + stream.OnNext(new(CacheAction.BatchRemoved, default, CreateBatch>( + new(CoverageValueFive, "apex"), + new(CoverageValueSix, "cedar")))); + stream.OnNext(new(CacheAction.Cleared, default)); + + _ = GetActions(received).Should().Equal( + CacheAction.Added, + CacheAction.Removed, + CacheAction.BatchOperation, + CacheAction.BatchOperation, + CacheAction.Cleared); + _ = received[0].Item.Value.Should().Be(AlphaItem); + _ = received[1].Item.Value.Should().Be("beta"); var addedBatch = received[CoverageValueTwo].Batch!; var removedBatch = received[CoverageValueThree].Batch!; - addedBatch.Items.Take(addedBatch.Count).Should().ContainSingle().Which.Value.Should().Be("atlas"); - removedBatch.Items.Take(removedBatch.Count).Should().ContainSingle().Which.Value.Should().Be("apex"); + _ = CopyBatchItems(addedBatch).Should().ContainSingle().Which.Value.Should().Be("atlas"); + _ = CopyBatchItems(removedBatch).Should().ContainSingle().Which.Value.Should().Be("apex"); DisposeBatches(received); } @@ -202,34 +205,34 @@ public void FilterDynamic_DictionaryStream_ShouldFilterAddsBatchesAndPassRemoves public void BatchFilterHelpers_ShouldReturnNullForNoBatchOrNoMatchesAndFilterMatches() { var noBatch = new CacheNotify(CacheAction.BatchOperation, default); - ReactiveListExtensions.FilterBatchByPredicate(noBatch, static _ => true).Should().BeNull(); - ReactiveListExtensions.FilterBatch(noBatch, [1]).Should().BeNull(); + _ = ReactiveListExtensions.FilterBatchByPredicate(noBatch, static _ => true).Should().BeNull(); + _ = ReactiveListExtensions.FilterBatch(noBatch, [1]).Should().BeNull(); var noMatch = new CacheNotify(CacheAction.BatchOperation, default, CreateBatch(1, CoverageValueThree, CoverageValueFive)); - ReactiveListExtensions.FilterBatchByPredicate(noMatch, static item => item % CoverageValueTwo == 0).Should().BeNull(); + _ = ReactiveListExtensions.FilterBatchByPredicate(noMatch, static item => item % CoverageValueTwo == 0).Should().BeNull(); noMatch.Batch!.Dispose(); var predicateMatch = new CacheNotify(CacheAction.BatchOperation, default, CreateBatch(1, CoverageValueTwo, CoverageValueFour)); var predicateResult = ReactiveListExtensions.FilterBatchByPredicate(predicateMatch, static item => item > 1); - predicateResult.Should().NotBeNull(); - predicateResult!.Batch!.Items.Take(predicateResult.Batch.Count).Should().Equal(CoverageValueTwo, CoverageValueFour); + _ = predicateResult.Should().NotBeNull(); + _ = CopyBatchItems(predicateResult!.Batch!).Should().Equal(CoverageValueTwo, CoverageValueFour); predicateMatch.Batch!.Dispose(); - predicateResult.Batch.Dispose(); + predicateResult.Batch!.Dispose(); var setMatch = new CacheNotify(CacheAction.BatchOperation, default, CreateBatch(1, CoverageValueTwo, CoverageValueThree)); var setResult = ReactiveListExtensions.FilterBatch(setMatch, [1, CoverageValueThree]); - setResult.Should().NotBeNull(); - setResult!.Batch!.Items.Take(setResult.Batch.Count).Should().Equal(1, CoverageValueThree); + _ = setResult.Should().NotBeNull(); + _ = CopyBatchItems(setResult!.Batch!).Should().Equal(1, CoverageValueThree); setMatch.Batch!.Dispose(); - setResult.Batch.Dispose(); + setResult.Batch!.Dispose(); } /// Grouping and auto-refresh operators should emit grouped changes and property refreshes. [Test] public void GroupingAndAutoRefresh_ShouldGroupChangesAndEmitPropertyRefreshes() { - var north = new MutableItem(1, NorthRegion, AlphaItem); - var south = new MutableItem(CoverageValueTwo, SouthRegion, "beta"); + var north = new MutableItem(NorthRegion, AlphaItem); + var south = new MutableItem(SouthRegion, "beta"); var changes = new ChangeSet([ Change.CreateAdd(north), Change.CreateAdd(south), @@ -241,9 +244,9 @@ public void GroupingAndAutoRefresh_ShouldGroupChangesAndEmitPropertyRefreshes() .GroupingByChanges(static item => item.Region) .Subscribe(groupings.Add); - groupings.Should().HaveCount(CoverageValueTwo); - groupings.Single(static group => group.Key == NorthRegion).Should().HaveCount(CoverageValueTwo); - groupings.Single(static group => group.Key == SouthRegion).Should().ContainSingle(); + _ = groupings.Should().HaveCount(CoverageValueTwo); + _ = FindGrouping(groupings, NorthRegion).Should().HaveCount(CoverageValueTwo); + _ = FindGrouping(groupings, SouthRegion).Should().ContainSingle(); var groupedValues = new Dictionary>(); using var groupBySubscription = Signal.Emit(changes) @@ -252,11 +255,11 @@ public void GroupingAndAutoRefresh_ShouldGroupChangesAndEmitPropertyRefreshes() { var valuesForGroup = new List(); groupedValues[group.Key] = valuesForGroup; - group.Subscribe(valuesForGroup.Add); + _ = group.Subscribe(valuesForGroup.Add); }); - groupedValues[NorthRegion].Should().HaveCount(CoverageValueTwo); - groupedValues[SouthRegion].Should().ContainSingle().Which.Should().Be(south); + _ = groupedValues[NorthRegion].Should().HaveCount(CoverageValueTwo); + _ = groupedValues[SouthRegion].Should().ContainSingle().Which.Should().Be(south); using var refreshSource = new Signal>(); var received = new List>(); @@ -264,26 +267,26 @@ public void GroupingAndAutoRefresh_ShouldGroupChangesAndEmitPropertyRefreshes() .AutoRefresh(nameof(MutableItem.Name)) .Subscribe(received.Add); - refreshSource.OnNext(new ChangeSet(Change.CreateAdd(north, 0))); + refreshSource.OnNext(new(Change.CreateAdd(north, 0))); north.RaisePropertyChanged(nameof(MutableItem.Region)); north.RaisePropertyChanged(nameof(MutableItem.Name)); - received.Should().HaveCount(CoverageValueTwo); - received[0][0].Reason.Should().Be(ChangeReason.Add); - received[1][0].Reason.Should().Be(ChangeReason.Refresh); - received[1][0].Current.Should().Be(north); - received[1][0].CurrentIndex.Should().Be(0); + _ = received.Should().HaveCount(CoverageValueTwo); + _ = received[0][0].Reason.Should().Be(ChangeReason.Add); + _ = received[1][0].Reason.Should().Be(ChangeReason.Refresh); + _ = received[1][0].Current.Should().Be(north); + _ = received[1][0].CurrentIndex.Should().Be(0); var allProperties = new List>(); using var allSubscription = refreshSource .AutoRefresh(null) .Subscribe(allProperties.Add); - refreshSource.OnNext(new ChangeSet(Change.CreateUpdate(south, south, 1))); + refreshSource.OnNext(new(Change.CreateUpdate(south, south, 1))); south.RaisePropertyChanged(nameof(MutableItem.Region)); - allProperties.Should().HaveCount(CoverageValueTwo); - allProperties[1][0].Reason.Should().Be(ChangeReason.Refresh); + _ = allProperties.Should().HaveCount(CoverageValueTwo); + _ = allProperties[1][0].Reason.Should().Be(ChangeReason.Refresh); } /// Source auto-refresh expression overload should validate property expressions and return the source stream. @@ -297,14 +300,14 @@ public void AutoRefresh_SourceExpression_ShouldValidatePropertyAndReturnSourceSt .AutoRefresh(static item => item.Name) .Subscribe(received.Add); - var item = new MutableItem(1, NorthRegion, AlphaItem); + var item = new MutableItem(NorthRegion, AlphaItem); list.Add(item); - received.Should().ContainSingle(); - received[0].Action.Should().Be(CacheAction.Added); + _ = received.Should().ContainSingle(); + _ = received[0].Action.Should().Be(CacheAction.Added); var invalidExpression = () => list.AutoRefresh(static _ => new object()); - invalidExpression.Should().Throw().WithParameterName("property"); + _ = invalidExpression.Should().Throw().WithParameterName("property"); } /// View factory extensions should create filtered, sorted, grouped, and dynamic views. @@ -316,25 +319,21 @@ public async Task ViewFactoryExtensions_ShouldCreateViewsWithFallbackSchedulersA list.AddRange([CoverageValueThree, 1, CoverageValueTwo]); using var filtered = list.CreateView(static item => item > 1, scheduler: null, throttleMs: 0); - filtered.Items.Should().BeEquivalentTo([CoverageValueTwo, CoverageValueThree]); + _ = filtered.Items.Should().BeEquivalentTo([CoverageValueTwo, CoverageValueThree]); using var dynamicFilters = new BehaviorSignal>(static item => item == 1); using var dynamicFiltered = list.CreateView(dynamicFilters, scheduler: null, throttleMs: 0); await WaitForPipeline(); - dynamicFiltered.Items.Should().Equal(1); + _ = dynamicFiltered.Items.Should().Equal(1); using var sorted = list.SortBy(static item => item, descending: true, scheduler: null, throttleMs: 0); - sorted.Items.Should().Equal(CoverageValueThree, CoverageValueTwo, 1); + _ = sorted.Items.Should().Equal(CoverageValueThree, CoverageValueTwo, 1); using var grouped = list.GroupBy(static item => item % CoverageValueTwo, scheduler: null, throttleMs: 0); - grouped.Keys.Should().BeEquivalentTo([0, 1]); + _ = grouped.Keys.Should().BeEquivalentTo([0, 1]); #if NET8_0_OR_GREATER || NETFRAMEWORK - using var quaternary = new QuaternaryList - { - AppleItem, - "banana", - }; + using var quaternary = new QuaternaryList { AppleItem, "banana" }; using var query = new BehaviorSignal("app"); using var queryView = quaternary.CreateView( @@ -342,11 +341,11 @@ public async Task ViewFactoryExtensions_ShouldCreateViewsWithFallbackSchedulersA static (queryText, item) => item.StartsWith(queryText, StringComparison.Ordinal), Sequencer.Immediate, throttleMs: 0); - queryView.Items.Should().Equal(AppleItem); + _ = queryView.Items.Should().Equal(AppleItem); using var sourceFilters = new BehaviorSignal>(static item => item.Contains("a", StringComparison.Ordinal)); using var sourceView = quaternary.CreateView(sourceFilters, Sequencer.Immediate, throttleMs: 0); - sourceView.Items.Should().BeEquivalentTo([AppleItem, "banana"]); + _ = sourceView.Items.Should().BeEquivalentTo([AppleItem, "banana"]); #endif } @@ -376,7 +375,7 @@ public async Task QuaternaryListSecondaryIndexFilter_ShouldPassMatchingSingleAnd list.Add(north); list.Add(east); list.Add(south); - list.Remove(north); + _ = list.Remove(north); var northBatch = new IndexedItem(CoverageValueFour, NorthRegion); var eastBatch = new IndexedItem(CoverageValueFive, "east"); @@ -386,17 +385,15 @@ public async Task QuaternaryListSecondaryIndexFilter_ShouldPassMatchingSingleAnd await WaitForPipeline(); - singleKey.Select(static notification => notification.Action) - .Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation, CacheAction.BatchOperation); - singleKey[0].Item.Should().Be(north); - singleKey[1].Item.Should().Be(north); + _ = GetActions(singleKey).Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation, CacheAction.BatchOperation); + _ = singleKey[0].Item.Should().Be(north); + _ = singleKey[1].Item.Should().Be(north); var singleAddedBatch = singleKey[CoverageValueTwo].Batch!; var singleRemovedBatch = singleKey[CoverageValueThree].Batch!; - singleAddedBatch.Items.Take(singleAddedBatch.Count).Should().ContainSingle().Which.Should().Be(northBatch); - singleRemovedBatch.Items.Take(singleRemovedBatch.Count).Should().ContainSingle().Which.Should().Be(northBatch); + _ = CopyBatchItems(singleAddedBatch).Should().ContainSingle().Which.Should().Be(northBatch); + _ = CopyBatchItems(singleRemovedBatch).Should().ContainSingle().Which.Should().Be(northBatch); - multipleKeys.Select(static notification => notification.Action) - .Should().Equal( + _ = GetActions(multipleKeys).Should().Equal( CacheAction.Added, CacheAction.Added, CacheAction.Removed, @@ -404,8 +401,8 @@ public async Task QuaternaryListSecondaryIndexFilter_ShouldPassMatchingSingleAnd CacheAction.BatchOperation); var multipleAddedBatch = multipleKeys[CoverageValueThree].Batch!; var multipleRemovedBatch = multipleKeys[CoverageValueFour].Batch!; - multipleAddedBatch.Items.Take(multipleAddedBatch.Count).Should().BeEquivalentTo([northBatch, eastBatch]); - multipleRemovedBatch.Items.Take(multipleRemovedBatch.Count).Should().BeEquivalentTo([northBatch, eastBatch]); + _ = CopyBatchItems(multipleAddedBatch).Should().BeEquivalentTo([northBatch, eastBatch]); + _ = CopyBatchItems(multipleRemovedBatch).Should().BeEquivalentTo([northBatch, eastBatch]); DisposeBatches(singleKey); DisposeBatches(multipleKeys); @@ -435,7 +432,7 @@ public async Task QuaternaryDictionarySecondaryIndexFilter_ShouldPassMatchingSin dictionary.Add(1, north); dictionary.Add(CoverageValueTwo, east); dictionary.Add(CoverageValueThree, south); - dictionary.Remove(1); + _ = dictionary.Remove(1); var northBatch = new KeyValuePair(CoverageValueFour, new IndexedItem(CoverageValueFour, NorthRegion)); var eastBatch = new KeyValuePair(CoverageValueFive, new IndexedItem(CoverageValueFive, "east")); @@ -444,17 +441,15 @@ public async Task QuaternaryDictionarySecondaryIndexFilter_ShouldPassMatchingSin await WaitForPipeline(); - singleKey.Select(static notification => notification.Action) - .Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation); - singleKey[0].Item.Value.Should().Be(north); - singleKey[1].Item.Value.Should().Be(north); + _ = GetActions(singleKey).Should().Equal(CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation); + _ = singleKey[0].Item.Value.Should().Be(north); + _ = singleKey[1].Item.Value.Should().Be(north); var dictionarySingleBatch = singleKey[CoverageValueTwo].Batch!; - dictionarySingleBatch.Items.Take(dictionarySingleBatch.Count).Should().ContainSingle().Which.Should().Be(northBatch); + _ = CopyBatchItems(dictionarySingleBatch).Should().ContainSingle().Which.Should().Be(northBatch); - multipleKeys.Select(static notification => notification.Action) - .Should().Equal(CacheAction.Added, CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation); + _ = GetActions(multipleKeys).Should().Equal(CacheAction.Added, CacheAction.Added, CacheAction.Removed, CacheAction.BatchOperation); var dictionaryMultipleBatch = multipleKeys[CoverageValueThree].Batch!; - dictionaryMultipleBatch.Items.Take(dictionaryMultipleBatch.Count).Should().BeEquivalentTo([northBatch, eastBatch]); + _ = CopyBatchItems(dictionaryMultipleBatch).Should().BeEquivalentTo([northBatch, eastBatch]); DisposeBatches(singleKey); DisposeBatches(multipleKeys); @@ -463,7 +458,7 @@ public async Task QuaternaryDictionarySecondaryIndexFilter_ShouldPassMatchingSin /// Provides WaitForPipeline. /// The result. - private static async Task WaitForPipeline() => await Task.Delay(CoverageTimeoutMilliseconds); + private static Task WaitForPipeline() => Task.Delay(CoverageTimeoutMilliseconds); /// Provides CreateBatch. /// The T type. @@ -473,7 +468,7 @@ private static PooledBatch CreateBatch(params T[] items) { var array = ArrayPool.Shared.Rent(items.Length); Array.Copy(items, array, items.Length); - return new PooledBatch(array, items.Length); + return new(array, items.Length); } /// Provides DisposeBatches. @@ -481,31 +476,93 @@ private static PooledBatch CreateBatch(params T[] items) /// The notifications value. private static void DisposeBatches(IEnumerable> notifications) { - foreach (var batch in notifications.Select(static notification => notification.Batch).Where(static batch => batch is not null)) + foreach (var notification in notifications) + { + notification.Batch?.Dispose(); + } + } + + /// Copies the active items from a pooled batch. + /// The item type. + /// The pooled batch. + /// The active batch items. + private static List CopyBatchItems(PooledBatch batch) + { + var items = new List(batch.Count); + for (var index = 0; index < batch.Count; index++) + { + items.Add(batch.Items[index]); + } + + return items; + } + + /// Gets the actions from a notification list. + /// The notification item type. + /// The notifications. + /// The actions. + private static List GetActions(List> notifications) + { + var actions = new List(notifications.Count); + foreach (var notification in notifications) { - batch!.Dispose(); + actions.Add(notification.Action); } + + return actions; + } + + /// Gets current values from a change set. + /// The item type. + /// The changes. + /// The current values. + private static List GetCurrentValues(ChangeSet changes) + { + var values = new List(changes.Count); + foreach (var change in changes) + { + values.Add(change.Current); + } + + return values; + } + + /// Finds a grouping by key. + /// The key type. + /// The element type. + /// The available groupings. + /// The requested key. + /// The matching grouping. + private static IGrouping FindGrouping( + IEnumerable> groupings, + TKey key) + { + foreach (var grouping in groupings) + { + if (EqualityComparer.Default.Equals(grouping.Key, key)) + { + return grouping; + } + } + + throw new InvalidOperationException("The requested grouping was not found."); } /// Provides MutableItem. private sealed class MutableItem : INotifyPropertyChanged { /// Initializes a new instance of the class. - /// The id value. /// The region value. /// The name value. - public MutableItem(int id, string region, string name) + public MutableItem(string region, string name) { - Id = id; Region = region; Name = name; } + /// public event PropertyChangedEventHandler? PropertyChanged; - /// Gets Id. - public int Id { get; } - /// Gets Name. public string Name { get; } diff --git a/src/ReactiveList.Test/GlobalUsings.cs b/src/ReactiveList.Test/GlobalUsings.cs deleted file mode 100644 index 9023a7c..0000000 --- a/src/ReactiveList.Test/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved. -// Chris Pulman and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -global using CP.Primitives.Internal; -global using ReactiveUI.Primitives; -global using ReactiveUI.Primitives.Concurrency; -global using ReactiveUI.Primitives.Signals; diff --git a/src/ReactiveList.Test/ModuleInitializerAttribute.cs b/src/ReactiveList.Test/ModuleInitializerAttribute.cs index 9c47394..7c41219 100644 --- a/src/ReactiveList.Test/ModuleInitializerAttribute.cs +++ b/src/ReactiveList.Test/ModuleInitializerAttribute.cs @@ -1,10 +1,19 @@ // Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved. // Chris Pulman and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. + +using System; + #if NETFRAMEWORK namespace System.Runtime.CompilerServices; /// Indicates that a method is a module initializer. [AttributeUsage(AttributeTargets.Method)] public sealed class ModuleInitializerAttribute : Attribute; +#else +namespace ReactiveList.Test.Compatibility; + +/// Identifies the platform-provided module initializer attribute. +[AttributeUsage(AttributeTargets.Method)] +internal sealed class ModuleInitializerAttribute : Attribute; #endif diff --git a/src/ReactiveList.Test/PooledBatchTests.cs b/src/ReactiveList.Test/PooledBatchTests.cs index b288e24..235706a 100644 --- a/src/ReactiveList.Test/PooledBatchTests.cs +++ b/src/ReactiveList.Test/PooledBatchTests.cs @@ -43,10 +43,10 @@ public void Constructor_ShouldInitializeProperties() array[1] = PairCount; array[PairCount] = TripleCount; - var batch = new PooledBatch(array, TripleCount); + using var batch = new PooledBatch(array, TripleCount); - batch.Items.Should().BeSameAs(array); - batch.Count.Should().Be(TripleCount); + _ = batch.Items.Should().BeSameAs(array); + _ = batch.Count.Should().Be(TripleCount); } /// Items should be accessible before dispose. @@ -57,10 +57,10 @@ public void Items_BeforeDispose_ShouldBeAccessible() array[0] = "hello"; array[1] = "world"; - var batch = new PooledBatch(array, PairCount); + using var batch = new PooledBatch(array, PairCount); - batch.Items[0].Should().Be("hello"); - batch.Items[1].Should().Be("world"); + _ = batch.Items[0].Should().Be("hello"); + _ = batch.Items[1].Should().Be("world"); } /// Count should reflect actual item count. @@ -69,9 +69,9 @@ public void Count_ShouldReflectActualItemCount() { var array = ArrayPool.Shared.Rent(LargeArrayCapacity); - var batch = new PooledBatch(array, ArbitraryBatchCount); + using var batch = new PooledBatch(array, ArbitraryBatchCount); - batch.Count.Should().Be(ArbitraryBatchCount); + _ = batch.Count.Should().Be(ArbitraryBatchCount); } /// Dispose should return array to pool. @@ -83,7 +83,7 @@ public void Dispose_ShouldReturnArrayToPool() var act = batch.Dispose; - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// Multiple dispose calls should be safe. @@ -96,7 +96,7 @@ public void Dispose_MultipleCalls_ShouldBeSafe() batch.Dispose(); var act = batch.Dispose; - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// Record equality should work correctly. @@ -107,8 +107,8 @@ public void RecordEquality_ShouldWorkCorrectly() var batch1 = new PooledBatch(array, DefaultBatchCount); var batch2 = new PooledBatch(array, DefaultBatchCount); - batch1.Should().Be(batch2); - (batch1 == batch2).Should().BeTrue(); + _ = batch1.Should().Be(batch2); + _ = (batch1 == batch2).Should().BeTrue(); // Clean up batch1.Dispose(); @@ -122,8 +122,8 @@ public void RecordInequality_DifferentCounts_ShouldNotBeEqual() var batch1 = new PooledBatch(array, DefaultBatchCount); var batch2 = new PooledBatch(array, ArrayCapacity); - batch1.Should().NotBe(batch2); - (batch1 != batch2).Should().BeTrue(); + _ = batch1.Should().NotBe(batch2); + _ = (batch1 != batch2).Should().BeTrue(); // Clean up - only one dispose needed since same array batch1.Dispose(); @@ -139,9 +139,9 @@ public void PooledBatch_WithReferenceTypes_ShouldWork() using var batch = new PooledBatch(array, PairCount); - batch.Items[0].Should().Be("test"); - batch.Items[1].Should().Be("data"); - batch.Count.Should().Be(PairCount); + _ = batch.Items[0].Should().Be("test"); + _ = batch.Items[1].Should().Be("data"); + _ = batch.Count.Should().Be(PairCount); } /// PooledBatch with zero count should be valid. @@ -152,8 +152,8 @@ public void PooledBatch_WithZeroCount_ShouldBeValid() using var batch = new PooledBatch(array, 0); - batch.Count.Should().Be(0); - batch.Items.Should().NotBeNull(); + _ = batch.Count.Should().Be(0); + _ = batch.Items.Should().NotBeNull(); } /// PooledBatch should support with expression. @@ -165,8 +165,8 @@ public void PooledBatch_WithExpression_ShouldWork() var batch2 = batch1 with { Count = UpdatedBatchCount }; - batch2.Items.Should().BeSameAs(array); - batch2.Count.Should().Be(UpdatedBatchCount); + _ = batch2.Items.Should().BeSameAs(array); + _ = batch2.Count.Should().Be(UpdatedBatchCount); // Clean up batch1.Dispose(); @@ -182,7 +182,7 @@ public void GetHashCode_ShouldBeConsistent() var hash1 = batch.GetHashCode(); var hash2 = batch.GetHashCode(); - hash1.Should().Be(hash2); + _ = hash1.Should().Be(hash2); batch.Dispose(); } @@ -196,7 +196,7 @@ public void ToString_ShouldReturnMeaningfulRepresentation() var result = batch.ToString(); - result.Should().Contain("PooledBatch"); - result.Should().Contain("5"); + _ = result.Should().Contain("PooledBatch"); + _ = result.Should().Contain("5"); } } diff --git a/src/ReactiveList.Test/QuadCollectionCoverageTests.cs b/src/ReactiveList.Test/QuadCollectionCoverageTests.cs index d6c3c5e..f97c97d 100644 --- a/src/ReactiveList.Test/QuadCollectionCoverageTests.cs +++ b/src/ReactiveList.Test/QuadCollectionCoverageTests.cs @@ -123,66 +123,69 @@ public class QuadCollectionCoverageTests [Test] public void QuadList_ShouldSupportMutationAndEnumerationPaths() { - using var list = new QuadList(); + var list = new QuadList(); list.AddRange(ReadOnlySpan.Empty); - Enumerable.Range(0, QuadListItemCount).ToList().ForEach(list.Add); + for (var value = 0; value < QuadListItemCount; value++) + { + list.Add(value); + } - list.Count.Should().Be(QuadListItemCount); - list[QuadListMutationIndex].Should().Be(QuadListMutationIndex); + _ = list.Count.Should().Be(QuadListItemCount); + _ = list[QuadListMutationIndex].Should().Be(QuadListMutationIndex); list[QuadListMutationIndex] = QuadListReplacementValue; - list[QuadListMutationIndex].Should().Be(QuadListReplacementValue); - list.Contains(QuadListReplacementValue).Should().BeTrue(); - list.IndexOf(QuadListReplacementValue).Should().Be(QuadListMutationIndex); + _ = list[QuadListMutationIndex].Should().Be(QuadListReplacementValue); + _ = list.Contains(QuadListReplacementValue).Should().BeTrue(); + _ = list.IndexOf(QuadListReplacementValue).Should().Be(QuadListMutationIndex); - list.Remove(QuadListReplacementValue).Should().BeTrue(); - list.Remove(MissingLookupValue).Should().BeFalse(); + _ = list.Remove(QuadListReplacementValue).Should().BeTrue(); + _ = list.Remove(MissingLookupValue).Should().BeFalse(); list.RemoveAt(list.Count - 1); var copied = new int[list.Count + CopyPadding]; list.CopyTo(copied, 1); - copied[1].Should().Be(0); + _ = copied[1].Should().Be(0); var structEnumerator = list.GetEnumerator(); var matchingStructEnumerator = list.GetEnumerator(); - (structEnumerator == matchingStructEnumerator).Should().BeTrue(); - (structEnumerator != matchingStructEnumerator).Should().BeFalse(); - structEnumerator.Equals((object)matchingStructEnumerator).Should().BeTrue(); - structEnumerator.Equals(new object()).Should().BeFalse(); - structEnumerator.GetHashCode().Should().NotBe(0); - structEnumerator.MoveNext().Should().BeTrue(); - (structEnumerator != matchingStructEnumerator).Should().BeTrue(); - (structEnumerator == matchingStructEnumerator).Should().BeFalse(); - structEnumerator.Current.Should().Be(0); + _ = (structEnumerator == matchingStructEnumerator).Should().BeTrue(); + _ = (structEnumerator != matchingStructEnumerator).Should().BeFalse(); + _ = structEnumerator.Equals((object)matchingStructEnumerator).Should().BeTrue(); + _ = structEnumerator.Equals(new object()).Should().BeFalse(); + _ = structEnumerator.GetHashCode().Should().NotBe(0); + _ = structEnumerator.MoveNext().Should().BeTrue(); + _ = (structEnumerator != matchingStructEnumerator).Should().BeTrue(); + _ = (structEnumerator == matchingStructEnumerator).Should().BeFalse(); + _ = structEnumerator.Current.Should().Be(0); while (structEnumerator.MoveNext()) { _ = structEnumerator.Current; } - structEnumerator.MoveNext().Should().BeFalse(); + _ = structEnumerator.MoveNext().Should().BeFalse(); using var enumerator = ((IEnumerable)list).GetEnumerator(); - enumerator.MoveNext().Should().BeTrue(); - enumerator.Current.Should().Be(0); + _ = enumerator.MoveNext().Should().BeTrue(); + _ = enumerator.Current.Should().Be(0); enumerator.Reset(); - enumerator.MoveNext().Should().BeTrue(); - ((IEnumerator)enumerator).Current.Should().Be(0); + _ = enumerator.MoveNext().Should().BeTrue(); + _ = ((IEnumerator)enumerator).Current.Should().Be(0); while (enumerator.MoveNext()) { _ = enumerator.Current; } - enumerator.MoveNext().Should().BeFalse(); + _ = enumerator.MoveNext().Should().BeFalse(); var nonGenericEnumerator = ((IEnumerable)list).GetEnumerator(); - nonGenericEnumerator.MoveNext().Should().BeTrue(); - nonGenericEnumerator.Current.Should().Be(0); + _ = nonGenericEnumerator.MoveNext().Should().BeTrue(); + _ = nonGenericEnumerator.Current.Should().Be(0); - list.AsSpan().ToArray().Should().Contain(HighestRemainingValue); - list.AsSpan().ToArray().Should().NotContain(RemovedTailValue); + _ = list.AsSpan().ToArray().Should().Contain(HighestRemainingValue); + _ = list.AsSpan().ToArray().Should().NotContain(RemovedTailValue); list.Clear(); - list.Count.Should().Be(0); + _ = list.Count.Should().Be(0); list.Dispose(); list.Dispose(); } @@ -198,78 +201,79 @@ public void QuadList_InvalidIndexes_ShouldThrow() Action setTooHigh = () => list[1] = "missing"; Action removeTooHigh = () => list.RemoveAt(1); - getNegative.Should().Throw(); - getTooHigh.Should().Throw(); - setTooHigh.Should().Throw(); - removeTooHigh.Should().Throw(); + _ = getNegative.Should().Throw(); + _ = getTooHigh.Should().Throw(); + _ = setTooHigh.Should().Throw(); + _ = removeTooHigh.Should().Throw(); } /// Verifies QuadDictionary collision handling, ref updates, free-list reuse, and wrapper enumeration. [Test] public void QuadDictionary_ShouldHandleCollisionsAndRemovedSlots() { - using var dictionary = new QuadDictionary(new ConstantHashStringComparer()); - - dictionary.TryAdd("one", 1).Should().BeTrue(); - dictionary.TryAdd("two", SecondDictionaryValue).Should().BeTrue(); - dictionary.TryAdd("three", InitialDictionaryCount).Should().BeTrue(); - dictionary.TryAdd("two", DuplicateDictionaryValue).Should().BeFalse(); - dictionary.Count.Should().Be(InitialDictionaryCount); - - dictionary.Remove("two", out var removedMiddle).Should().BeTrue(); - removedMiddle.Should().Be(SecondDictionaryValue); - dictionary.Remove("three").Should().BeTrue(); - dictionary.Remove("missing", out var missingValue).Should().BeFalse(); - missingValue.Should().Be(default(int)); - - dictionary.TryAdd("four", FourthDictionaryValue).Should().BeTrue(); - dictionary["one"].Should().Be(1); + var dictionary = new QuadDictionary(new ConstantHashStringComparer()); + + _ = dictionary.TryAdd("one", 1).Should().BeTrue(); + _ = dictionary.TryAdd("two", SecondDictionaryValue).Should().BeTrue(); + _ = dictionary.TryAdd("three", InitialDictionaryCount).Should().BeTrue(); + _ = dictionary.TryAdd("two", DuplicateDictionaryValue).Should().BeFalse(); + _ = dictionary.Count.Should().Be(InitialDictionaryCount); + + _ = dictionary.Remove("two", out var removedMiddle).Should().BeTrue(); + _ = removedMiddle.Should().Be(SecondDictionaryValue); + _ = dictionary.Remove("three").Should().BeTrue(); + _ = dictionary.Remove("missing", out var missingValue).Should().BeFalse(); + _ = missingValue.Should().Be(default(int)); + + _ = dictionary.TryAdd("four", FourthDictionaryValue).Should().BeTrue(); + _ = dictionary["one"].Should().Be(1); dictionary["one"] = UpdatedDictionaryValue; - dictionary["one"].Should().Be(UpdatedDictionaryValue); + _ = dictionary["one"].Should().Be(UpdatedDictionaryValue); ref var valueRef = ref dictionary.GetValueRefOrAddDefault("five", out var existed); - existed.Should().BeFalse(); + _ = existed.Should().BeFalse(); valueRef = FifthDictionaryValue; ref var existingRef = ref dictionary.GetValueRefOrAddDefault("five", out existed); - existed.Should().BeTrue(); - existingRef.Should().Be(FifthDictionaryValue); + _ = existed.Should().BeTrue(); + _ = existingRef.Should().Be(FifthDictionaryValue); - dictionary.Keys.Should().BeEquivalentTo(["one", "four", "five"]); - dictionary.Values.Should().BeEquivalentTo([UpdatedDictionaryValue, FourthDictionaryValue, FifthDictionaryValue]); + _ = dictionary.Keys.Should().BeEquivalentTo(["one", "four", "five"]); + _ = dictionary.Values.Should().BeEquivalentTo([UpdatedDictionaryValue, FourthDictionaryValue, FifthDictionaryValue]); var copied = new List>(); dictionary.CopyTo(copied); - copied.Should().BeEquivalentTo(dictionary.ToArray()); + var enumerated = new List>(dictionary); + _ = copied.Should().BeEquivalentTo(enumerated); var structEnumerator = dictionary.GetEnumerator(); var matchingStructEnumerator = dictionary.GetEnumerator(); - (structEnumerator == matchingStructEnumerator).Should().BeTrue(); - (structEnumerator != matchingStructEnumerator).Should().BeFalse(); - structEnumerator.Equals((object)matchingStructEnumerator).Should().BeTrue(); - structEnumerator.Equals(new object()).Should().BeFalse(); - structEnumerator.GetHashCode().Should().NotBe(0); - structEnumerator.TryGetNext(out var first).Should().BeTrue(); - (structEnumerator != matchingStructEnumerator).Should().BeTrue(); - (structEnumerator == matchingStructEnumerator).Should().BeFalse(); - first.Key.Should().NotBeNull(); + _ = (structEnumerator == matchingStructEnumerator).Should().BeTrue(); + _ = (structEnumerator != matchingStructEnumerator).Should().BeFalse(); + _ = structEnumerator.Equals((object)matchingStructEnumerator).Should().BeTrue(); + _ = structEnumerator.Equals(new object()).Should().BeFalse(); + _ = structEnumerator.GetHashCode().Should().NotBe(0); + _ = structEnumerator.TryGetNext(out var first).Should().BeTrue(); + _ = (structEnumerator != matchingStructEnumerator).Should().BeTrue(); + _ = (structEnumerator == matchingStructEnumerator).Should().BeFalse(); + _ = first.Key.Should().NotBeNull(); while (structEnumerator.MoveNext()) { _ = structEnumerator.Current; } - structEnumerator.TryGetNext(out var afterLast).Should().BeFalse(); - afterLast.Should().Be(default(KeyValuePair)); + _ = structEnumerator.TryGetNext(out var afterLast).Should().BeFalse(); + _ = afterLast.Should().Be(default(KeyValuePair)); using var wrapper = ((IEnumerable>)dictionary).GetEnumerator(); - wrapper.MoveNext().Should().BeTrue(); - ((IEnumerator)wrapper).Current.Should().BeOfType>(); + _ = wrapper.MoveNext().Should().BeTrue(); + _ = ((IEnumerator)wrapper).Current.Should().BeOfType>(); wrapper.Reset(); - wrapper.MoveNext().Should().BeTrue(); + _ = wrapper.MoveNext().Should().BeTrue(); var nonGenericWrapper = ((IEnumerable)dictionary).GetEnumerator(); - nonGenericWrapper.MoveNext().Should().BeTrue(); - nonGenericWrapper.Current.Should().BeOfType>(); + _ = nonGenericWrapper.MoveNext().Should().BeTrue(); + _ = nonGenericWrapper.Current.Should().BeOfType>(); dictionary.Dispose(); dictionary.Dispose(); @@ -284,12 +288,15 @@ public void QuadDictionary_ShouldCoverGuardsCapacityAndClear() dictionary.EnsureCapacity(InitialDictionaryCapacity); dictionary.EnsureCapacity(ExpandedDictionaryCapacity); - Enumerable.Range(0, DictionaryPopulationCount).ToList().ForEach(i => dictionary.Add(i, $"value-{i}")); + for (var key = 0; key < DictionaryPopulationCount; key++) + { + dictionary.Add(key, $"value-{key}"); + } - dictionary.Count.Should().Be(DictionaryPopulationCount); - dictionary.ContainsKey(ExistingDictionaryKey).Should().BeTrue(); - dictionary.TryGetValue(MissingLookupValue, out var missing).Should().BeFalse(); - missing.Should().BeNull(); + _ = dictionary.Count.Should().Be(DictionaryPopulationCount); + _ = dictionary.ContainsKey(ExistingDictionaryKey).Should().BeTrue(); + _ = dictionary.TryGetValue(MissingLookupValue, out var missing).Should().BeFalse(); + _ = missing.Should().BeNull(); Action duplicateAdd = () => dictionary.Add(ExistingDictionaryKey, "duplicate"); Action missingIndexer = () => _ = dictionary[MissingLookupValue]; @@ -297,14 +304,14 @@ public void QuadDictionary_ShouldCoverGuardsCapacityAndClear() Action nullKeysTarget = () => dictionary.CopyKeysTo(null!); Action nullValuesTarget = () => dictionary.CopyValuesTo(null!); - duplicateAdd.Should().Throw(); - missingIndexer.Should().Throw(); - nullCopyTarget.Should().Throw(); - nullKeysTarget.Should().Throw().WithParameterName("list"); - nullValuesTarget.Should().Throw().WithParameterName("list"); + _ = duplicateAdd.Should().Throw(); + _ = missingIndexer.Should().Throw(); + _ = nullCopyTarget.Should().Throw(); + _ = nullKeysTarget.Should().Throw().WithParameterName("list"); + _ = nullValuesTarget.Should().Throw().WithParameterName("list"); dictionary.Clear(); - dictionary.Count.Should().Be(0); + _ = dictionary.Count.Should().Be(0); dictionary.Clear(); using var autoResize = new QuadDictionary(); @@ -313,14 +320,14 @@ public void QuadDictionary_ShouldCoverGuardsCapacityAndClear() autoResize.Add(i, i); } - autoResize.Remove(1).Should().BeTrue(); + _ = autoResize.Remove(1).Should().BeTrue(); autoResize.EnsureCapacity(AutoResizeCapacity); - autoResize.Keys.Should().Contain(AutoResizeLastKey); + _ = autoResize.Keys.Should().Contain(AutoResizeLastKey); using var nullableKeyDictionary = new QuadDictionary(); - nullableKeyDictionary.TryAdd(null, 1).Should().BeTrue(); - nullableKeyDictionary.TryGetValue(null, out var nullKeyValue).Should().BeTrue(); - nullKeyValue.Should().Be(1); + _ = nullableKeyDictionary.TryAdd(null, 1).Should().BeTrue(); + _ = nullableKeyDictionary.TryGetValue(null, out var nullKeyValue).Should().BeTrue(); + _ = nullKeyValue.Should().Be(1); } /// Verifies pooled batch change tracking including growth and reset on disposal. @@ -339,15 +346,15 @@ public void BatchChangeTracker_ShouldTrackGrowAndDispose() tracker.TrackRemoved($"removed-{i}"); } - tracker.HasChanges.Should().BeTrue(); - tracker.AddedItems.ToArray().Should().StartWith("added-0").And.EndWith("added-23"); - tracker.RemovedItems.ToArray().Should().StartWith("removed-0").And.EndWith("removed-19"); + _ = tracker.HasChanges.Should().BeTrue(); + _ = tracker.AddedItems.ToArray().Should().StartWith("added-0").And.EndWith("added-23"); + _ = tracker.RemovedItems.ToArray().Should().StartWith("removed-0").And.EndWith("removed-19"); tracker.Dispose(); - tracker.HasChanges.Should().BeFalse(); - tracker.AddedItems.Length.Should().Be(0); - tracker.RemovedItems.Length.Should().Be(0); + _ = tracker.HasChanges.Should().BeFalse(); + _ = tracker.AddedItems.Length.Should().Be(0); + _ = tracker.RemovedItems.Length.Should().Be(0); } /// Verifies ChangeToken value storage and change detection. @@ -356,11 +363,11 @@ public void ChangeToken_ShouldReportVersionChanges() { var token = new ChangeToken(version: 7, count: 3); - token.Version.Should().Be(InitialTokenVersion); - token.Count.Should().Be(TrackedItemCount); - token.HasChanged(InitialTokenVersion).Should().BeFalse(); - token.HasChanged(NextTokenVersion).Should().BeTrue(); - token.Should().Be(new ChangeToken(InitialTokenVersion, TrackedItemCount)); + _ = token.Version.Should().Be(InitialTokenVersion); + _ = token.Count.Should().Be(TrackedItemCount); + _ = token.HasChanged(InitialTokenVersion).Should().BeFalse(); + _ = token.HasChanged(NextTokenVersion).Should().BeTrue(); + _ = token.Should().Be(new ChangeToken(InitialTokenVersion, TrackedItemCount)); } /// Verifies PooledBuffer list copying and idempotent disposal. @@ -368,9 +375,9 @@ public void ChangeToken_ShouldReportVersionChanges() public void PooledBuffer_FromList_ShouldExposeCopiedSpanAndDispose() { var source = new List { "alpha", "beta", "gamma" }; - using var buffer = PooledBuffer.FromList(source); + var buffer = PooledBuffer.FromList(source); - buffer.Span.ToArray().Should().Equal(source); + _ = buffer.Span.ToArray().Should().Equal(source); buffer.Dispose(); buffer.Dispose(); @@ -385,7 +392,7 @@ public void ValueBuffer_ShouldUseStackThenRentedStorage() buffer.Add(1); buffer.Add(SecondBufferedValue); - buffer.Span.ToArray().Should().Equal(1, SecondBufferedValue); + _ = buffer.Span.ToArray().Should().Equal(1, SecondBufferedValue); buffer.Add(ThirdBufferedValue); for (var i = 4; i <= ValueBufferFinalCount; i++) @@ -393,8 +400,8 @@ public void ValueBuffer_ShouldUseStackThenRentedStorage() buffer.Add(i); } - buffer.Count.Should().Be(ValueBufferFinalCount); - buffer.Span.ToArray().Should().Equal(Enumerable.Range(1, ValueBufferFinalCount)); + _ = buffer.Count.Should().Be(ValueBufferFinalCount); + _ = buffer.Span.ToArray().Should().Equal(Enumerable.Range(1, ValueBufferFinalCount)); buffer.Dispose(); buffer.Dispose(); @@ -404,16 +411,16 @@ public void ValueBuffer_ShouldUseStackThenRentedStorage() [Test] public void ShardHash_ShouldReturnExpectedShardRanges() { - ShardHash.GetShardIndex(null, FourWayShardCount).Should().Be(0); - ShardHash.GetShardIndex4(null).Should().Be(0); + _ = ShardHash.GetShardIndex(null, FourWayShardCount).Should().Be(0); + _ = ShardHash.GetShardIndex4(null).Should().Be(0); var positive = new FixedHash(1); var negative = new FixedHash(int.MinValue); - ShardHash.GetShardIndex(positive, EightWayShardCount).Should().BeInRange(0, EightWayMaximumIndex); - ShardHash.GetShardIndex(negative, SixteenWayShardCount).Should().BeInRange(0, SixteenWayMaximumIndex); - ShardHash.GetShardIndex4(positive).Should().Be(ShardHash.GetShardIndex(positive, FourWayShardCount)); - ShardHash.GetShardIndex4(negative).Should().BeInRange(0, FourWayMaximumIndex); + _ = ShardHash.GetShardIndex(positive, EightWayShardCount).Should().BeInRange(0, EightWayMaximumIndex); + _ = ShardHash.GetShardIndex(negative, SixteenWayShardCount).Should().BeInRange(0, SixteenWayMaximumIndex); + _ = ShardHash.GetShardIndex4(positive).Should().Be(ShardHash.GetShardIndex(positive, FourWayShardCount)); + _ = ShardHash.GetShardIndex4(negative).Should().BeInRange(0, FourWayMaximumIndex); } /// Provides ConstantHashStringComparer. @@ -434,6 +441,7 @@ private sealed class ConstantHashStringComparer : IEqualityComparer /// Provides FixedHash. private sealed class FixedHash { + /// The fixed hash code returned by this instance. private readonly int _hashCode; /// Initializes a new instance of the class. diff --git a/src/ReactiveList.Test/QuaternaryCollectionCoverageTests.cs b/src/ReactiveList.Test/QuaternaryCollectionCoverageTests.cs index 57288c4..fff4f45 100644 --- a/src/ReactiveList.Test/QuaternaryCollectionCoverageTests.cs +++ b/src/ReactiveList.Test/QuaternaryCollectionCoverageTests.cs @@ -9,7 +9,6 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; -using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Channels; @@ -25,68 +24,94 @@ namespace ReactiveList.Test; /// Covers quaternary list, dictionary, and base notification paths that are not reached by the public API tests. public class QuaternaryCollectionCoverageTests { + /// The second collection value used by test data. private const int CollectionValueTwo = 2; + /// The third collection value used by test data. private const int CollectionValueThree = 3; + /// The fourth collection value used by test data. private const int CollectionValueFour = 4; + /// The fifth collection value used by test data. private const int CollectionValueFive = 5; + /// The sixth collection value used by test data. private const int CollectionValueSix = 6; - private const int CollectionValueSeven = 7; - + /// The eighth collection value used by test data. private const int CollectionValueEight = 8; + /// The number of modulo buckets used by secondary-index tests. private const int ModuloBucketCount = 10; + /// The eleventh collection value used by test data. private const int CollectionValueEleven = 11; + /// The expected number of remaining items after removal. private const int ExpectedRemainingItems = 40; + /// The value used by replacement tests. private const int ReplacementValue = 42; + /// The interval used when polling the event processor. private const int ProcessorPollMilliseconds = 50; + /// A value deliberately absent from test collections. private const int MissingCollectionValue = 99; + /// The size of the final batch used by parallel-path tests. private const int FinalBatchSize = 100; + /// The expected number of items in each parity bucket. private const int ExpectedParityItems = 150; + /// The size of batches used to exercise parallel paths. private const int ParallelBatchSize = 300; + /// The first identifier in the final batch. private const int FinalBatchStart = 600; + /// The minimum number of retained items expected after removals. private const int MinimumRetainedItems = 700; + /// A key or index deliberately outside the populated range. private const int MissingKeyOrIndex = 999; + /// The number of initial keys removed from the large batch. private const int RemovedInitialKeys = 1060; + /// The last key expected to remain from the initial batch. private const int LastRetainedKey = 1099; + /// The size of the large batch used by parallel-path tests. private const int LargeBatchSize = 1100; + /// The first retained key after the second removal pass. private const int FirstRetainedKey = 1101; + /// A value guaranteed not to occur in test collections. private const int DefinitelyMissingValue = 999_999; + /// The name of the parity secondary index. private const string ParityIndexName = "Parity"; + /// The name of the string-length secondary index. private const string LengthIndexName = "Length"; + /// The textual representation used for the number three. private const string ThreeText = "three"; + /// The replacement key-value pairs used by dictionary tests. private static readonly KeyValuePair[] ReplacementPairs = [new(2, "TWO")]; + /// The keys deliberately absent from the test dictionary. private static readonly int[] MissingKeys = [999]; - private static readonly int[] ReflectedAddRangeItems = [5, 6]; - + /// An empty integer list used to exercise list overloads. private static readonly List EmptyIntList = []; + /// An empty pair list used to exercise dictionary overloads. private static readonly List> EmptyPairs = []; /// Provides a rename-safe marker for a deliberately absent secondary index. @@ -104,10 +129,10 @@ public void QuaternaryList_BatchOverloads_ShouldMaintainItemsIndexesAndVersion() var initialVersion = list.Version; list.AddRange([]); - list.Count.Should().Be(0); - list.Version.Should().Be(initialVersion); + _ = list.Count.Should().Be(0); + _ = list.Version.Should().Be(initialVersion); - list.AddIndex(ParityIndexName, item => item % CollectionValueTwo); + list.AddIndex(ParityIndexName, static item => item % CollectionValueTwo); list.AddRange(Yield(0, 1, CollectionValueTwo, CollectionValueThree, CollectionValueFour)); list.RemoveRange(Yield(1, CollectionValueThree)); list.AddRange([ModuloBucketCount, CollectionValueEleven]); @@ -118,11 +143,11 @@ public void QuaternaryList_BatchOverloads_ShouldMaintainItemsIndexesAndVersion() list.RemoveRange([]); list.RemoveRange([CollectionValueTwo]); - list.Count.Should().Be(CollectionValueFour); - list.Should().BeEquivalentTo([0, CollectionValueFour, ModuloBucketCount, CollectionValueEleven]); - list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEquivalentTo([0, CollectionValueFour, ModuloBucketCount]); - list.GetItemsBySecondaryIndex(ParityIndexName, 1).Should().ContainSingle().Which.Should().Be(CollectionValueEleven); - list.Version.Should().BeGreaterThan(initialVersion); + _ = list.Count.Should().Be(CollectionValueFour); + _ = list.Should().BeEquivalentTo([0, CollectionValueFour, ModuloBucketCount, CollectionValueEleven]); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEquivalentTo([0, CollectionValueFour, ModuloBucketCount]); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 1).Should().ContainSingle().Which.Should().Be(CollectionValueEleven); + _ = list.Version.Should().BeGreaterThan(initialVersion); } /// Verifies QuaternaryList parallel array and list paths for large batch adds and removals. @@ -130,27 +155,27 @@ public void QuaternaryList_BatchOverloads_ShouldMaintainItemsIndexesAndVersion() public void QuaternaryList_LargeBatchOverloads_ShouldUseParallelPaths() { using var list = new QuaternaryList(); - list.AddIndex("Mod10", item => item % ModuloBucketCount); + list.AddIndex("Mod10", static item => item % ModuloBucketCount); - var firstBatch = Enumerable.Range(0, LargeBatchSize).ToArray(); + var firstBatch = CreateRangeArray(0, LargeBatchSize); list.AddRange(firstBatch); - list.RemoveRange(firstBatch.Where(item => item % CollectionValueThree == 0).ToArray()); + list.RemoveRange(FilterToArray(firstBatch, static item => item % CollectionValueThree == 0)); - list.Count.Should().BeGreaterThan(MinimumRetainedItems); - list.Contains(0).Should().BeFalse(); - list.Contains(1).Should().BeTrue(); + _ = list.Count.Should().BeGreaterThan(MinimumRetainedItems); + _ = list.Contains(0).Should().BeFalse(); + _ = list.Contains(1).Should().BeTrue(); - var secondBatch = Enumerable.Range(LargeBatchSize, LargeBatchSize).ToList(); + var secondBatch = CreateRangeList(LargeBatchSize, LargeBatchSize); list.AddRange(secondBatch); - list.RemoveRange(secondBatch.Where(item => item % CollectionValueTwo == 0).ToList()); + list.RemoveRange(FilterToList(secondBatch, static item => item % CollectionValueTwo == 0)); - list.Contains(LargeBatchSize).Should().BeFalse(); - list.Contains(FirstRetainedKey).Should().BeTrue(); - list.GetItemsBySecondaryIndex("Mod10", 1).Should().Contain(FirstRetainedKey); + _ = list.Contains(LargeBatchSize).Should().BeFalse(); + _ = list.Contains(FirstRetainedKey).Should().BeTrue(); + _ = list.GetItemsBySecondaryIndex("Mod10", 1).Should().Contain(FirstRetainedKey); var countBeforeMissingRemove = list.Count; list.RemoveRange([DefinitelyMissingValue]); - list.Count.Should().Be(countBeforeMissingRemove); + _ = list.Count.Should().Be(countBeforeMissingRemove); } /// Verifies QuaternaryList parallel paths when all items land in one shard, plus RemoveMany buffer growth. @@ -160,52 +185,49 @@ public void QuaternaryList_SingleShardParallelBatchesAndRemoveManyGrowth_ShouldM using var list = new QuaternaryList(); list.AddIndex(ParityIndexName, static item => item.Id % CollectionValueTwo); - var arrayBatch = Enumerable.Range(0, ParallelBatchSize).Select(static id => new ConstantShardItem(id)).ToArray(); + var arrayBatch = CreateConstantShardItems(0, ParallelBatchSize); list.AddRange(arrayBatch); - list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().HaveCount(ExpectedParityItems); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().HaveCount(ExpectedParityItems); list.RemoveRange(arrayBatch); - list.Count.Should().Be(0); + _ = list.Count.Should().Be(0); - var listBatch = Enumerable.Range(ParallelBatchSize, ParallelBatchSize).Select(static id => new ConstantShardItem(id)).ToList(); + var listBatch = new List(CreateConstantShardItems(ParallelBatchSize, ParallelBatchSize)); list.AddRange(listBatch); - list.GetItemsBySecondaryIndex(ParityIndexName, 1).Should().HaveCount(ExpectedParityItems); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 1).Should().HaveCount(ExpectedParityItems); list.RemoveRange(listBatch); - list.Count.Should().Be(0); + _ = list.Count.Should().Be(0); - list.AddRange(Enumerable.Range(FinalBatchStart, FinalBatchSize).Select(static id => new ConstantShardItem(id)).ToArray()); - list.RemoveMany(static _ => true).Should().Be(FinalBatchSize); - list.Count.Should().Be(0); - list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEmpty(); + list.AddRange(CreateConstantShardItems(FinalBatchStart, FinalBatchSize)); + _ = list.RemoveMany(static _ => true).Should().Be(FinalBatchSize); + _ = list.Count.Should().Be(0); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEmpty(); } - /// Verifies QuaternaryList edit wrapper members and reflected members not exposed by ICollection. + /// Verifies the collection contract exposed by QuaternaryList batch edits. [Test] public void QuaternaryList_EditWrapper_ShouldExposeCollectionMembers() { using var list = new QuaternaryList(); - list.AddIndex(ParityIndexName, item => item % CollectionValueTwo); + list.AddIndex(ParityIndexName, static item => item % CollectionValueTwo); list.AddRange([1, CollectionValueTwo, CollectionValueThree]); - list.Edit(editor => + list.Edit(static editor => { - editor.Count.Should().Be(CollectionValueThree); - editor.IsReadOnly.Should().BeFalse(); - editor.Contains(CollectionValueTwo).Should().BeTrue(); + _ = editor.Count.Should().Be(CollectionValueThree); + _ = editor.IsReadOnly.Should().BeFalse(); + _ = editor.Contains(CollectionValueTwo).Should().BeTrue(); var copy = new int[3]; editor.CopyTo(copy, 0); - copy.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); - editor.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); + _ = copy.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); + _ = editor.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); - editor.Remove(1).Should().BeTrue(); - editor.Remove(MissingCollectionValue).Should().BeFalse(); + _ = editor.Remove(1).Should().BeTrue(); + _ = editor.Remove(MissingCollectionValue).Should().BeFalse(); editor.Add(CollectionValueFour); - var wrapperType = editor.GetType(); - var addRangeMethod = wrapperType.GetMethod("AddRange") ?? throw new MissingMethodException(wrapperType.FullName, "AddRange"); - var itemProperty = wrapperType.GetProperty("Item") ?? throw new MissingMemberException(wrapperType.FullName, "Item"); - addRangeMethod.Invoke(editor, [ReflectedAddRangeItems]); - itemProperty.GetValue(editor, [0]).Should().NotBeNull(); + editor.Add(CollectionValueFive); + editor.Add(CollectionValueSix); var editorEnumerator = editor.GetEnumerator(); while (editorEnumerator.MoveNext()) @@ -213,40 +235,34 @@ public void QuaternaryList_EditWrapper_ShouldExposeCollectionMembers() _ = editorEnumerator.Current; } - editorEnumerator.MoveNext().Should().BeFalse(); - ((IEnumerable)editor).GetEnumerator().MoveNext().Should().BeTrue(); - - Action badIndex = () => itemProperty.GetValue(editor, [MissingKeyOrIndex]); - Action replaceByIndex = () => itemProperty.SetValue(editor, CollectionValueSeven, [0]); - - badIndex.Should().Throw().WithInnerException(); - replaceByIndex.Should().Throw().WithInnerException(); + _ = editorEnumerator.MoveNext().Should().BeFalse(); + _ = ((IEnumerable)editor).GetEnumerator().MoveNext().Should().BeTrue(); }); - list.Should().BeEquivalentTo([CollectionValueTwo, CollectionValueThree, CollectionValueFour, CollectionValueFive, CollectionValueSix]); - list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEquivalentTo([CollectionValueTwo, CollectionValueFour, CollectionValueSix]); + _ = list.Should().BeEquivalentTo([CollectionValueTwo, CollectionValueThree, CollectionValueFour, CollectionValueFive, CollectionValueSix]); + _ = list.GetItemsBySecondaryIndex(ParityIndexName, 0).Should().BeEquivalentTo([CollectionValueTwo, CollectionValueFour, CollectionValueSix]); using var noIndexList = new QuaternaryList(); noIndexList.AddRange([1, CollectionValueTwo]); - noIndexList.Edit(editor => editor.Remove(1).Should().BeTrue()); - noIndexList.Should().ContainSingle().Which.Should().Be(CollectionValueTwo); + noIndexList.Edit(static editor => _ = editor.Remove(1).Should().BeTrue()); + _ = noIndexList.Should().ContainSingle().Which.Should().Be(CollectionValueTwo); } /// Verifies QuaternaryList snapshot and index guard paths. [Test] public void QuaternaryList_SnapshotAndInvalidIndexes_ShouldBehaveAsExpected() { - using var list = new QuaternaryList(); + var list = new QuaternaryList(); list.AddRange([0, CollectionValueFour, CollectionValueEight, 1]); - list.Snapshot().Should().BeEquivalentTo(list.ToArray()); + _ = list.Snapshot().Should().BeEquivalentTo(list.ToArray()); using var enumerator = ((IEnumerable)list).GetEnumerator(); while (enumerator.MoveNext()) { _ = enumerator.Current; } - enumerator.MoveNext().Should().BeFalse(); + _ = enumerator.MoveNext().Should().BeFalse(); var nonGenericEnumerator = ((IEnumerable)list).GetEnumerator(); while (nonGenericEnumerator.MoveNext()) @@ -254,15 +270,15 @@ public void QuaternaryList_SnapshotAndInvalidIndexes_ShouldBehaveAsExpected() _ = nonGenericEnumerator.Current; } - nonGenericEnumerator.MoveNext().Should().BeFalse(); + _ = nonGenericEnumerator.MoveNext().Should().BeFalse(); Action negativeIndex = () => _ = list[-1]; Action tooHighIndex = () => _ = list[MissingCollectionValue]; Action setter = () => list[0] = ReplacementValue; - negativeIndex.Should().Throw(); - tooHighIndex.Should().Throw(); - setter.Should().Throw(); + _ = negativeIndex.Should().Throw(); + _ = tooHighIndex.Should().Throw(); + _ = setter.Should().Throw(); list.Dispose(); list.Dispose(); @@ -290,9 +306,9 @@ public void QuaternaryBase_CollectionChanged_ShouldUseCapturedSynchronizationCon list.Add(ReplacementValue); - reset.Wait(TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); - context.PostCount.Should().BeGreaterThan(0); - action.Should().Be(NotifyCollectionChangedAction.Reset); + _ = reset.Wait(TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); + _ = context.PostCount.Should().BeGreaterThan(0); + _ = action.Should().Be(NotifyCollectionChangedAction.Reset); } finally { @@ -308,23 +324,23 @@ public void QuaternaryDictionary_BatchOverloadsAndViews_ShouldMaintainIndexes() var initialVersion = dictionary.Version; dictionary.AddRange([]); - dictionary.Version.Should().Be(initialVersion); + _ = dictionary.Version.Should().Be(initialVersion); - dictionary.AddRange(Yield( - new KeyValuePair(1, "one"), - new KeyValuePair(CollectionValueTwo, "two"), - new KeyValuePair(CollectionValueThree, ThreeText))); - dictionary.AddValueIndex(LengthIndexName, value => value.Length); + dictionary.AddRange(Yield>( + new(1, "one"), + new(CollectionValueTwo, "two"), + new(CollectionValueThree, ThreeText))); + dictionary.AddValueIndex(LengthIndexName, static value => value.Length); dictionary.AddRange(EmptyPairs); using var view = dictionary.CreateViewBySecondaryIndex(LengthIndexName, CollectionValueThree, Sequencer.Immediate, throttleMs: 1); - view.Count.Should().Be(CollectionValueTwo); + _ = view.Count.Should().Be(CollectionValueTwo); dictionary.AddRange([ new(CollectionValueFour, "four"), new(CollectionValueFive, "five") ]); - dictionary.Add(new KeyValuePair(CollectionValueSix, "six")); + dictionary.Add(new(CollectionValueSix, "six")); dictionary.AddRange([]); dictionary.RemoveKeys(Yield(1)); @@ -334,20 +350,20 @@ public void QuaternaryDictionary_BatchOverloadsAndViews_ShouldMaintainIndexes() dictionary.RemoveKeys([CollectionValueFour]); dictionary.RemoveKeys(MissingKeys); - dictionary.Count.Should().Be(CollectionValueFour); - dictionary.ContainsKey(1).Should().BeFalse(); - dictionary.ContainsKey(CollectionValueFour).Should().BeFalse(); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueThree).Should().BeEquivalentTo(["two", "six"]); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().ContainSingle().Which.Should().Be("five"); + _ = dictionary.Count.Should().Be(CollectionValueFour); + _ = dictionary.ContainsKey(1).Should().BeFalse(); + _ = dictionary.ContainsKey(CollectionValueFour).Should().BeFalse(); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueThree).Should().BeEquivalentTo(["two", "six"]); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().ContainSingle().Which.Should().Be("five"); dictionary.AddRange(ReplacementPairs); dictionary.AddRange([new(CollectionValueTwo, "deux")]); - dictionary[CollectionValueTwo].Should().Be("deux"); + _ = dictionary[CollectionValueTwo].Should().Be("deux"); Action missingIndex = () => dictionary.CreateViewBySecondaryIndex(nameof(Missing), CollectionValueThree, Sequencer.Immediate); Action incompatibleIndex = () => dictionary.CreateViewBySecondaryIndex(LengthIndexName, ThreeText, Sequencer.Immediate); - missingIndex.Should().Throw(); - incompatibleIndex.Should().Throw(); + _ = missingIndex.Should().Throw(); + _ = incompatibleIndex.Should().Throw(); } /// Verifies QuaternaryDictionary parallel array and list paths for large batch adds and key removals. @@ -355,29 +371,25 @@ public void QuaternaryDictionary_BatchOverloadsAndViews_ShouldMaintainIndexes() public void QuaternaryDictionary_LargeBatchOverloads_ShouldUseParallelPaths() { using var dictionary = new QuaternaryDictionary(); - dictionary.AddValueIndex(LengthIndexName, value => value.Length); + dictionary.AddValueIndex(LengthIndexName, static value => value.Length); - var firstBatch = Enumerable.Range(0, LargeBatchSize) - .Select(i => new KeyValuePair(i, $"value-{i}")) - .ToArray(); + var firstBatch = CreateIntStringPairs(0, LargeBatchSize); dictionary.AddRange(firstBatch); - dictionary.RemoveKeys(Enumerable.Range(0, RemovedInitialKeys).ToArray()); + dictionary.RemoveKeys(CreateRangeArray(0, RemovedInitialKeys)); - dictionary.Count.Should().Be(ExpectedRemainingItems); - dictionary.ContainsKey(0).Should().BeFalse(); - dictionary.ContainsKey(LastRetainedKey).Should().BeTrue(); + _ = dictionary.Count.Should().Be(ExpectedRemainingItems); + _ = dictionary.ContainsKey(0).Should().BeFalse(); + _ = dictionary.ContainsKey(LastRetainedKey).Should().BeTrue(); - var secondBatch = Enumerable.Range(LargeBatchSize, LargeBatchSize) - .Select(i => new KeyValuePair(i, $"value-{i}")) - .ToList(); + var secondBatch = new List>(CreateIntStringPairs(LargeBatchSize, LargeBatchSize)); dictionary.AddRange(secondBatch); - dictionary.RemoveKeys(secondBatch.Select(pair => pair.Key).Where(key => key % CollectionValueTwo == 0).ToList()); + dictionary.RemoveKeys(ExtractEvenKeys(secondBatch)); - dictionary.ContainsKey(LargeBatchSize).Should().BeFalse(); - dictionary.ContainsKey(FirstRetainedKey).Should().BeTrue(); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, "value-1101".Length).Should().Contain("value-1101"); + _ = dictionary.ContainsKey(LargeBatchSize).Should().BeFalse(); + _ = dictionary.ContainsKey(FirstRetainedKey).Should().BeTrue(); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, "value-1101".Length).Should().Contain("value-1101"); } /// Verifies QuaternaryDictionary parallel paths when all keys land in one shard, plus RemoveMany buffer growth. @@ -387,35 +399,29 @@ public void QuaternaryDictionary_SingleShardParallelBatchesAndRemoveManyGrowth_S using var dictionary = new QuaternaryDictionary(); dictionary.AddValueIndex(LengthIndexName, static value => value.Length); - var arrayBatch = Enumerable.Range(0, ParallelBatchSize) - .Select(static id => new KeyValuePair(new ConstantShardKey(id), $"v{id}")) - .ToArray(); + var arrayBatch = CreateConstantShardPairs(0, ParallelBatchSize); dictionary.AddRange(arrayBatch); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueTwo).Should().Contain("v0"); - dictionary.RemoveKeys(arrayBatch.Select(static pair => pair.Key).ToArray()); - dictionary.Count.Should().Be(0); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueTwo).Should().Contain("v0"); + dictionary.RemoveKeys(ExtractKeys(arrayBatch)); + _ = dictionary.Count.Should().Be(0); - var listBatch = Enumerable.Range(ParallelBatchSize, ParallelBatchSize) - .Select(static id => new KeyValuePair(new ConstantShardKey(id), $"v{id}")) - .ToList(); + var listBatch = new List>(CreateConstantShardPairs(ParallelBatchSize, ParallelBatchSize)); dictionary.AddRange(listBatch); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().Contain("v300"); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().Contain("v300"); dictionary.RemoveKeys(listBatch.ConvertAll(static pair => pair.Key)); - dictionary.Count.Should().Be(0); + _ = dictionary.Count.Should().Be(0); - dictionary.AddRange(Enumerable.Range(FinalBatchStart, FinalBatchSize) - .Select(static id => new KeyValuePair(new ConstantShardKey(id), $"v{id}")) - .ToArray()); + dictionary.AddRange(CreateConstantShardPairs(FinalBatchStart, FinalBatchSize)); - dictionary.RemoveMany(static _ => true).Should().Be(FinalBatchSize); - dictionary.Count.Should().Be(0); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().BeEmpty(); + _ = dictionary.RemoveMany(static _ => true).Should().Be(FinalBatchSize); + _ = dictionary.Count.Should().Be(0); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueFour).Should().BeEmpty(); - dictionary.Add(new ConstantShardKey(MissingKeyOrIndex), "v999"); - dictionary.RemoveMany(static _ => false).Should().Be(0); - dictionary.Count.Should().Be(1); + dictionary.Add(new(MissingKeyOrIndex), "v999"); + _ = dictionary.RemoveMany(static _ => false).Should().Be(0); + _ = dictionary.Count.Should().Be(1); } /// Verifies QuaternaryDictionary edit wrapper members and index maintenance. @@ -423,56 +429,56 @@ public void QuaternaryDictionary_SingleShardParallelBatchesAndRemoveManyGrowth_S public void QuaternaryDictionary_EditWrapper_ShouldExposeDictionaryMembers() { using var dictionary = new QuaternaryDictionary(); - dictionary.AddValueIndex(LengthIndexName, value => value.Length); + dictionary.AddValueIndex(LengthIndexName, static value => value.Length); dictionary.AddRange([ - new KeyValuePair(1, "one"), - new KeyValuePair(CollectionValueTwo, "two"), - new KeyValuePair(CollectionValueThree, ThreeText) + new(1, "one"), + new(CollectionValueTwo, "two"), + new(CollectionValueThree, ThreeText) ]); dictionary.Edit(editor => { - editor.Count.Should().Be(CollectionValueThree); - editor.IsReadOnly.Should().BeFalse(); - editor.Keys.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); - editor.Values.Should().BeEquivalentTo(["one", "two", ThreeText]); - editor[1].Should().Be("one"); + _ = editor.Count.Should().Be(CollectionValueThree); + _ = editor.IsReadOnly.Should().BeFalse(); + _ = editor.Keys.Should().BeEquivalentTo([1, CollectionValueTwo, CollectionValueThree]); + _ = editor.Values.Should().BeEquivalentTo(["one", "two", ThreeText]); + _ = editor[1].Should().Be("one"); editor[1] = "ONE"; editor[CollectionValueFour] = "four"; editor.Add(CollectionValueFive, "five"); - editor.Add(new KeyValuePair(CollectionValueSix, "six")); + editor.Add(new(CollectionValueSix, "six")); - editor.ContainsKey(CollectionValueSix).Should().BeTrue(); - editor.TryGetValue(CollectionValueSix, out var six).Should().BeTrue(); - six.Should().Be("six"); - editor.Contains(new KeyValuePair(CollectionValueSix, "six")).Should().BeTrue(); + _ = editor.ContainsKey(CollectionValueSix).Should().BeTrue(); + _ = editor.TryGetValue(CollectionValueSix, out var six).Should().BeTrue(); + _ = six.Should().Be("six"); + _ = editor.Contains(Pair(CollectionValueSix, "six")).Should().BeTrue(); var copy = new KeyValuePair[editor.Count]; editor.CopyTo(copy, 0); - copy.Should().Contain(new KeyValuePair(CollectionValueSix, "six")); + _ = copy.Should().Contain(Pair(CollectionValueSix, "six")); - ((IEnumerable)editor).GetEnumerator().MoveNext().Should().BeTrue(); + _ = ((IEnumerable)editor).GetEnumerator().MoveNext().Should().BeTrue(); var editorEnumerator = editor.GetEnumerator(); while (editorEnumerator.MoveNext()) { _ = editorEnumerator.Current; } - editorEnumerator.MoveNext().Should().BeFalse(); - editor.Remove(new KeyValuePair(CollectionValueFive, "wrong")).Should().BeFalse(); - editor.Remove(new KeyValuePair(CollectionValueFive, "five")).Should().BeTrue(); - editor.Remove(MissingCollectionValue).Should().BeFalse(); + _ = editorEnumerator.MoveNext().Should().BeFalse(); + _ = editor.Remove(Pair(CollectionValueFive, "wrong")).Should().BeFalse(); + _ = editor.Remove(Pair(CollectionValueFive, "five")).Should().BeTrue(); + _ = editor.Remove(MissingCollectionValue).Should().BeFalse(); Action missingKey = () => _ = editor[MissingCollectionValue]; - missingKey.Should().Throw(); + _ = missingKey.Should().Throw(); }); - dictionary.Should().Contain(new KeyValuePair(1, "ONE")); - dictionary.Should().Contain(new KeyValuePair(CollectionValueFour, "four")); - dictionary.Should().Contain(new KeyValuePair(CollectionValueSix, "six")); - dictionary.ContainsKey(CollectionValueFive).Should().BeFalse(); - dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueThree).Should().BeEquivalentTo(["ONE", "two", "six"]); + _ = dictionary.Should().Contain(Pair(1, "ONE")); + _ = dictionary.Should().Contain(Pair(CollectionValueFour, "four")); + _ = dictionary.Should().Contain(Pair(CollectionValueSix, "six")); + _ = dictionary.ContainsKey(CollectionValueFive).Should().BeFalse(); + _ = dictionary.GetValuesBySecondaryIndex(LengthIndexName, CollectionValueThree).Should().BeEquivalentTo(["ONE", "two", "six"]); } /// Verifies QuaternaryDictionary guard paths and legacy collection changed update notifications. @@ -480,8 +486,8 @@ public void QuaternaryDictionary_EditWrapper_ShouldExposeDictionaryMembers() public void QuaternaryDictionary_GuardsAndCollectionChanged_ShouldBehaveAsExpected() { using var dictionary = new QuaternaryDictionary { { 1, "one" } }; - dictionary.Contains(new KeyValuePair(1, "uno")).Should().BeFalse(); - dictionary.Remove(new KeyValuePair(1, "uno")).Should().BeFalse(); + _ = dictionary.Contains(Pair(1, "uno")).Should().BeFalse(); + _ = dictionary.Remove(Pair(1, "uno")).Should().BeFalse(); Action duplicate = () => dictionary.Add(1, "duplicate"); Action missingIndexer = () => _ = dictionary[MissingCollectionValue]; @@ -490,12 +496,12 @@ public void QuaternaryDictionary_GuardsAndCollectionChanged_ShouldBehaveAsExpect Action nullRemoveMany = () => dictionary.RemoveMany(null!); Action nullEdit = () => dictionary.Edit(null!); - duplicate.Should().Throw(); - missingIndexer.Should().Throw(); - nullCopy.Should().Throw(); - nullRemoveKeys.Should().Throw(); - nullRemoveMany.Should().Throw(); - nullEdit.Should().Throw(); + _ = duplicate.Should().Throw(); + _ = missingIndexer.Should().Throw(); + _ = nullCopy.Should().Throw(); + _ = nullRemoveKeys.Should().Throw(); + _ = nullRemoveMany.Should().Throw(); + _ = nullEdit.Should().Throw(); using var reset = new ManualResetEventSlim(false); NotifyCollectionChangedAction? action = null; @@ -508,8 +514,8 @@ public void QuaternaryDictionary_GuardsAndCollectionChanged_ShouldBehaveAsExpect dictionary[1] = "ONE"; - reset.Wait(TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); - action.Should().Be(NotifyCollectionChangedAction.Reset); + _ = reset.Wait(TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); + _ = action.Should().Be(NotifyCollectionChangedAction.Reset); } /// Verifies protected QuaternaryBase batch helpers and null guard paths through a minimal harness. @@ -523,18 +529,18 @@ public void QuaternaryBase_BatchHelpers_ShouldEmitAndValidateArguments() harness.EmitDirect([1, CollectionValueTwo]); harness.EmitAddedFromList([CollectionValueThree, CollectionValueFour]); harness.EmitRemovedFromList([CollectionValueFive, CollectionValueSix]); - SpinWait.SpinUntil(() => received.Count >= 3, TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); + _ = SpinWait.SpinUntil(() => received.Count >= 3, TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); - received.Select(static notification => notification.Action) + _ = ExtractActions(received) .Should().Equal(CacheAction.BatchOperation, CacheAction.BatchAdded, CacheAction.BatchRemoved); - received.Select(static notification => notification.Batch!.Count) + _ = ExtractBatchCounts(received) .Should().Equal(CollectionValueTwo, CollectionValueTwo, CollectionValueTwo); Action nullAdded = () => harness.EmitAddedFromList(null!); Action nullRemoved = () => harness.EmitRemovedFromList(null!); - nullAdded.Should().Throw().WithParameterName("items"); - nullRemoved.Should().Throw().WithParameterName("items"); + _ = nullAdded.Should().Throw().WithParameterName("items"); + _ = nullRemoved.Should().Throw().WithParameterName("items"); foreach (var notification in received) { @@ -564,8 +570,8 @@ public void QuaternaryBase_NoObserverAndLegacyCollectionChangedBranches_ShouldEx harness.EmitSingle(CacheAction.Removed, CollectionValueFour); harness.EmitSingle(CacheAction.Moved, CollectionValueFive); - SpinWait.SpinUntil(() => actions.Count >= 4, TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); - actions.Should().Equal( + _ = SpinWait.SpinUntil(() => actions.Count >= 4, TimeSpan.FromSeconds(CollectionValueTwo)).Should().BeTrue(); + _ = actions.Should().Equal( NotifyCollectionChangedAction.Reset, NotifyCollectionChangedAction.Reset, NotifyCollectionChangedAction.Reset, @@ -578,10 +584,8 @@ public void QuaternaryBase_NoObserverAndLegacyCollectionChangedBranches_ShouldEx public async Task QuaternaryBase_PrivateEventProcessorEdges_ShouldExecute() { var baseType = typeof(QuaternaryBase); - var processEvents = baseType.GetMethod("ProcessEventsAsync", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMethodException(baseType.FullName, "ProcessEventsAsync"); - var ensureStarted = baseType.GetMethod("EnsureEventProcessorStarted", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMethodException(baseType.FullName, "EnsureEventProcessorStarted"); + var processEvents = FindInstanceMethod(baseType, "ProcessEventsAsync"); + var ensureStarted = FindInstanceMethod(baseType, "EnsureEventProcessorStarted"); using (var nullStateHarness = new QuaternaryBaseHarness()) { @@ -616,7 +620,7 @@ public async Task QuaternaryBase_PrivateEventProcessorEdges_ShouldExecute() using (var nullHandlerHarness = new QuaternaryBaseHarness()) { - InvokePrivate( + _ = InvokePrivate( nullHandlerHarness, "InvokeLegacyINCC", new CacheNotify(CacheAction.BatchAdded, default, CreateBatch(1, CollectionValueTwo))); @@ -627,9 +631,9 @@ public async Task QuaternaryBase_PrivateEventProcessorEdges_ShouldExecute() var actions = new List(); legacyHarness.CollectionChanged += (_, args) => actions.Add(args.Action); - InvokePrivate(legacyHarness, "InvokeLegacyINCC", new CacheNotify(CacheAction.Cleared, default)); + _ = InvokePrivate(legacyHarness, "InvokeLegacyINCC", new CacheNotify(CacheAction.Cleared, default)); - actions.Should().Contain(NotifyCollectionChangedAction.Reset); + _ = actions.Should().Contain(NotifyCollectionChangedAction.Reset); } using var startedRaceHarness = new QuaternaryBaseHarness(); @@ -640,13 +644,171 @@ public async Task QuaternaryBase_PrivateEventProcessorEdges_ShouldExecute() lock (gate) { ensureTask = Task.Run(() => ensureStarted.Invoke(startedRaceHarness, null)); - SpinWait.SpinUntil(() => ensureTask.IsCompleted, TimeSpan.FromMilliseconds(ProcessorPollMilliseconds)); + _ = SpinWait.SpinUntil(() => ensureTask.IsCompleted, TimeSpan.FromMilliseconds(ProcessorPollMilliseconds)); SetPrivateField(startedRaceHarness, "_eventProcessorStarted", 1); } await ensureTask; } + /// Creates an integer-string key-value pair. + /// The pair key. + /// The pair value. + /// The created pair. + private static KeyValuePair Pair(int key, string value) => new(key, value); + + /// Creates an array containing a contiguous integer range. + /// The first value. + /// The number of values. + /// The populated array. + private static int[] CreateRangeArray(int start, int count) + { + var values = new int[count]; + for (var index = 0; index < count; index++) + { + values[index] = start + index; + } + + return values; + } + + /// Creates a list containing a contiguous integer range. + /// The first value. + /// The number of values. + /// The populated list. + private static List CreateRangeList(int start, int count) => new(CreateRangeArray(start, count)); + + /// Copies matching values to a new array. + /// The values to filter. + /// The predicate that selects values. + /// An array containing the selected values. + private static int[] FilterToArray(IEnumerable values, Func predicate) + { + var filtered = new List(); + foreach (var value in values) + { + if (predicate(value)) + { + filtered.Add(value); + } + } + + return filtered.ToArray(); + } + + /// Copies matching values to a new list. + /// The values to filter. + /// The predicate that selects values. + /// A list containing the selected values. + private static List FilterToList(IEnumerable values, Func predicate) => + new(FilterToArray(values, predicate)); + + /// Creates constant-shard items for the requested identifier range. + /// The first identifier. + /// The number of items. + /// The populated array. + private static ConstantShardItem[] CreateConstantShardItems(int start, int count) + { + var items = new ConstantShardItem[count]; + for (var index = 0; index < count; index++) + { + items[index] = new(start + index); + } + + return items; + } + + /// Creates integer-string pairs for the requested key range. + /// The first key. + /// The number of pairs. + /// The populated array. + private static KeyValuePair[] CreateIntStringPairs(int start, int count) + { + var pairs = new KeyValuePair[count]; + for (var index = 0; index < count; index++) + { + var key = start + index; + pairs[index] = new(key, $"value-{key}"); + } + + return pairs; + } + + /// Extracts even keys from the supplied pairs. + /// The pairs to inspect. + /// The selected keys. + private static List ExtractEvenKeys(IEnumerable> pairs) + { + var keys = new List(); + foreach (var pair in pairs) + { + if (pair.Key % CollectionValueTwo == 0) + { + keys.Add(pair.Key); + } + } + + return keys; + } + + /// Creates constant-shard key-value pairs for the requested identifier range. + /// The first identifier. + /// The number of pairs. + /// The populated array. + private static KeyValuePair[] CreateConstantShardPairs(int start, int count) + { + var pairs = new KeyValuePair[count]; + for (var index = 0; index < count; index++) + { + var id = start + index; + pairs[index] = new(new(id), $"v{id}"); + } + + return pairs; + } + + /// Extracts keys from constant-shard pairs. + /// The pairs to inspect. + /// The extracted keys. + private static ConstantShardKey[] ExtractKeys(IReadOnlyList> pairs) + { + var keys = new ConstantShardKey[pairs.Count]; + for (var index = 0; index < pairs.Count; index++) + { + keys[index] = pairs[index].Key; + } + + return keys; + } + + /// Extracts actions from received cache notifications. + /// The notifications to inspect. + /// The extracted actions. + private static CacheAction[] ExtractActions(List> notifications) + { + var actions = new CacheAction[notifications.Count]; + for (var index = 0; index < notifications.Count; index++) + { + actions[index] = notifications[index].Action; + } + + return actions; + } + + /// Extracts batch counts from received cache notifications. + /// The notifications to inspect. + /// The extracted batch counts. + private static int[] ExtractBatchCounts(List> notifications) + { + var counts = new int[notifications.Count]; + for (var index = 0; index < notifications.Count; index++) + { + counts[index] = notifications[index].Batch!.Count; + } + + return counts; + } + /// Provides Yield. /// The T type. /// The result. @@ -666,7 +828,7 @@ private static PooledBatch CreateBatch(params int[] values) { var array = ArrayPool.Shared.Rent(Math.Max(1, values.Length)); Array.Copy(values, array, values.Length); - return new PooledBatch(array, values.Length); + return new(array, values.Length); } /// Provides InvokePrivate. @@ -677,11 +839,27 @@ private static PooledBatch CreateBatch(params int[] values) private static object? InvokePrivate(object target, string methodName, params object?[] args) { var baseType = target.GetType().BaseType ?? throw new InvalidOperationException("The test harness has no base type."); - var method = baseType.GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMethodException(baseType.FullName, methodName); + var method = FindInstanceMethod(baseType, methodName); return method.Invoke(target, args); } + /// Finds an instance method, including an internal implementation detail required by the coverage harness. + /// The type to inspect. + /// The required method name. + /// The matching instance method. + private static MethodInfo FindInstanceMethod(Type type, string methodName) + { + foreach (var method in type.GetRuntimeMethods()) + { + if (!method.IsStatic && method.Name == methodName) + { + return method; + } + } + + throw new MissingMethodException(type.FullName, methodName); + } + /// Provides SetPrivateField. /// The target value. /// The fieldName value. @@ -690,11 +868,13 @@ private static void SetPrivateField(object target, string fieldName, object? val { for (var type = target.GetType(); type is not null; type = type.BaseType) { - var field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - if (field is not null) + foreach (var field in type.GetRuntimeFields()) { - field.SetValue(target, value); - return; + if (!field.IsStatic && field.Name == fieldName) + { + field.SetValue(target, value); + return; + } } } @@ -704,6 +884,7 @@ private static void SetPrivateField(object target, string fieldName, object? val /// Provides ImmediateSynchronizationContext. private sealed class ImmediateSynchronizationContext : SynchronizationContext { + /// The number of callbacks posted through this context. private int _postCount; /// Gets PostCount. @@ -711,7 +892,7 @@ private sealed class ImmediateSynchronizationContext : SynchronizationContext public override void Post(SendOrPostCallback d, object? state) { - Interlocked.Increment(ref _postCount); + _ = Interlocked.Increment(ref _postCount); d(state); } } @@ -788,7 +969,7 @@ private sealed class QuaternaryBaseHarness : QuaternaryBase /// The item value. public void EmitSingle(CacheAction action, int item) => Emit(action, item); - public override IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); + public override IEnumerator GetEnumerator() => ((IEnumerable)Array.Empty()).GetEnumerator(); } } #endif diff --git a/src/ReactiveList.Test/QuaternaryDictionaryExtensionsTests.cs b/src/ReactiveList.Test/QuaternaryDictionaryExtensionsTests.cs index 7995b0c..bca8390 100644 --- a/src/ReactiveList.Test/QuaternaryDictionaryExtensionsTests.cs +++ b/src/ReactiveList.Test/QuaternaryDictionaryExtensionsTests.cs @@ -14,18 +14,25 @@ namespace ReactiveList.Test; /// Contains unit tests for the QuaternaryDictionary extension methods in QuaternaryExtensions. public class QuaternaryDictionaryExtensionsTests { + /// The second key used by dictionary test data. private const int SecondEntryKey = 2; + /// The third key used by dictionary test data. private const int ThirdEntryKey = 3; + /// The fourth key used by dictionary test data. private const int FourthEntryKey = 4; + /// The delay used to allow throttled view updates to complete. private const int ViewUpdateDelayMilliseconds = 200; + /// The first person name used by the test data. private const string AliceName = "Alice"; + /// The name of the secondary index that groups people by city. private const string CityIndexName = "ByCity"; + /// The textual value associated with the third key. private const string ThreeText = "three"; /// Verifies that CreateView returns a view with all items when no filter is applied. @@ -55,11 +62,11 @@ public void CreateView_WithFilter_ShouldContainOnlyMatchingItems() new KeyValuePair(ThirdEntryKey, ThreeText) ]); - using var view = dict.CreateView(kvp => kvp.Value.Length == 3, Sequencer.Default, throttleMs: 10); + using var view = dict.CreateView(static kvp => kvp.Value.Length == 3, Sequencer.Default, throttleMs: 10); Assert.Equal(SecondEntryKey, view.Items.Count); - Assert.Contains(view.Items, kvp => kvp.Value == "one"); - Assert.Contains(view.Items, kvp => kvp.Value == "two"); + Assert.Contains(view.Items, static kvp => kvp.Value == "one"); + Assert.Contains(view.Items, static kvp => kvp.Value == "two"); } /// Verifies that CreateViewBySecondaryIndex filters items by the secondary value index key. @@ -67,7 +74,7 @@ public void CreateView_WithFilter_ShouldContainOnlyMatchingItems() public void CreateViewBySecondaryIndex_ShouldFilterByKey() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(CityIndexName, p => p.City); + dict.AddValueIndex(CityIndexName, static p => p.City); dict.AddRange([ new KeyValuePair(1, new TestPerson(AliceName, "NYC")), new KeyValuePair(SecondEntryKey, new TestPerson("Bob", "LA")), @@ -77,7 +84,7 @@ public void CreateViewBySecondaryIndex_ShouldFilterByKey() using var view = dict.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 10); Assert.Equal(SecondEntryKey, view.Items.Count); - Assert.All(view.Items, kvp => Assert.Equal("NYC", kvp.Value.City)); + Assert.All(view.Items, static kvp => Assert.Equal("NYC", kvp.Value.City)); } /// Verifies that CreateViewBySecondaryIndex with multiple keys includes items matching any key. @@ -85,7 +92,7 @@ public void CreateViewBySecondaryIndex_ShouldFilterByKey() public void CreateViewBySecondaryIndex_WithMultipleKeys_ShouldIncludeAllMatches() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(CityIndexName, p => p.City); + dict.AddValueIndex(CityIndexName, static p => p.City); dict.AddRange([ new KeyValuePair(1, new TestPerson(AliceName, "NYC")), new KeyValuePair(SecondEntryKey, new TestPerson("Bob", "LA")), @@ -93,7 +100,7 @@ public void CreateViewBySecondaryIndex_WithMultipleKeys_ShouldIncludeAllMatches( new KeyValuePair(FourthEntryKey, new TestPerson("Diana", "NYC")) ]); - using var view = dict.CreateViewBySecondaryIndex(CityIndexName, ["NYC", "LA"], Sequencer.Default, throttleMs: 10); + using var view = dict.CreateViewBySecondaryIndex(CityIndexName, ["NYC", "LA"], Sequencer.Default, throttleMs: 10); Assert.Equal(ThirdEntryKey, view.Items.Count); } @@ -113,7 +120,7 @@ public void ToProperty_ShouldSetProperty() using var view = dict.CreateView(Sequencer.Default, throttleMs: 10) .ToProperty(x => result = x); - Assert.NotNull(result); + _ = Assert.NotNull(result); Assert.Equal(ThirdEntryKey, result!.Count); } @@ -130,8 +137,8 @@ public async Task ReactiveView_ShouldUpdateOnAdd() // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); - Assert.Single(view.Items); - Assert.Contains(view.Items, kvp => kvp.Key == 1 && kvp.Value == "one"); + _ = Assert.Single(view.Items); + Assert.Contains(view.Items, static kvp => kvp.Key == 1 && kvp.Value == "one"); } /// Verifies that ReactiveView updates when items are removed from the source dictionary. @@ -151,13 +158,13 @@ public async Task ReactiveView_ShouldUpdateOnRemove() // Initial state Assert.Equal(ThirdEntryKey, view.Items.Count); - dict.Remove(SecondEntryKey); + _ = dict.Remove(SecondEntryKey); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); Assert.Equal(SecondEntryKey, view.Items.Count); - Assert.DoesNotContain(view.Items, kvp => kvp.Key == SecondEntryKey); + Assert.DoesNotContain(view.Items, static kvp => kvp.Key == SecondEntryKey); } /// Verifies that CreateViewBySecondaryIndex updates when new matching items are added. @@ -166,14 +173,14 @@ public async Task ReactiveView_ShouldUpdateOnRemove() public async Task CreateViewBySecondaryIndex_ShouldUpdateOnAdd() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(CityIndexName, p => p.City); - dict.Add(1, new TestPerson(AliceName, "NYC")); + dict.AddValueIndex(CityIndexName, static p => p.City); + dict.Add(1, new(AliceName, "NYC")); using var view = dict.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 50); - Assert.Single(view.Items); + _ = Assert.Single(view.Items); - dict.Add(SecondEntryKey, new TestPerson("Bob", "NYC")); + dict.Add(SecondEntryKey, new("Bob", "NYC")); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); @@ -187,20 +194,20 @@ public async Task CreateViewBySecondaryIndex_ShouldUpdateOnAdd() public async Task CreateViewBySecondaryIndex_ShouldNotIncludeNonMatchingItems() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(CityIndexName, p => p.City); - dict.Add(1, new TestPerson(AliceName, "NYC")); + dict.AddValueIndex(CityIndexName, static p => p.City); + dict.Add(1, new(AliceName, "NYC")); using var view = dict.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 50); - Assert.Single(view.Items); + _ = Assert.Single(view.Items); - dict.Add(SecondEntryKey, new TestPerson("Bob", "LA")); + dict.Add(SecondEntryKey, new("Bob", "LA")); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); // Should still be only 1 item (Alice from NYC) - Assert.Single(view.Items); + _ = Assert.Single(view.Items); } /// Provides TestPerson. diff --git a/src/ReactiveList.Test/QuaternaryDictionaryTests.cs b/src/ReactiveList.Test/QuaternaryDictionaryTests.cs index b279f08..73fe712 100644 --- a/src/ReactiveList.Test/QuaternaryDictionaryTests.cs +++ b/src/ReactiveList.Test/QuaternaryDictionaryTests.cs @@ -5,10 +5,7 @@ #if NET6_0_OR_GREATER || NETFRAMEWORK using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; -using System.Reflection; using System.Threading; using CP.Primitives.Collections; using CP.Primitives.Core; @@ -26,28 +23,40 @@ namespace ReactiveList.Test; /// intended to validate the public API and observable behaviors of QuaternaryDictionary. public class QuaternaryDictionaryTests { + /// The second key used by dictionary test data. private const int SecondDictionaryKey = 2; + /// The third key used by dictionary test data. private const int ThirdDictionaryKey = 3; + /// The fourth key used by dictionary test data. private const int FourthDictionaryKey = 4; + /// The expected length of five-character test values. private const int FiveCharacterLength = 5; + /// The expected length of nine-character test values. private const int NineCharacterLength = 9; + /// The tenth key used by dictionary test data. private const int TenthDictionaryKey = 10; + /// The expected length of eleven-character test values. private const int ElevenCharacterLength = 11; + /// The twentieth key used by dictionary test data. private const int TwentiethDictionaryKey = 20; + /// A key that is intentionally absent from test dictionaries. private const int MissingDictionaryKey = 99; + /// The textual value associated with the third key. private const string ThreeText = "three"; + /// The name of the secondary index that groups values by length. private const string LengthIndexName = "ByLength"; + /// A five-character value used by length-index tests. private const string ShortValue = "short"; /// @@ -61,13 +70,13 @@ public void AddAndIndexer_ShouldStoreAndUpdateValues() { using var dict = new QuaternaryDictionary { { 1, "one" } }; - dict[1].Should().Be("one"); + _ = dict[1].Should().Be("one"); dict[1] = "uno"; - dict[1].Should().Be("uno"); - dict.Count.Should().Be(1); - dict.ContainsKey(1).Should().BeTrue(); + _ = dict[1].Should().Be("uno"); + _ = dict.Count.Should().Be(1); + _ = dict.ContainsKey(1).Should().BeTrue(); } /// @@ -81,10 +90,10 @@ public void TryAdd_ShouldPreventDuplicateKeys() { using var dict = new QuaternaryDictionary(); - dict.TryAdd(SecondDictionaryKey, "two").Should().BeTrue(); - dict.TryAdd(SecondDictionaryKey, "dos").Should().BeFalse(); + _ = dict.TryAdd(SecondDictionaryKey, "two").Should().BeTrue(); + _ = dict.TryAdd(SecondDictionaryKey, "dos").Should().BeFalse(); - dict[SecondDictionaryKey].Should().Be("two"); + _ = dict[SecondDictionaryKey].Should().Be("two"); } /// @@ -114,9 +123,9 @@ public void AddOrUpdate_ShouldEmitCorrectActions() dict.AddOrUpdate(ThirdDictionaryKey, "tres"); dict.AddOrUpdate(ThirdDictionaryKey, ThreeText); - reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - actions.Should().ContainInOrder(CacheAction.Added, CacheAction.Updated); - dict[ThirdDictionaryKey].Should().Be(ThreeText); + _ = reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = actions.Should().ContainInOrder(CacheAction.Added, CacheAction.Updated); + _ = dict[ThirdDictionaryKey].Should().Be(ThreeText); } /// @@ -131,9 +140,9 @@ public void Remove_ShouldRemoveExistingAndReturnFalseForMissing() { using var dict = new QuaternaryDictionary { { 1, "one" } }; - dict.Remove(1).Should().BeTrue(); - dict.ContainsKey(1).Should().BeFalse(); - dict.Remove(1).Should().BeFalse(); + _ = dict.Remove(1).Should().BeTrue(); + _ = dict.ContainsKey(1).Should().BeFalse(); + _ = dict.Remove(1).Should().BeFalse(); } /// @@ -164,16 +173,16 @@ public void AddRange_ShouldEmitBatchAndExposeKeysAndValues() dict.AddRange(items); - reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - notification.Should().NotBeNull(); - notification!.Action.Should().Be(CacheAction.BatchAdded); - notification.Batch.Should().NotBeNull(); - notification.Batch!.Count.Should().Be(ThirdDictionaryKey); + _ = reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = notification.Should().NotBeNull(); + _ = notification!.Action.Should().Be(CacheAction.BatchAdded); + _ = notification.Batch.Should().NotBeNull(); + _ = notification.Batch!.Count.Should().Be(ThirdDictionaryKey); notification.Batch.Dispose(); - dict.Count.Should().Be(ThirdDictionaryKey); - dict.Keys.Should().BeEquivalentTo([1, SecondDictionaryKey, ThirdDictionaryKey]); - dict.Values.Should().BeEquivalentTo(["one", "two", ThreeText]); + _ = dict.Count.Should().Be(ThirdDictionaryKey); + _ = dict.Keys.Should().BeEquivalentTo([1, SecondDictionaryKey, ThirdDictionaryKey]); + _ = dict.Values.Should().BeEquivalentTo(["one", "two", ThreeText]); } /// @@ -186,17 +195,19 @@ public void AddRange_ShouldEmitBatchAndExposeKeysAndValues() [Test] public void CopyTo_ShouldCopyAllEntries() { - using var dict = new QuaternaryDictionary - { - { 1, "one" }, - { SecondDictionaryKey, "two" } - }; + using var dict = new QuaternaryDictionary { { 1, "one" }, { SecondDictionaryKey, "two" } }; var array = new KeyValuePair[3]; dict.CopyTo(array, 1); - array.Skip(1).Should().BeEquivalentTo(dict.ToArray()); + var copiedEntries = new List>(dict.Count); + for (var index = 1; index < array.Length; index++) + { + copiedEntries.Add(array[index]); + } + + _ = copiedEntries.Should().BeEquivalentTo(dict); } /// Verifies that the value index in a QuaternaryDictionary correctly tracks additions and removals of items. @@ -207,41 +218,37 @@ public void CopyTo_ShouldCopyAllEntries() public void ValueIndex_ShouldTrackAddsAndRemovals() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(LengthIndexName, v => v.Length); + dict.AddValueIndex(LengthIndexName, static v => v.Length); dict.AddRange([ new KeyValuePair(1, ShortValue), new KeyValuePair(SecondDictionaryKey, "longvalue") ]); - GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().ContainSingle().Which.Should().Be(ShortValue); + _ = GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().ContainSingle().Which.Should().Be(ShortValue); - dict.Remove(1); + _ = dict.Remove(1); - GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().BeEmpty(); + _ = GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().BeEmpty(); dict.Clear(); - GetLookup(dict, LengthIndexName, NineCharacterLength).Should().BeEmpty(); + _ = GetLookup(dict, LengthIndexName, NineCharacterLength).Should().BeEmpty(); } /// Verifies that the Lookup method returns the correct result for existing and non-existing keys. [Test] public void Lookup_ShouldReturnCorrectResult() { - using var dict = new QuaternaryDictionary - { - { 1, "one" }, - { SecondDictionaryKey, "two" } - }; + using var dict = new QuaternaryDictionary { { 1, "one" }, { SecondDictionaryKey, "two" } }; var result1 = dict.Lookup(1); - result1.HasValue.Should().BeTrue(); - result1.Value.Should().Be("one"); + _ = result1.HasValue.Should().BeTrue(); + _ = result1.Value.Should().Be("one"); var result2 = dict.Lookup(MissingDictionaryKey); - result2.HasValue.Should().BeFalse(); - result2.Value.Should().BeNull(); + _ = result2.HasValue.Should().BeFalse(); + _ = result2.Value.Should().BeNull(); } /// Verifies that RemoveKeys removes multiple keys in a batch operation. @@ -271,13 +278,13 @@ public void RemoveKeys_ShouldRemoveMultipleKeysAndEmitBatch() dict.RemoveKeys([SecondDictionaryKey, FourthDictionaryKey]); - reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - notification.Should().NotBeNull(); - dict.Count.Should().Be(SecondDictionaryKey); - dict.ContainsKey(SecondDictionaryKey).Should().BeFalse(); - dict.ContainsKey(FourthDictionaryKey).Should().BeFalse(); - dict.ContainsKey(1).Should().BeTrue(); - dict.ContainsKey(ThirdDictionaryKey).Should().BeTrue(); + _ = reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = notification.Should().NotBeNull(); + _ = dict.Count.Should().Be(SecondDictionaryKey); + _ = dict.ContainsKey(SecondDictionaryKey).Should().BeFalse(); + _ = dict.ContainsKey(FourthDictionaryKey).Should().BeFalse(); + _ = dict.ContainsKey(1).Should().BeTrue(); + _ = dict.ContainsKey(ThirdDictionaryKey).Should().BeTrue(); } /// Verifies that RemoveMany with a predicate removes matching entries. @@ -291,13 +298,13 @@ public void RemoveMany_WithPredicate_ShouldRemoveMatchingEntries() new KeyValuePair(ThirdDictionaryKey, "verylongvalue") ]); - var removedCount = dict.RemoveMany(kvp => kvp.Value.Length > 5); + var removedCount = dict.RemoveMany(static kvp => kvp.Value.Length > 5); - removedCount.Should().Be(SecondDictionaryKey); - dict.Count.Should().Be(1); - dict.ContainsKey(1).Should().BeTrue(); - dict.ContainsKey(SecondDictionaryKey).Should().BeFalse(); - dict.ContainsKey(ThirdDictionaryKey).Should().BeFalse(); + _ = removedCount.Should().Be(SecondDictionaryKey); + _ = dict.Count.Should().Be(1); + _ = dict.ContainsKey(1).Should().BeTrue(); + _ = dict.ContainsKey(SecondDictionaryKey).Should().BeFalse(); + _ = dict.ContainsKey(ThirdDictionaryKey).Should().BeFalse(); } /// Verifies that the Edit method allows batch modifications with a single notification. @@ -323,19 +330,19 @@ public void Edit_ShouldPerformBatchModificationsWithSingleNotification() reset.Set(); }); - dict.Edit(innerDict => + dict.Edit(static innerDict => { innerDict.Clear(); innerDict.Add(TenthDictionaryKey, "ten"); innerDict.Add(TwentiethDictionaryKey, "twenty"); }); - reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - notifications.Should().ContainSingle().Which.Should().Be(CacheAction.BatchOperation); - dict.Count.Should().Be(SecondDictionaryKey); - dict.ContainsKey(TenthDictionaryKey).Should().BeTrue(); - dict.ContainsKey(TwentiethDictionaryKey).Should().BeTrue(); - dict.ContainsKey(1).Should().BeFalse(); + _ = reset.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = notifications.Should().ContainSingle().Which.Should().Be(CacheAction.BatchOperation); + _ = dict.Count.Should().Be(SecondDictionaryKey); + _ = dict.ContainsKey(TenthDictionaryKey).Should().BeTrue(); + _ = dict.ContainsKey(TwentiethDictionaryKey).Should().BeTrue(); + _ = dict.ContainsKey(1).Should().BeFalse(); } /// Verifies that Edit updates value indices correctly. @@ -343,23 +350,23 @@ public void Edit_ShouldPerformBatchModificationsWithSingleNotification() public void Edit_ShouldUpdateValueIndicesCorrectly() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(LengthIndexName, v => v.Length); + dict.AddValueIndex(LengthIndexName, static v => v.Length); dict.AddRange([ new KeyValuePair(1, ShortValue), new KeyValuePair(SecondDictionaryKey, "longvalue") ]); - dict.Edit(innerDict => + dict.Edit(static innerDict => { innerDict.Clear(); innerDict.Add(ThirdDictionaryKey, "tiny"); innerDict.Add(FourthDictionaryKey, "biggervalue"); }); - GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().BeEmpty(); - GetLookup(dict, LengthIndexName, FourthDictionaryKey).Should().ContainSingle().Which.Should().Be("tiny"); - GetLookup(dict, LengthIndexName, ElevenCharacterLength).Should().ContainSingle().Which.Should().Be("biggervalue"); + _ = GetLookup(dict, LengthIndexName, FiveCharacterLength).Should().BeEmpty(); + _ = GetLookup(dict, LengthIndexName, FourthDictionaryKey).Should().ContainSingle().Which.Should().Be("tiny"); + _ = GetLookup(dict, LengthIndexName, ElevenCharacterLength).Should().ContainSingle().Which.Should().Be("biggervalue"); } /// Verifies that GetValuesBySecondaryIndex returns matching values. @@ -367,7 +374,7 @@ public void Edit_ShouldUpdateValueIndicesCorrectly() public void GetValuesBySecondaryIndex_ShouldReturnMatchingValues() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(LengthIndexName, v => v.Length); + dict.AddValueIndex(LengthIndexName, static v => v.Length); dict.AddRange([ new KeyValuePair(1, "one"), @@ -376,13 +383,13 @@ public void GetValuesBySecondaryIndex_ShouldReturnMatchingValues() new KeyValuePair(FourthDictionaryKey, "four") ]); - var threeCharValues = dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).ToList(); - threeCharValues.Should().HaveCount(SecondDictionaryKey); - threeCharValues.Should().Contain("one"); - threeCharValues.Should().Contain("two"); + var threeCharValues = new List(dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey)); + _ = threeCharValues.Should().HaveCount(SecondDictionaryKey); + _ = threeCharValues.Should().Contain("one"); + _ = threeCharValues.Should().Contain("two"); - var fiveCharValues = dict.GetValuesBySecondaryIndex(LengthIndexName, FiveCharacterLength).ToList(); - fiveCharValues.Should().ContainSingle().Which.Should().Be(ThreeText); + var fiveCharValues = new List(dict.GetValuesBySecondaryIndex(LengthIndexName, FiveCharacterLength)); + _ = fiveCharValues.Should().ContainSingle().Which.Should().Be(ThreeText); } /// Verifies that GetValuesBySecondaryIndex returns empty for non-existent index. @@ -392,7 +399,7 @@ public void GetValuesBySecondaryIndex_WithNonExistentIndex_ShouldReturnEmpty() using var dict = new QuaternaryDictionary { { 1, "one" } }; var result = dict.GetValuesBySecondaryIndex("NonExistent", "key"); - result.Should().BeEmpty(); + _ = result.Should().BeEmpty(); } /// Verifies that ValueMatchesSecondaryIndex returns correct results. @@ -400,12 +407,12 @@ public void GetValuesBySecondaryIndex_WithNonExistentIndex_ShouldReturnEmpty() public void ValueMatchesSecondaryIndex_ShouldReturnCorrectResult() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(LengthIndexName, v => v.Length); + dict.AddValueIndex(LengthIndexName, static v => v.Length); dict.Add(1, "test"); - dict.ValueMatchesSecondaryIndex(LengthIndexName, "test", FourthDictionaryKey).Should().BeTrue(); - dict.ValueMatchesSecondaryIndex(LengthIndexName, "test", FiveCharacterLength).Should().BeFalse(); - dict.ValueMatchesSecondaryIndex("NonExistent", "test", FourthDictionaryKey).Should().BeFalse(); + _ = dict.ValueMatchesSecondaryIndex(LengthIndexName, "test", FourthDictionaryKey).Should().BeTrue(); + _ = dict.ValueMatchesSecondaryIndex(LengthIndexName, "test", FiveCharacterLength).Should().BeFalse(); + _ = dict.ValueMatchesSecondaryIndex("NonExistent", "test", FourthDictionaryKey).Should().BeFalse(); } /// Verifies that GetValuesBySecondaryIndex updates after additions and removals. @@ -413,43 +420,35 @@ public void ValueMatchesSecondaryIndex_ShouldReturnCorrectResult() public void GetValuesBySecondaryIndex_ShouldUpdateAfterAdditionsAndRemovals() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(LengthIndexName, v => v.Length); + dict.AddValueIndex(LengthIndexName, static v => v.Length); dict.Add(1, "one"); - dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().ContainSingle().Which.Should().Be("one"); + _ = dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().ContainSingle().Which.Should().Be("one"); dict.Add(SecondDictionaryKey, "two"); - dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().HaveCount(SecondDictionaryKey); + _ = dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().HaveCount(SecondDictionaryKey); - dict.Remove(1); - dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().ContainSingle().Which.Should().Be("two"); + _ = dict.Remove(1); + _ = dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().ContainSingle().Which.Should().Be("two"); dict.Clear(); - dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().BeEmpty(); + _ = dict.GetValuesBySecondaryIndex(LengthIndexName, ThirdDictionaryKey).Should().BeEmpty(); } /// Provides GetLookup. /// The TKey type. /// The TValue type. + /// The secondary-index key type. /// The dictionary value. /// The indexName value. /// The key value. /// The result. - private static IEnumerable GetLookup(QuaternaryDictionary dictionary, string indexName, object key) + private static IEnumerable GetLookup( + QuaternaryDictionary dictionary, + string indexName, + TIndexKey key) where TKey : notnull - { - // The Indices property is in the base class QuaternaryBase - var baseType = dictionary.GetType().BaseType ?? throw new InvalidOperationException("The dictionary has no base type."); - var property = baseType.GetProperty("Indices", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy) - ?? throw new MissingMemberException(baseType.FullName, "Indices"); - var indices = (ConcurrentDictionary>)(property.GetValue(dictionary) - ?? throw new InvalidOperationException("The secondary-index dictionary was null.")); - indices.TryGetValue(indexName, out var index).Should().BeTrue(); - var concreteIndex = index ?? throw new InvalidOperationException($"The secondary index '{indexName}' was not found."); - var lookupMethod = concreteIndex.GetType().GetMethod(nameof(QuaternaryDictionary<,>.Lookup), BindingFlags.Public | BindingFlags.Instance) - ?? throw new MissingMethodException(concreteIndex.GetType().FullName, nameof(QuaternaryDictionary<,>.Lookup)); - return (IEnumerable)(lookupMethod.Invoke(concreteIndex, [key]) - ?? throw new InvalidOperationException("The secondary-index lookup returned null.")); - } + where TIndexKey : notnull => + dictionary.GetValuesBySecondaryIndex(indexName, key); } #endif diff --git a/src/ReactiveList.Test/QuaternaryExtensionsAdditionalTests.cs b/src/ReactiveList.Test/QuaternaryExtensionsAdditionalTests.cs index 3f62828..0f50bfd 100644 --- a/src/ReactiveList.Test/QuaternaryExtensionsAdditionalTests.cs +++ b/src/ReactiveList.Test/QuaternaryExtensionsAdditionalTests.cs @@ -5,7 +5,6 @@ #if NET8_0_OR_GREATER || NETFRAMEWORK using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using CP.Primitives; using CP.Primitives.Collections; @@ -22,54 +21,79 @@ namespace ReactiveList.Test; /// public class QuaternaryExtensionsAdditionalTests { + /// The expected number of items in a pair. private const int ExpectedPairCount = 2; + /// The expected number of items in a triple. private const int ExpectedTripleCount = 3; + /// The expected number of items in five-item test data. private const int ExpectedFiveItems = 5; + /// The throttle interval used when constructing test views. private const int ViewThrottleMilliseconds = 10; + /// The delay used to allow an initial view update to complete. private const int InitialViewDelayMilliseconds = 50; + /// The delay used to allow a filter update to complete. private const int FilterUpdateDelayMilliseconds = 100; + /// The delay used to allow a selection update to complete. private const int SelectionUpdateDelayMilliseconds = 150; + /// The delay used to allow throttled changes to coalesce. private const int CoalescingDelayMilliseconds = 200; - private const decimal FirstOrderAmount = 100m; + /// The amount assigned to the first test order. + private const decimal FirstOrderAmount = 100M; - private const decimal ThirdOrderAmount = 150m; + /// The amount assigned to the third test order. + private const decimal ThirdOrderAmount = 150M; - private const decimal SecondOrderAmount = 200m; + /// The amount assigned to the second test order. + private const decimal SecondOrderAmount = 200M; - private const decimal FourthOrderAmount = 300m; + /// The amount assigned to the fourth test order. + private const decimal FourthOrderAmount = 300M; + /// The first employee name used by the test data. private const string AliceName = "Alice"; + /// The third employee name used by the test data. private const string CharlieName = "Charlie"; + /// The name of the secondary index that groups employees by department. private const string DepartmentIndexName = "ByDepartment"; + /// The engineering department value used by employee tests. private const string EngineeringDepartment = "Engineering"; + /// The sales department value used by employee tests. private const string SalesDepartment = "Sales"; + /// The marketing department value used by employee tests. private const string MarketingDepartment = "Marketing"; + /// The name of the secondary index that groups orders by status. private const string StatusIndexName = "ByStatus"; + /// The pending status used by order tests. private const string PendingStatus = "Pending"; + /// The shipped status used by order tests. private const string ShippedStatus = "Shipped"; + /// The delivered status used by order tests. private const string DeliveredStatus = "Delivered"; + /// The identifier of the first test order. private const string FirstOrderId = "ORD001"; + /// The identifier of the second test order. private const string SecondOrderId = "ORD002"; + /// The identifier of the third test order. private const string ThirdOrderId = "ORD003"; /// Tests that CreateViewBySecondaryIndex with observable keys rebuilds when keys change. @@ -79,7 +103,7 @@ public async Task CreateViewBySecondaryIndex_WithObservableKeys_RebuildsWhenKeys { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); list.AddRange( [ new Employee(AliceName, EngineeringDepartment), @@ -90,29 +114,29 @@ public async Task CreateViewBySecondaryIndex_WithObservableKeys_RebuildsWhenKeys ]); // Verify index works directly first - var directLookup = list.GetItemsBySecondaryIndex(DepartmentIndexName, EngineeringDepartment).ToList(); - directLookup.Count.Should().Be(ExpectedPairCount, "direct index lookup should find 2 Engineering employees"); + var directLookup = new List(list.GetItemsBySecondaryIndex(DepartmentIndexName, EngineeringDepartment)); + _ = directLookup.Count.Should().Be(ExpectedPairCount, "direct index lookup should find 2 Engineering employees"); // Verify ItemMatchesSecondaryIndex works - var alice = list.First(e => e.Name == AliceName); - list.ItemMatchesSecondaryIndex(DepartmentIndexName, alice, EngineeringDepartment).Should().BeTrue("Alice should match Engineering"); - list.ItemMatchesSecondaryIndex(DepartmentIndexName, alice, SalesDepartment).Should().BeFalse("Alice should not match Sales"); + var alice = FindEmployeeByName(list, AliceName); + _ = list.ItemMatchesSecondaryIndex(DepartmentIndexName, alice, EngineeringDepartment).Should().BeTrue("Alice should match Engineering"); + _ = list.ItemMatchesSecondaryIndex(DepartmentIndexName, alice, SalesDepartment).Should().BeFalse("Alice should not match Sales"); // Verify filter logic works directly on list var keysToMatch = new HashSet([EngineeringDepartment]); - var filteredByFilter = list.Where(item => keysToMatch.Any(key => list.ItemMatchesSecondaryIndex(DepartmentIndexName, item, key))).ToList(); - filteredByFilter.Count.Should().Be(ExpectedPairCount, "filter applied to list should find 2 Engineering employees"); + var filteredByFilter = FilterBySecondaryIndex(list, keysToMatch); + _ = filteredByFilter.Count.Should().Be(ExpectedPairCount, "filter applied to list should find 2 Engineering employees"); // Test DynamicReactiveView with a simple direct filter first - var simpleFilterSubject = new BehaviorSignal>(e => e.Department == EngineeringDepartment); + var simpleFilterSubject = new BehaviorSignal>(static e => e.Department == EngineeringDepartment); using var simpleView = new CP.Primitives.Views.DynamicReactiveView(list, simpleFilterSubject, TimeSpan.Zero, Sequencer.Immediate); - simpleView.Items.Count.Should().Be(ExpectedPairCount, "DynamicReactiveView with simple filter should work"); + _ = simpleView.Items.Count.Should().Be(ExpectedPairCount, "DynamicReactiveView with simple filter should work"); // Test DynamicReactiveView with ItemMatchesSecondaryIndex filter directly var indexFilterSubject = new BehaviorSignal>( - item => new HashSet([EngineeringDepartment]).Any(key => list.ItemMatchesSecondaryIndex(DepartmentIndexName, item, key))); + item => list.ItemMatchesSecondaryIndex(DepartmentIndexName, item, EngineeringDepartment)); using var indexView = new CP.Primitives.Views.DynamicReactiveView(list, indexFilterSubject, TimeSpan.Zero, Sequencer.Immediate); - indexView.Items.Count.Should().Be(ExpectedPairCount, "DynamicReactiveView with ItemMatchesSecondaryIndex filter should work"); + _ = indexView.Items.Count.Should().Be(ExpectedPairCount, "DynamicReactiveView with ItemMatchesSecondaryIndex filter should work"); var departmentFilter = new BehaviorSignal([EngineeringDepartment]); @@ -121,21 +145,21 @@ public async Task CreateViewBySecondaryIndex_WithObservableKeys_RebuildsWhenKeys await Task.Delay(InitialViewDelayMilliseconds); // Initial state - only Engineering - view.Items.Count.Should().Be(ExpectedPairCount); - view.Items.All(e => e.Department == EngineeringDepartment).Should().BeTrue(); + _ = view.Items.Count.Should().Be(ExpectedPairCount); + _ = AllEmployeesBelongTo(view.Items, EngineeringDepartment).Should().BeTrue(); // Change to Sales departmentFilter.OnNext([SalesDepartment]); await Task.Delay(FilterUpdateDelayMilliseconds); - view.Items.Count.Should().Be(ExpectedPairCount); - view.Items.All(e => e.Department == SalesDepartment).Should().BeTrue(); + _ = view.Items.Count.Should().Be(ExpectedPairCount); + _ = AllEmployeesBelongTo(view.Items, SalesDepartment).Should().BeTrue(); // Change to multiple departments departmentFilter.OnNext([EngineeringDepartment, MarketingDepartment]); await Task.Delay(FilterUpdateDelayMilliseconds); - view.Items.Count.Should().Be(ExpectedTripleCount); + _ = view.Items.Count.Should().Be(ExpectedTripleCount); } /// Tests that CreateViewBySecondaryIndex with observable keys handles empty key array. @@ -145,7 +169,7 @@ public async Task CreateViewBySecondaryIndex_WithObservableKeys_HandlesEmptyKeyA { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); list.AddRange( [ new Employee(AliceName, EngineeringDepartment), @@ -158,14 +182,14 @@ public async Task CreateViewBySecondaryIndex_WithObservableKeys_HandlesEmptyKeyA using var view = list.CreateDynamicViewBySecondaryIndex(DepartmentIndexName, departmentFilter, Sequencer.Immediate, 0); await Task.Delay(InitialViewDelayMilliseconds); - view.Items.Count.Should().Be(1); + _ = view.Items.Count.Should().Be(1); // Change to empty array departmentFilter.OnNext([]); await Task.Delay(FilterUpdateDelayMilliseconds); // Assert - no items match empty filter - view.Items.Count.Should().Be(0); + _ = view.Items.Count.Should().Be(0); } /// Tests that CreateViewBySecondaryIndex throws for null list. @@ -177,7 +201,7 @@ public void CreateViewBySecondaryIndex_ThrowsForNullList() // Act & Assert var act = () => nullList!.CreateViewBySecondaryIndex(DepartmentIndexName, EngineeringDepartment, Sequencer.Immediate); - act.Should().Throw(); + _ = act.Should().Throw(); } /// Tests that CreateViewBySecondaryIndex throws for null index name. @@ -186,11 +210,11 @@ public void CreateViewBySecondaryIndex_ThrowsForNullIndexName() { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); // Act & Assert var act = () => list.CreateViewBySecondaryIndex(null!, EngineeringDepartment, Sequencer.Immediate); - act.Should().Throw(); + _ = act.Should().Throw(); } /// Tests that views handle rapid key changes gracefully. @@ -200,7 +224,7 @@ public async Task CreateViewBySecondaryIndex_HandlesRapidKeyChanges() { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); list.AddRange( [ new Employee(AliceName, EngineeringDepartment), @@ -221,7 +245,7 @@ public async Task CreateViewBySecondaryIndex_HandlesRapidKeyChanges() await Task.Delay(CoalescingDelayMilliseconds); // Assert - final state should be Sales and Marketing - view.Items.Count.Should().Be(ExpectedPairCount); + _ = view.Items.Count.Should().Be(ExpectedPairCount); } /// Tests a real-world scenario of filtering employees by multiple criteria. @@ -231,7 +255,7 @@ public async Task RealWorldScenario_EmployeeFilteringByDepartment() { // Arrange - Company employee directory using var employees = new QuaternaryList(); - employees.AddIndex(DepartmentIndexName, e => e.Department); + employees.AddIndex(DepartmentIndexName, static e => e.Department); // Add initial employees employees.AddRange( @@ -259,21 +283,21 @@ public async Task RealWorldScenario_EmployeeFilteringByDepartment() await Task.Delay(FilterUpdateDelayMilliseconds); // Assert initial state - filteredView.Items.Count.Should().Be(ExpectedTripleCount); - filteredView.Items.All(e => e.Department == EngineeringDepartment).Should().BeTrue(); + _ = filteredView.Items.Count.Should().Be(ExpectedTripleCount); + _ = AllEmployeesBelongTo(filteredView.Items, EngineeringDepartment).Should().BeTrue(); // User selects SalesDepartment department selectedDepartments.OnNext([SalesDepartment]); await Task.Delay(SelectionUpdateDelayMilliseconds); - filteredView.Items.Count.Should().Be(ExpectedPairCount); - filteredView.Items.All(e => e.Department == SalesDepartment).Should().BeTrue(); + _ = filteredView.Items.Count.Should().Be(ExpectedPairCount); + _ = AllEmployeesBelongTo(filteredView.Items, SalesDepartment).Should().BeTrue(); // User selects multiple departments selectedDepartments.OnNext([EngineeringDepartment, MarketingDepartment]); await Task.Delay(SelectionUpdateDelayMilliseconds); - filteredView.Items.Count.Should().Be(ExpectedFiveItems); + _ = filteredView.Items.Count.Should().Be(ExpectedFiveItems); } /// Tests dictionary CreateViewBySecondaryIndex with single key. @@ -283,20 +307,20 @@ public async Task Dictionary_CreateViewBySecondaryIndex_FiltersByValueIndexKey() { // Arrange using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(StatusIndexName, o => o.Status); + dict.AddValueIndex(StatusIndexName, static o => o.Status); - dict.Add(FirstOrderId, new OrderInfo(FirstOrderId, PendingStatus, FirstOrderAmount)); - dict.Add(SecondOrderId, new OrderInfo(SecondOrderId, ShippedStatus, SecondOrderAmount)); - dict.Add(ThirdOrderId, new OrderInfo(ThirdOrderId, PendingStatus, ThirdOrderAmount)); - dict.Add("ORD004", new OrderInfo("ORD004", DeliveredStatus, FourthOrderAmount)); + dict.Add(FirstOrderId, new(FirstOrderId, PendingStatus, FirstOrderAmount)); + dict.Add(SecondOrderId, new(SecondOrderId, ShippedStatus, SecondOrderAmount)); + dict.Add(ThirdOrderId, new(ThirdOrderId, PendingStatus, ThirdOrderAmount)); + dict.Add("ORD004", new("ORD004", DeliveredStatus, FourthOrderAmount)); // Act - instance method returns SecondaryIndexReactiveView where Items are TValue directly using var view = dict.CreateViewBySecondaryIndex(StatusIndexName, PendingStatus, Sequencer.Immediate, 0); await Task.Delay(InitialViewDelayMilliseconds); // Assert - view.Items.Count.Should().Be(ExpectedPairCount); - view.Items.All(order => order.Status == PendingStatus).Should().BeTrue(); + _ = view.Items.Count.Should().Be(ExpectedPairCount); + _ = AllOrdersHaveStatus(view.Items, PendingStatus).Should().BeTrue(); } /// Tests dictionary CreateViewBySecondaryIndex with multiple keys via extension method. @@ -306,19 +330,19 @@ public async Task Dictionary_CreateViewBySecondaryIndex_HandlesMultipleIndexKeys { // Arrange using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(StatusIndexName, o => o.Status); + dict.AddValueIndex(StatusIndexName, static o => o.Status); - dict.Add(FirstOrderId, new OrderInfo(FirstOrderId, PendingStatus, FirstOrderAmount)); - dict.Add(SecondOrderId, new OrderInfo(SecondOrderId, ShippedStatus, SecondOrderAmount)); - dict.Add(ThirdOrderId, new OrderInfo(ThirdOrderId, DeliveredStatus, FourthOrderAmount)); + dict.Add(FirstOrderId, new(FirstOrderId, PendingStatus, FirstOrderAmount)); + dict.Add(SecondOrderId, new(SecondOrderId, ShippedStatus, SecondOrderAmount)); + dict.Add(ThirdOrderId, new(ThirdOrderId, DeliveredStatus, FourthOrderAmount)); // Act - extension method with array returns ReactiveView using var view = QuaternaryExtensions.CreateViewBySecondaryIndex(dict, StatusIndexName, [PendingStatus, ShippedStatus], Sequencer.Immediate, 0); await Task.Delay(InitialViewDelayMilliseconds); // Assert - view.Items.Count.Should().Be(ExpectedPairCount); - view.Items.Select(kvp => kvp.Value.Status).Should().BeEquivalentTo([PendingStatus, ShippedStatus]); + _ = view.Items.Count.Should().Be(ExpectedPairCount); + _ = GetOrderStatuses(view.Items).Should().BeEquivalentTo([PendingStatus, ShippedStatus]); } /// Tests dictionary CreateViewBySecondaryIndex with observable keys. @@ -328,11 +352,11 @@ public async Task Dictionary_CreateViewBySecondaryIndex_WithObservableKeys_Rebui { // Arrange using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(StatusIndexName, o => o.Status); + dict.AddValueIndex(StatusIndexName, static o => o.Status); - dict.Add(FirstOrderId, new OrderInfo(FirstOrderId, PendingStatus, FirstOrderAmount)); - dict.Add(SecondOrderId, new OrderInfo(SecondOrderId, ShippedStatus, SecondOrderAmount)); - dict.Add(ThirdOrderId, new OrderInfo(ThirdOrderId, DeliveredStatus, FourthOrderAmount)); + dict.Add(FirstOrderId, new(FirstOrderId, PendingStatus, FirstOrderAmount)); + dict.Add(SecondOrderId, new(SecondOrderId, ShippedStatus, SecondOrderAmount)); + dict.Add(ThirdOrderId, new(ThirdOrderId, DeliveredStatus, FourthOrderAmount)); var statusFilter = new BehaviorSignal([PendingStatus]); @@ -340,14 +364,14 @@ public async Task Dictionary_CreateViewBySecondaryIndex_WithObservableKeys_Rebui using var view = QuaternaryExtensions.CreateDynamicViewBySecondaryIndex(dict, StatusIndexName, statusFilter, Sequencer.Immediate, 0); await Task.Delay(InitialViewDelayMilliseconds); - view.Items.Count.Should().Be(1); + _ = view.Items.Count.Should().Be(1); // Change filter statusFilter.OnNext([ShippedStatus, DeliveredStatus]); await Task.Delay(FilterUpdateDelayMilliseconds); // Assert - view.Items.Count.Should().Be(ExpectedPairCount); + _ = view.Items.Count.Should().Be(ExpectedPairCount); } /// Tests that DynamicSecondaryIndexReactiveView initializes correctly with direct construction. @@ -356,7 +380,7 @@ public void DynamicSecondaryIndexReactiveView_DirectConstruction_InitializesCorr { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); list.AddRange( [ new Employee(AliceName, EngineeringDepartment), @@ -365,8 +389,8 @@ public void DynamicSecondaryIndexReactiveView_DirectConstruction_InitializesCorr ]); // Verify direct lookup works - var directLookup = list.GetItemsBySecondaryIndex(DepartmentIndexName, EngineeringDepartment).ToList(); - directLookup.Count.Should().Be(ExpectedPairCount, "direct index lookup should find 2 Engineering employees"); + var directLookup = new List(list.GetItemsBySecondaryIndex(DepartmentIndexName, EngineeringDepartment)); + _ = directLookup.Count.Should().Be(ExpectedPairCount, "direct index lookup should find 2 Engineering employees"); // Create the view directly (not through extension method) var keysObservable = new BehaviorSignal([EngineeringDepartment]); @@ -379,7 +403,7 @@ public void DynamicSecondaryIndexReactiveView_DirectConstruction_InitializesCorr TimeSpan.Zero); // Assert - should have items immediately after construction - view.Items.Count.Should().Be(ExpectedPairCount, "view should have 2 items immediately after construction"); + _ = view.Items.Count.Should().Be(ExpectedPairCount, "view should have 2 items immediately after construction"); } /// Tests that CreateDynamicViewBySecondaryIndex extension method works same as direct construction. @@ -388,7 +412,7 @@ public void CreateDynamicViewBySecondaryIndex_ExtensionMethod_WorksCorrectly() { // Arrange using var list = new QuaternaryList(); - list.AddIndex(DepartmentIndexName, e => e.Department); + list.AddIndex(DepartmentIndexName, static e => e.Department); list.AddRange( [ new Employee(AliceName, EngineeringDepartment), @@ -401,7 +425,7 @@ public void CreateDynamicViewBySecondaryIndex_ExtensionMethod_WorksCorrectly() using var extView = list.CreateDynamicViewBySecondaryIndex(DepartmentIndexName, keysObservable, Sequencer.Immediate, 0); // Assert - should have items immediately after construction - extView.Items.Count.Should().Be(ExpectedPairCount, "extension method should produce view with 2 items"); + _ = extView.Items.Count.Should().Be(ExpectedPairCount, "extension method should produce view with 2 items"); } /// Tests that secondary-index stream filters keep clear notifications for view reset semantics. @@ -410,7 +434,7 @@ public void FilterBySecondaryIndex_ClearNotifications_ShouldPassThroughAllOverlo { using var list = new QuaternaryList(); list.AddIndex(DepartmentIndexName, static employee => employee.Department); - list.Add(new Employee(AliceName, EngineeringDepartment)); + list.Add(new(AliceName, EngineeringDepartment)); using var listStream = new Signal>(); var listSingle = new List>(); var listMultiple = new List>(); @@ -421,14 +445,14 @@ public void FilterBySecondaryIndex_ClearNotifications_ShouldPassThroughAllOverlo .FilterBySecondaryIndex(list, DepartmentIndexName, EngineeringDepartment, SalesDepartment) .Subscribe(listMultiple.Add); - listStream.OnNext(new CacheNotify(CacheAction.Cleared, default!)); + listStream.OnNext(new(CacheAction.Cleared, default!)); - listSingle.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); - listMultiple.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); + _ = listSingle.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); + _ = listMultiple.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); using var dict = new QuaternaryDictionary(); dict.AddValueIndex(StatusIndexName, static order => order.Status); - dict.Add(FirstOrderId, new OrderInfo(FirstOrderId, PendingStatus, FirstOrderAmount)); + dict.Add(FirstOrderId, new(FirstOrderId, PendingStatus, FirstOrderAmount)); using var dictStream = new Signal>>(); var dictSingle = new List>>(); var dictMultiple = new List>>(); @@ -439,10 +463,99 @@ public void FilterBySecondaryIndex_ClearNotifications_ShouldPassThroughAllOverlo .FilterBySecondaryIndex(dict, StatusIndexName, PendingStatus, ShippedStatus) .Subscribe(dictMultiple.Add); - dictStream.OnNext(new CacheNotify>(CacheAction.Cleared, default)); + dictStream.OnNext(new(CacheAction.Cleared, default)); - dictSingle.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); - dictMultiple.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); + _ = dictSingle.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); + _ = dictMultiple.Should().ContainSingle().Which.Action.Should().Be(CacheAction.Cleared); + } + + /// Finds an employee by name without allocating a LINQ iterator. + /// The employees to search. + /// The employee name to find. + /// The matching employee. + private static Employee FindEmployeeByName(IEnumerable employees, string name) + { + foreach (var employee in employees) + { + if (employee.Name == name) + { + return employee; + } + } + + throw new InvalidOperationException($"Employee '{name}' was not found."); + } + + /// Filters employees by one or more secondary-index keys using explicit iteration. + /// The indexed employee list. + /// The secondary-index keys to match. + /// The matching employees. + private static List FilterBySecondaryIndex(QuaternaryList list, IEnumerable keys) + { + var matches = new List(); + foreach (var employee in list) + { + foreach (var key in keys) + { + if (!list.ItemMatchesSecondaryIndex(DepartmentIndexName, employee, key)) + { + continue; + } + + matches.Add(employee); + break; + } + } + + return matches; + } + + /// Determines whether all employees belong to the requested department. + /// The employees to inspect. + /// The expected department. + /// when every employee belongs to the department; otherwise, . + private static bool AllEmployeesBelongTo(IEnumerable employees, string department) + { + foreach (var employee in employees) + { + if (employee.Department != department) + { + return false; + } + } + + return true; + } + + /// Determines whether all orders have the requested status. + /// The orders to inspect. + /// The expected status. + /// when every order has the status; otherwise, . + private static bool AllOrdersHaveStatus(IEnumerable orders, string status) + { + foreach (var order in orders) + { + if (order.Status != status) + { + return false; + } + } + + return true; + } + + /// Collects order statuses from dictionary entries using explicit iteration. + /// The dictionary entries to inspect. + /// The order statuses. + private static List GetOrderStatuses(IEnumerable> orders) + { + var statuses = new List(); + foreach (var order in orders) + { + statuses.Add(order.Value.Status); + } + + return statuses; } /// Provides Employee. diff --git a/src/ReactiveList.Test/QuaternaryExtensionsTests.cs b/src/ReactiveList.Test/QuaternaryExtensionsTests.cs index b302c7a..013cc98 100644 --- a/src/ReactiveList.Test/QuaternaryExtensionsTests.cs +++ b/src/ReactiveList.Test/QuaternaryExtensionsTests.cs @@ -13,20 +13,28 @@ namespace ReactiveList.Test; /// Contains unit tests for the QuaternaryExtensions class. public class QuaternaryExtensionsTests { + /// The second integer value used by collection tests. private const int SecondCollectionValue = 2; + /// The third integer value used by collection tests. private const int ThirdCollectionValue = 3; + /// The fourth integer value used by collection tests. private const int FourthCollectionValue = 4; + /// The fifth integer value used by collection tests. private const int FifthCollectionValue = 5; + /// The integer value added during reactive-view tests. private const int AddedCollectionValue = 42; + /// The delay used to allow throttled view updates to complete. private const int ViewUpdateDelayMilliseconds = 200; + /// The first person name used by the test data. private const string AliceName = "Alice"; + /// The name of the secondary index that groups people by city. private const string CityIndexName = "ByCity"; /// Verifies that CreateView returns a view with all items when no filter is applied. @@ -48,7 +56,7 @@ public void CreateView_WithFilter_ShouldContainOnlyMatchingItems() using var list = new QuaternaryList(); list.AddRange([1, SecondCollectionValue, ThirdCollectionValue, FourthCollectionValue, FifthCollectionValue]); - using var view = list.CreateView(x => x % SecondCollectionValue == 0, Sequencer.Default, throttleMs: 10); + using var view = list.CreateView(static x => x % SecondCollectionValue == 0, Sequencer.Default, throttleMs: 10); Assert.Equal(SecondCollectionValue, view.Items.Count); Assert.Contains(SecondCollectionValue, view.Items); @@ -60,7 +68,7 @@ public void CreateView_WithFilter_ShouldContainOnlyMatchingItems() public void CreateViewBySecondaryIndex_ShouldFilterByKey() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); list.AddRange([ new TestPerson(AliceName, "NYC"), new TestPerson("Bob", "LA"), @@ -70,7 +78,7 @@ public void CreateViewBySecondaryIndex_ShouldFilterByKey() using var view = list.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 10); Assert.Equal(SecondCollectionValue, view.Items.Count); - Assert.All(view.Items, p => Assert.Equal("NYC", p.City)); + Assert.All(view.Items, static p => Assert.Equal("NYC", p.City)); } /// Verifies that CreateViewBySecondaryIndex with multiple keys includes items matching any key. @@ -78,7 +86,7 @@ public void CreateViewBySecondaryIndex_ShouldFilterByKey() public void CreateViewBySecondaryIndex_WithMultipleKeys_ShouldIncludeAllMatches() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); list.AddRange([ new TestPerson(AliceName, "NYC"), new TestPerson("Bob", "LA"), @@ -102,7 +110,7 @@ public void ToProperty_ShouldSetProperty() using var view = list.CreateView(Sequencer.Default, throttleMs: 10) .ToProperty(x => result = x); - Assert.NotNull(result); + _ = Assert.NotNull(result); Assert.Equal(ThirdCollectionValue, result!.Count); } @@ -119,7 +127,7 @@ public async Task ReactiveView_ShouldUpdateOnAdd() // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); - Assert.Single(view.Items); + _ = Assert.Single(view.Items); Assert.Contains(AddedCollectionValue, view.Items); } @@ -136,7 +144,7 @@ public async Task ReactiveView_ShouldUpdateOnRemove() // Initial state Assert.Equal(ThirdCollectionValue, view.Items.Count); - list.Remove(SecondCollectionValue); + _ = list.Remove(SecondCollectionValue); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); @@ -174,14 +182,14 @@ public async Task ReactiveView_ShouldUpdateOnRemoveRange() public async Task CreateViewBySecondaryIndex_ShouldUpdateOnAdd() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); - list.Add(new TestPerson(AliceName, "NYC")); + list.AddIndex(CityIndexName, static p => p.City); + list.Add(new(AliceName, "NYC")); using var view = list.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 50); - Assert.Single(view.Items); + _ = Assert.Single(view.Items); - list.Add(new TestPerson("Bob", "NYC")); + list.Add(new("Bob", "NYC")); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); @@ -195,20 +203,20 @@ public async Task CreateViewBySecondaryIndex_ShouldUpdateOnAdd() public async Task CreateViewBySecondaryIndex_ShouldNotIncludeNonMatchingItems() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); - list.Add(new TestPerson(AliceName, "NYC")); + list.AddIndex(CityIndexName, static p => p.City); + list.Add(new(AliceName, "NYC")); using var view = list.CreateViewBySecondaryIndex(CityIndexName, "NYC", Sequencer.Default, throttleMs: 50); - Assert.Single(view.Items); + _ = Assert.Single(view.Items); - list.Add(new TestPerson("Bob", "LA")); + list.Add(new("Bob", "LA")); // Wait for throttle + processing await Task.Delay(ViewUpdateDelayMilliseconds); // Should still be only 1 item (Alice from NYC) - Assert.Single(view.Items); + _ = Assert.Single(view.Items); } /// Provides TestPerson. diff --git a/src/ReactiveList.Test/QuaternaryListTests.cs b/src/ReactiveList.Test/QuaternaryListTests.cs index 217fe4f..01b3376 100644 --- a/src/ReactiveList.Test/QuaternaryListTests.cs +++ b/src/ReactiveList.Test/QuaternaryListTests.cs @@ -5,7 +5,6 @@ #if NET8_0_OR_GREATER || NETFRAMEWORK using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using CP.Primitives.Collections; using CP.Primitives.Core; @@ -19,38 +18,55 @@ namespace ReactiveList.Test; /// under various conditions and that its public API contracts are enforced. public class QuaternaryListTests { + /// The second collection value used by test data. private const int SecondCollectionValue = 2; + /// The third collection value used by test data. private const int ThirdCollectionValue = 3; + /// The fourth collection value used by test data. private const int FourthCollectionValue = 4; + /// The fifth collection value used by test data. private const int FifthCollectionValue = 5; + /// The sixth collection value used by test data. private const int SixthCollectionValue = 6; + /// The seventh collection value used by test data. private const int SeventhCollectionValue = 7; + /// The eighth collection value used by test data. private const int EighthCollectionValue = 8; + /// The ninth collection value used by test data. private const int NinthCollectionValue = 9; + /// The first replacement value used by test data. private const int FirstReplacementValue = 10; + /// The second replacement value used by test data. private const int SecondReplacementValue = 20; + /// The third replacement value used by test data. private const int ThirdReplacementValue = 30; + /// The collection value tracked by notification tests. private const int TrackedCollectionValue = 42; + /// A value deliberately absent from test collections. private const int MissingCollectionValue = 99; + /// The name of the city secondary index. private const string CityIndexName = "ByCity"; + /// The New York test value. private const string NewYorkCity = "New York"; + /// The Los Angeles test value. private const string LosAngelesCity = "Los Angeles"; + /// The Chicago test value. private const string ChicagoCity = "Chicago"; /// Verifies that adding an item to a QuaternaryList increases the count and that the item is present in the list. @@ -59,7 +75,7 @@ public void Add_ShouldIncreaseCountAndContainItem() { using var list = new QuaternaryList { TrackedCollectionValue }; - Assert.Single(list); + _ = Assert.Single(list); Assert.Contains(TrackedCollectionValue, list); } @@ -82,9 +98,9 @@ public void AddRange_ShouldEmitBatchAndCopyItems() list.AddRange([0, 1, SecondCollectionValue, ThirdCollectionValue, FourthCollectionValue]); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.NotNull(notification); + _ = Assert.NotNull(notification); Assert.Equal(CacheAction.BatchAdded, notification!.Action); - Assert.NotNull(notification.Batch); + _ = Assert.NotNull(notification.Batch); Assert.Equal(FifthCollectionValue, notification.Batch!.Count); notification.Batch.Dispose(); @@ -115,7 +131,7 @@ public void IndexerSetter_ShouldThrowNotSupportedException() { using var list = new QuaternaryList { 1, SecondCollectionValue, ThirdCollectionValue }; - Assert.Throws(() => list[0] = FifthCollectionValue); + _ = Assert.Throws(() => list[0] = FifthCollectionValue); } /// @@ -126,7 +142,7 @@ public void IndexerSetter_ShouldThrowNotSupportedException() public void AddIndexAndQuery_ShouldTrackAndUpdate() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); var newYorkPerson = new TestPerson("A", NewYorkCity); var losAngelesPerson = new TestPerson("B", LosAngelesCity); @@ -134,19 +150,19 @@ public void AddIndexAndQuery_ShouldTrackAndUpdate() list.AddRange([newYorkPerson, losAngelesPerson, secondNewYorkPerson]); - var newYorkResults = list.GetItemsBySecondaryIndex(CityIndexName, NewYorkCity).ToList(); + var newYorkResults = new List(list.GetItemsBySecondaryIndex(CityIndexName, NewYorkCity)); Assert.Equal(SecondCollectionValue, newYorkResults.Count); Assert.Contains(newYorkPerson, newYorkResults); Assert.Contains(secondNewYorkPerson, newYorkResults); - var losAngelesResults = list.GetItemsBySecondaryIndex(CityIndexName, LosAngelesCity).ToList(); - Assert.Single(losAngelesResults); + var losAngelesResults = new List(list.GetItemsBySecondaryIndex(CityIndexName, LosAngelesCity)); + _ = Assert.Single(losAngelesResults); Assert.Equal(losAngelesPerson, losAngelesResults[0]); - list.Remove(newYorkPerson); + _ = list.Remove(newYorkPerson); - var newYorkResultsAfterRemove = list.GetItemsBySecondaryIndex(CityIndexName, NewYorkCity).ToList(); - Assert.Single(newYorkResultsAfterRemove); + var newYorkResultsAfterRemove = new List(list.GetItemsBySecondaryIndex(CityIndexName, NewYorkCity)); + _ = Assert.Single(newYorkResultsAfterRemove); Assert.Equal(secondNewYorkPerson, newYorkResultsAfterRemove[0]); } @@ -155,7 +171,7 @@ public void AddIndexAndQuery_ShouldTrackAndUpdate() public void Clear_ShouldResetItemsAndIndices() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); list.AddRange( [ new TestPerson("A", NewYorkCity), @@ -191,9 +207,9 @@ public void RemoveRange_ShouldRemoveItemsAndEmitBatchRemoved() list.RemoveRange([SecondCollectionValue, FourthCollectionValue]); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.NotNull(notification); + _ = Assert.NotNull(notification); Assert.Equal(CacheAction.BatchRemoved, notification!.Action); - Assert.NotNull(notification.Batch); + _ = Assert.NotNull(notification.Batch); Assert.Equal(SecondCollectionValue, notification.Batch!.Count); notification.Batch.Dispose(); @@ -209,9 +225,20 @@ public void RemoveRange_ShouldRemoveItemsAndEmitBatchRemoved() public void RemoveMany_WithPredicate_ShouldRemoveMatchingItems() { using var list = new QuaternaryList(); - list.AddRange([1, SecondCollectionValue, ThirdCollectionValue, FourthCollectionValue, FifthCollectionValue, SixthCollectionValue, SeventhCollectionValue, EighthCollectionValue, NinthCollectionValue, FirstReplacementValue]); + list.AddRange( + [ + 1, + SecondCollectionValue, + ThirdCollectionValue, + FourthCollectionValue, + FifthCollectionValue, + SixthCollectionValue, + SeventhCollectionValue, + EighthCollectionValue, + NinthCollectionValue, + FirstReplacementValue + ]); - CacheNotify? notification = null; using var reset = new ManualResetEventSlim(false); using var subscription = list.Stream.Subscribe(evt => { @@ -220,11 +247,10 @@ public void RemoveMany_WithPredicate_ShouldRemoveMatchingItems() return; } - notification = evt; reset.Set(); }); - var removedCount = list.RemoveMany(x => x % SecondCollectionValue == 0); + var removedCount = list.RemoveMany(static x => x % SecondCollectionValue == 0); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); Assert.Equal(FifthCollectionValue, removedCount); @@ -241,7 +267,7 @@ public void Snapshot_ReentrantFailure_ShouldReleaseAcquiredShardLocks() { using var list = new QuaternaryList { 1 }; - Assert.Throws(() => list.RemoveMany(item => + _ = Assert.Throws(() => list.RemoveMany(item => { if (item != 1) { @@ -277,7 +303,7 @@ public void Edit_ShouldPerformBatchModificationsWithSingleNotification() reset.Set(); }); - list.Edit(innerList => + list.Edit(static innerList => { innerList.Clear(); innerList.Add(FirstReplacementValue); @@ -286,7 +312,7 @@ public void Edit_ShouldPerformBatchModificationsWithSingleNotification() }); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.Single(notifications); + _ = Assert.Single(notifications); Assert.Equal(CacheAction.BatchOperation, notifications[0]); Assert.Equal(ThirdCollectionValue, list.Count); Assert.Contains(FirstReplacementValue, list); @@ -300,26 +326,26 @@ public void Edit_ShouldPerformBatchModificationsWithSingleNotification() public void Edit_ShouldUpdateIndicesCorrectly() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); list.AddRange([ new TestPerson("Alice", "NYC"), new TestPerson("Bob", "LA") ]); - list.Edit(innerList => + list.Edit(static innerList => { innerList.Clear(); - innerList.Add(new TestPerson("Charlie", "NYC")); - innerList.Add(new TestPerson("Diana", ChicagoCity)); + innerList.Add(new("Charlie", "NYC")); + innerList.Add(new("Diana", ChicagoCity)); }); - var nycResults = list.GetItemsBySecondaryIndex(CityIndexName, "NYC").ToList(); - Assert.Single(nycResults); + var nycResults = new List(list.GetItemsBySecondaryIndex(CityIndexName, "NYC")); + _ = Assert.Single(nycResults); Assert.Equal("Charlie", nycResults[0].Name); Assert.Empty(list.GetItemsBySecondaryIndex(CityIndexName, "LA")); - var chicagoResults = list.GetItemsBySecondaryIndex(CityIndexName, ChicagoCity).ToList(); - Assert.Single(chicagoResults); + var chicagoResults = new List(list.GetItemsBySecondaryIndex(CityIndexName, ChicagoCity)); + _ = Assert.Single(chicagoResults); Assert.Equal("Diana", chicagoResults[0].Name); } @@ -369,7 +395,7 @@ public void GetEnumerator_ShouldIterateAllItems() using var list = new QuaternaryList(); list.AddRange([1, SecondCollectionValue, ThirdCollectionValue, FourthCollectionValue, FifthCollectionValue]); - var items = list.ToList(); + var items = new List(list); Assert.Equal(FifthCollectionValue, items.Count); Assert.Contains(1, items); @@ -392,7 +418,7 @@ public void IsReadOnly_ShouldReturnFalse() public void ItemMatchesSecondaryIndex_ShouldReturnCorrectResult() { using var list = new QuaternaryList(); - list.AddIndex(CityIndexName, p => p.City); + list.AddIndex(CityIndexName, static p => p.City); var newYorkPerson = new TestPerson("A", NewYorkCity); var losAngelesPerson = new TestPerson("B", LosAngelesCity); @@ -429,7 +455,7 @@ public void Stream_ShouldEmitAddedNotification() list.Add(TrackedCollectionValue); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.NotNull(notification); + _ = Assert.NotNull(notification); Assert.Equal(CacheAction.Added, notification!.Action); Assert.Equal(TrackedCollectionValue, notification.Item); } @@ -453,10 +479,10 @@ public void Stream_ShouldEmitRemovedNotification() reset.Set(); }); - list.Remove(TrackedCollectionValue); + _ = list.Remove(TrackedCollectionValue); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.NotNull(notification); + _ = Assert.NotNull(notification); Assert.Equal(CacheAction.Removed, notification!.Action); Assert.Equal(TrackedCollectionValue, notification.Item); } @@ -484,7 +510,7 @@ public void Stream_ShouldEmitClearedNotification() list.Clear(); Assert.True(reset.Wait(TimeSpan.FromSeconds(1))); - Assert.NotNull(notification); + _ = Assert.NotNull(notification); Assert.Equal(CacheAction.Cleared, notification!.Action); } @@ -542,16 +568,17 @@ public void ReplaceAll_WithEmptyCollection_ShouldClearList() public void ReplaceAll_ShouldUpdateSecondaryIndices() { using var list = new QuaternaryList(); - list.AddIndex("Mod2", x => x % SecondCollectionValue); + list.AddIndex("Mod2", static x => x % SecondCollectionValue); list.AddRange([1, SecondCollectionValue, ThirdCollectionValue, FourthCollectionValue, FifthCollectionValue]); // Verify initial state - Assert.Equal(SecondCollectionValue, list.GetItemsBySecondaryIndex("Mod2", 0).Count()); // 2, 4 + var initialEvenItems = new List(list.GetItemsBySecondaryIndex("Mod2", 0)); + Assert.Equal(SecondCollectionValue, initialEvenItems.Count); // 2, 4 list.ReplaceAll([FirstReplacementValue, SecondReplacementValue, ThirdReplacementValue]); // After replace, new even numbers - var evenItems = list.GetItemsBySecondaryIndex("Mod2", 0).ToList(); + var evenItems = new List(list.GetItemsBySecondaryIndex("Mod2", 0)); Assert.Equal(ThirdCollectionValue, evenItems.Count); // 10, 20, 30 are all even } @@ -562,7 +589,7 @@ public void ReplaceAll_WithNull_ShouldThrowArgumentNullException() using var list = new QuaternaryList(); list.AddRange([1, SecondCollectionValue, ThirdCollectionValue]); - Assert.Throws(() => list.ReplaceAll(null!)); + _ = Assert.Throws(() => list.ReplaceAll(null!)); } /// Provides TestPerson. diff --git a/src/ReactiveList.Test/Reactive2DListTests.cs b/src/ReactiveList.Test/Reactive2DListTests.cs index 4a2119f..9b41430 100644 --- a/src/ReactiveList.Test/Reactive2DListTests.cs +++ b/src/ReactiveList.Test/Reactive2DListTests.cs @@ -52,8 +52,8 @@ public void Constructor_ShouldInitializeWithReactiveLists() public void Constructor_ShouldInitializeWithSingleItem() { var list = new Reactive2DList(TestData.TestValueFive); - Assert.Single(list); - Assert.Single(list[0]); + _ = Assert.Single(list); + _ = Assert.Single(list[0]); Assert.Equal(TestData.TestValueFive, list[0][0]); list.Dispose(); } @@ -66,8 +66,8 @@ public void Constructor_ShouldInitializeWithItemEnumerable() var list = new Reactive2DList(items); Assert.Equal(TestData.TestValueTwo, list.Count); - Assert.Single(list[0]); - Assert.Single(list[1]); + _ = Assert.Single(list[0]); + _ = Assert.Single(list[1]); Assert.Equal(TestData.TestValueSeven, list[0][0]); Assert.Equal(TestData.TestValueEight, list[1][0]); list.Dispose(); @@ -79,7 +79,7 @@ public void Constructor_ShouldInitializeWithReactiveList() { var items = new ReactiveList { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour }; var list = new Reactive2DList(items); - Assert.Single(list); + _ = Assert.Single(list); Assert.Equal(TestData.TestValueFour, list[0].Count); list.Dispose(); } @@ -122,13 +122,13 @@ public void AddRange_ShouldInsertItemsAtIndex() list.Dispose(); } - /// Inserts the index of the should insert items at. + /// InsertRange should insert items as a new row at the requested index. [Test] - public void Insert_ShouldInsertItemsAtIndex() + public void InsertRange_ShouldInsertItemsAtIndex() { var list = new Reactive2DList([TestData.TestValueFive]); var items = new List { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour }; - list.Insert(0, items); + list.InsertRange(0, items); Assert.Equal(TestData.TestValueTwo, list.Count); Assert.Equal(1, list[0][0]); Assert.Equal(TestData.TestValueFour, list[0][TestData.TestValueThree]); @@ -177,11 +177,16 @@ public void Insert_ShouldInsertItemsInReactiveListAtIndex() [Test] public void GetItem_ShouldReturnItemAtSpecifiedIndices() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, + new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); var item = list.GetItem(1, TestData.TestValueTwo); - item.Should().Be(TestData.TestValueSix); + _ = item.Should().Be(TestData.TestValueSix); list.Dispose(); } @@ -193,7 +198,7 @@ public void GetItem_ShouldThrowWhenOuterIndexIsNegative() var action = () => list.GetItem(-1, 0); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -206,7 +211,7 @@ public void GetItem_ShouldThrowWhenOuterIndexExceedsCount() var action = () => list.GetItem(TestData.TestValueFive, 0); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -219,7 +224,7 @@ public void GetItem_ShouldThrowWhenInnerIndexIsNegative() var action = () => list.GetItem(0, -1); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.InnerIndexParameterName); list.Dispose(); } @@ -232,7 +237,7 @@ public void GetItem_ShouldThrowWhenInnerIndexExceedsCount() var action = () => list.GetItem(0, TestData.TestValueFive); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.InnerIndexParameterName); list.Dispose(); } @@ -241,11 +246,16 @@ public void GetItem_ShouldThrowWhenInnerIndexExceedsCount() [Test] public void SetItem_ShouldUpdateItemAtSpecifiedIndices() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, + new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); list.SetItem(1, 1, TestData.TestValueNinetyNine); - list.GetItem(1, 1).Should().Be(TestData.TestValueNinetyNine); + _ = list.GetItem(1, 1).Should().Be(TestData.TestValueNinetyNine); list.Dispose(); } @@ -257,7 +267,7 @@ public void SetItem_ShouldThrowWhenOuterIndexIsOutOfRange() var action = () => list.SetItem(TestData.TestValueFive, 0, TestData.TestValueNinetyNine); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -270,7 +280,7 @@ public void SetItem_ShouldThrowWhenInnerIndexIsOutOfRange() var action = () => list.SetItem(0, TestData.TestValueFive, TestData.TestValueNinetyNine); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.InnerIndexParameterName); list.Dispose(); } @@ -279,12 +289,18 @@ public void SetItem_ShouldThrowWhenInnerIndexIsOutOfRange() [Test] public void Flatten_ShouldReturnAllItemsInOrder() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo }, new() { TestData.TestValueThree, TestData.TestValueFour }, new() { TestData.TestValueFive, TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo }, + new() { TestData.TestValueThree, TestData.TestValueFour }, + new() { TestData.TestValueFive, TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); - var flattened = list.Flatten().ToList(); + var flattened = FlattenToList(list); - flattened.Should().HaveCount(TestData.TestValueSix); - flattened.Should().ContainInOrder(1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix); + _ = flattened.Should().HaveCount(TestData.TestValueSix); + _ = flattened.Should().ContainInOrder(1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix); list.Dispose(); } @@ -294,9 +310,9 @@ public void Flatten_ShouldReturnEmptyForEmptyList() { var list = new Reactive2DList(); - var flattened = list.Flatten().ToList(); + var flattened = FlattenToList(list); - flattened.Should().BeEmpty(); + _ = flattened.Should().BeEmpty(); list.Dispose(); } @@ -304,17 +320,12 @@ public void Flatten_ShouldReturnEmptyForEmptyList() [Test] public void Flatten_ShouldHandleEmptyInnerLists() { - var list = new Reactive2DList - { - new ReactiveList { 1, TestData.TestValueTwo }, - new ReactiveList(), - new ReactiveList { TestData.TestValueThree } - }; + var list = new Reactive2DList { new ReactiveList { 1, TestData.TestValueTwo }, new ReactiveList(), new ReactiveList { TestData.TestValueThree } }; - var flattened = list.Flatten().ToList(); + var flattened = FlattenToList(list); - flattened.Should().HaveCount(TestData.TestValueThree); - flattened.Should().ContainInOrder(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = flattened.Should().HaveCount(TestData.TestValueThree); + _ = flattened.Should().ContainInOrder(1, TestData.TestValueTwo, TestData.TestValueThree); list.Dispose(); } @@ -322,11 +333,17 @@ public void Flatten_ShouldHandleEmptyInnerLists() [Test] public void TotalCount_ShouldReturnSumOfAllInnerListCounts() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo }, new() { TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }, new() { TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo }, + new() { TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }, + new() { TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); var total = list.TotalCount(); - total.Should().Be(TestData.TestValueSix); + _ = total.Should().Be(TestData.TestValueSix); list.Dispose(); } @@ -338,7 +355,7 @@ public void TotalCount_ShouldReturnZeroForEmptyList() var total = list.TotalCount(); - total.Should().Be(0); + _ = total.Should().Be(0); list.Dispose(); } @@ -346,16 +363,11 @@ public void TotalCount_ShouldReturnZeroForEmptyList() [Test] public void TotalCount_ShouldHandleEmptyInnerLists() { - var list = new Reactive2DList - { - new ReactiveList { 1, TestData.TestValueTwo }, - new ReactiveList(), - new ReactiveList { TestData.TestValueThree } - }; + var list = new Reactive2DList { new ReactiveList { 1, TestData.TestValueTwo }, new ReactiveList(), new ReactiveList { TestData.TestValueThree } }; var total = list.TotalCount(); - total.Should().Be(TestData.TestValueThree); + _ = total.Should().Be(TestData.TestValueThree); list.Dispose(); } @@ -367,9 +379,9 @@ public void AddToInner_ShouldAddItemsToSpecifiedInnerList() list.AddToInner(0, [TestData.TestValueFive, TestData.TestValueSix]); - list[0].Count.Should().Be(TestData.TestValueFour); - list[0][TestData.TestValueTwo].Should().Be(TestData.TestValueFive); - list[0][TestData.TestValueThree].Should().Be(TestData.TestValueSix); + _ = list[0].Count.Should().Be(TestData.TestValueFour); + _ = list[0][TestData.TestValueTwo].Should().Be(TestData.TestValueFive); + _ = list[0][TestData.TestValueThree].Should().Be(TestData.TestValueSix); list.Dispose(); } @@ -381,8 +393,8 @@ public void AddToInner_ShouldAddSingleItemToSpecifiedInnerList() list.AddToInner(1, TestData.TestValueNinetyNine); - list[1].Count.Should().Be(TestData.TestValueThree); - list[1][TestData.TestValueTwo].Should().Be(TestData.TestValueNinetyNine); + _ = list[1].Count.Should().Be(TestData.TestValueThree); + _ = list[1][TestData.TestValueTwo].Should().Be(TestData.TestValueNinetyNine); list.Dispose(); } @@ -394,7 +406,7 @@ public void AddToInner_ShouldThrowWhenOuterIndexIsOutOfRange() var action = () => list.AddToInner(TestData.TestValueFive, TestData.TestValueNinetyNine); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -407,7 +419,7 @@ public void AddToInner_ShouldThrowWhenItemsIsNull() var action = () => list.AddToInner(0, (IEnumerable)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); list.Dispose(); } @@ -416,13 +428,18 @@ public void AddToInner_ShouldThrowWhenItemsIsNull() [Test] public void RemoveFromInner_ShouldRemoveItemAtSpecifiedIndices() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, + new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); list.RemoveFromInner(0, 1); - list[0].Count.Should().Be(TestData.TestValueTwo); - list[0][0].Should().Be(1); - list[0][1].Should().Be(TestData.TestValueThree); + _ = list[0].Count.Should().Be(TestData.TestValueTwo); + _ = list[0][0].Should().Be(1); + _ = list[0][1].Should().Be(TestData.TestValueThree); list.Dispose(); } @@ -434,7 +451,7 @@ public void RemoveFromInner_ShouldThrowWhenOuterIndexIsOutOfRange() var action = () => list.RemoveFromInner(TestData.TestValueFive, 0); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -443,12 +460,17 @@ public void RemoveFromInner_ShouldThrowWhenOuterIndexIsOutOfRange() [Test] public void ClearInner_ShouldClearTheSpecifiedInnerList() { - var list = new Reactive2DList((ReactiveList[])[new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix }]); + ReactiveList[] rows = + [ + new() { 1, TestData.TestValueTwo, TestData.TestValueThree }, + new() { TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix } + ]; + var list = new Reactive2DList(rows); list.ClearInner(0); - list[0].Count.Should().Be(0); - list[1].Count.Should().Be(TestData.TestValueThree); // Other list unchanged + _ = list[0].Count.Should().Be(0); + _ = list[1].Count.Should().Be(TestData.TestValueThree); // Other list unchanged list.Dispose(); } @@ -460,7 +482,7 @@ public void ClearInner_ShouldThrowWhenOuterIndexIsOutOfRange() var action = () => list.ClearInner(TestData.TestValueFive); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.OuterIndexParameterName); list.Dispose(); } @@ -469,9 +491,9 @@ public void ClearInner_ShouldThrowWhenOuterIndexIsOutOfRange() [Test] public void Constructor_ShouldThrowWhenItemsEnumerableIsNull() { - var action = () => new Reactive2DList((IEnumerable>)null!); + var action = static () => new Reactive2DList((IEnumerable>)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); } @@ -479,7 +501,7 @@ public void Constructor_ShouldThrowWhenItemsEnumerableIsNull() [Test] public void Constructor_ShouldThrowWhenItemEnumerableIsNull() { - var exception = Assert.Throws(() => _ = new Reactive2DList((IEnumerable)null!)); + var exception = Assert.Throws(static () => _ = new Reactive2DList((IEnumerable)null!)); Assert.Equal(TestData.ItemsParameterName, exception.ParamName); } @@ -488,9 +510,9 @@ public void Constructor_ShouldThrowWhenItemEnumerableIsNull() [Test] public void Constructor_ShouldThrowWhenReactiveListItemIsNull() { - var action = () => new Reactive2DList((ReactiveList)null!); + var action = static () => new Reactive2DList((ReactiveList)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("item"); } @@ -502,7 +524,7 @@ public void AddRange_NestedEnumerable_ShouldThrowWhenNull() var action = () => list.AddRange((IEnumerable>)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); list.Dispose(); } @@ -515,20 +537,20 @@ public void AddRange_SingleEnumerable_ShouldThrowWhenNull() var action = () => list.AddRange((IEnumerable)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); list.Dispose(); } - /// Insert with enumerable should throw when null. + /// InsertRange with enumerable should throw when null. [Test] - public void Insert_Enumerable_ShouldThrowWhenNull() + public void InsertRange_Enumerable_ShouldThrowWhenNull() { var list = new Reactive2DList([1]); - var action = () => list.Insert(0, (IEnumerable)null!); + var action = () => list.InsertRange(0, (IEnumerable)null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); list.Dispose(); } @@ -541,8 +563,13 @@ public void Insert_WithInnerIndex_ShouldThrowWhenItemsNull() var action = () => list.Insert(0, (IEnumerable)null!, 0); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName(TestData.ItemsParameterName); list.Dispose(); } + + /// Copies the flattened sequence without a LINQ allocation. + /// The two-dimensional reactive list. + /// The flattened values. + private static List FlattenToList(Reactive2DList list) => new(list.Flatten()); } diff --git a/src/ReactiveList.Test/ReactiveListAddTests.cs b/src/ReactiveList.Test/ReactiveListAddTests.cs index a5ac174..948f429 100644 --- a/src/ReactiveList.Test/ReactiveListAddTests.cs +++ b/src/ReactiveList.Test/ReactiveListAddTests.cs @@ -21,9 +21,9 @@ public void CanAddArrayItem() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); } /// Determines whether this instance [can add complex array item]. @@ -32,9 +32,9 @@ public void CanAddComplexArrayItem() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueThree); } /// Determines whether this instance [can add multiple single complex items]. @@ -43,13 +43,13 @@ public void CanAddMultipleSingleComplexItems() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); - fixture.Add(new TestData(TestData.CelineName, TestData.TestValueFive)); - fixture.Count.Should().Be(1); - fixture.Add(new TestData(TestData.ClarenceName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Add(new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(0); + fixture.Add(new(TestData.CelineName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(1); + fixture.Add(new(TestData.ClarenceName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + fixture.Add(new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); } /// Determines whether this instance [can add multiple single complex items and edit]. @@ -58,15 +58,15 @@ public void CanAddMultipleSingleComplexItemsAndEdit() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.Add(TestData.CelineName); - fixture.Count.Should().Be(1); + _ = fixture.Count.Should().Be(1); fixture.Add(TestData.ClarenceName); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); fixture.Add("Cliffordddd"); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueThree); fixture.Update(fixture.Items[TestData.TestValueTwo], TestData.CliffordName); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueThree); } /// Determines whether this instance [can add multiple single items]. @@ -75,13 +75,13 @@ public void CanAddMultipleSingleItems() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.Add("one"); - fixture.Count.Should().Be(1); + _ = fixture.Count.Should().Be(1); fixture.Add("two"); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); fixture.Add(TestData.ThreeText); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueThree); } /// Determines whether this instance [can add single complex item]. @@ -90,9 +90,9 @@ public void CanAddSingleComplexItem() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); - fixture.Add(new TestData("Chris", TestData.TestValueFortyFour)); - fixture.Count.Should().Be(1); + _ = fixture.Count.Should().Be(0); + fixture.Add(new("Chris", TestData.TestValueFortyFour)); + _ = fixture.Count.Should().Be(1); } /// Determines whether this instance [can add single item]. @@ -101,9 +101,9 @@ public void CanAddSingleItem() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.Add("one"); - fixture.Count.Should().Be(1); + _ = fixture.Count.Should().Be(1); } /// Determines whether this instance [can clear and add item]. @@ -112,29 +112,29 @@ public void CanClearAndAddItem() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be("one"); fixture.Clear(); - fixture.Count.Should().Be(0); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(0); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); fixture.Add(TestData.ThreeText); - fixture.Count.Should().Be(1); - fixture.ItemsAdded.Count.Should().Be(1); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(1); + _ = fixture.ItemsAdded.Count.Should().Be(1); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be(TestData.ThreeText); fixture.Clear(); - fixture.Count.Should().Be(0); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(1); + _ = fixture.Count.Should().Be(0); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(1); } /// Determines whether this instance [can observe add array of item asynchronous]. @@ -143,19 +143,21 @@ public void CanClearAndAddItem() public async Task CanObserveAddArrayOfItemAsync() { ReactiveList fixture = []; - var a = false; - fixture.Added.Subscribe(items => + var observedCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = fixture.Added.Subscribe(items => { - items.Count().Should().Be(TestData.TestValueTwo); - a = true; + var count = 0; + foreach (var _ in items) + { + count++; + } + + _ = observedCount.TrySetResult(count); }); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - while (!a) - { - await Task.Delay(1); - } + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + await TUnit.Assertions.Assert.That(await observedCount.Task).IsEqualTo(TestData.TestValueTwo); } /// Determines whether this instance [can observe add single item asynchronous]. @@ -164,20 +166,22 @@ public async Task CanObserveAddArrayOfItemAsync() public async Task CanObserveAddSingleItemAsync() { ReactiveList fixture = []; - var a = false; - fixture.Added.Subscribe(items => + var observedCount = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = fixture.Added.Subscribe(items => { - items.Count().Should().Be(1); - a = true; + var count = 0; + foreach (var _ in items) + { + count++; + } + + _ = observedCount.TrySetResult(count); }); fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.Add("one"); - fixture.Count.Should().Be(1); - while (!a) - { - await Task.Delay(1); - } + _ = fixture.Count.Should().Be(1); + await TUnit.Assertions.Assert.That(await observedCount.Task).IsEqualTo(1); } /// Determines whether this instance [can replace all items]. @@ -186,19 +190,19 @@ public void CanReplaceAllItems() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be("one"); fixture.ReplaceAll([TestData.ThreeText, "four", "five"]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); - fixture.Items[0].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Items[0].Should().Be(TestData.ThreeText); } /// Determines whether this instance [can replace all items many times]. @@ -207,25 +211,25 @@ public void CanReplaceAllItemsManyTimes() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be("one"); fixture.ReplaceAll([TestData.ThreeText, "four", "five"]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); - fixture.Items[0].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Items[0].Should().Be(TestData.ThreeText); fixture.ReplaceAll(["six", "seven", "eight"]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Should().Be("six"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Should().Be("six"); } /// Determines whether this instance [can replace all items with complex items]. @@ -234,19 +238,19 @@ public void CanReplaceAllItemsWithComplexItems() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); fixture.ReplaceAll([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); } /// Determines whether this instance [can replace all items with complex items and edit]. @@ -255,25 +259,25 @@ public void CanReplaceAllItemsWithComplexItemsAndEdit() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); fixture.ReplaceAll([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Name.Should().Be(TestData.CelineName); - fixture.Update(fixture.Items[TestData.TestValueTwo], new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); + fixture.Update(fixture.Items[TestData.TestValueTwo], new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); } /// Determines whether this instance [can replace all items with complex items and edit and remove]. @@ -282,30 +286,30 @@ public void CanReplaceAllItemsWithComplexItemsAndEditAndRemove() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); fixture.ReplaceAll([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Name.Should().Be(TestData.CelineName); - fixture.Update(fixture.Items[TestData.TestValueTwo], new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); - fixture.Remove(fixture.Items[TestData.TestValueTwo]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(1); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); + fixture.Update(fixture.Items[TestData.TestValueTwo], new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); + _ = fixture.Remove(fixture.Items[TestData.TestValueTwo]); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(1); } /// Determines whether this instance [can replace all items with complex items and edit and remove and add]. @@ -316,39 +320,39 @@ public void CanReplaceAllItemsWithComplexItemsAndEditAndRemoveAndAdd() var inpcName = string.Empty; fixture.PropertyChanged += (sender, args) => inpcName += args.PropertyName; fixture.Clear(); - fixture.Count.Should().Be(0); - inpcName.Should().Be("CountItem[]"); + _ = fixture.Count.Should().Be(0); + _ = inpcName.Should().Be("CountItem[]"); inpcName = string.Empty; fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - inpcName.Should().Be("CountItem[]"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = inpcName.Should().Be("CountItem[]"); inpcName = string.Empty; - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); fixture.ReplaceAll([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Name.Should().Be(TestData.CelineName); - fixture.Update(fixture.Items[TestData.TestValueTwo], new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); - fixture.Remove(fixture.Items[TestData.TestValueTwo]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(1); - fixture.Add(new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(1); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); + fixture.Update(fixture.Items[TestData.TestValueTwo], new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); + _ = fixture.Remove(fixture.Items[TestData.TestValueTwo]); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(1); + fixture.Add(new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(1); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(0); } /// Determines whether this instance [can replace all items with complex items and edit and remove and add and clear]. @@ -357,40 +361,40 @@ public void CanReplaceAllItemsWithComplexItemsAndEditAndRemoveAndAddAndClear() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Name.Should().Be(TestData.CelineName); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); fixture.ReplaceAll([new(TestData.CelineName, TestData.TestValueFive), new(TestData.ClarenceName, TestData.TestValueFive), new(TestData.CliffordName, TestData.TestValueFive)]); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[0].Name.Should().Be(TestData.CelineName); - fixture.Update(fixture.Items[TestData.TestValueTwo], new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); - fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); - fixture.Remove(fixture.Items[TestData.TestValueTwo]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(1); - fixture.Add(new TestData(TestData.CliffordName, TestData.TestValueFive)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(1); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[0].Name.Should().Be(TestData.CelineName); + fixture.Update(fixture.Items[TestData.TestValueTwo], new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Items[TestData.TestValueTwo].Name.Should().Be(TestData.CliffordName); + _ = fixture.Remove(fixture.Items[TestData.TestValueTwo]); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(1); + fixture.Add(new(TestData.CliffordName, TestData.TestValueFive)); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(1); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(0); fixture.Clear(); - fixture.Count.Should().Be(0); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(0); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsRemoved.Count.Should().Be(TestData.TestValueThree); } /// Determines whether this instance [can add items and insert items]. @@ -399,19 +403,19 @@ public void CanAddItemsAndInsertItems() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be("one"); fixture.Insert(1, TestData.ThreeText); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(1); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[1].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(1); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[1].Should().Be(TestData.ThreeText); } /// Determines whether this instance [can add items and insert items and remove at index]. @@ -420,24 +424,24 @@ public void CanAddItemsAndInsertItemsAndRemoveAtIndex() { ReactiveList fixture = []; fixture.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[0].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[0].Should().Be("one"); fixture.Insert(1, TestData.ThreeText); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.ItemsAdded.Count.Should().Be(1); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(0); - fixture.Items[1].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.ItemsAdded.Count.Should().Be(1); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(0); + _ = fixture.Items[1].Should().Be(TestData.ThreeText); fixture.RemoveAt(1); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.ItemsAdded.Count.Should().Be(0); - fixture.ItemsChanged.Count.Should().Be(1); - fixture.ItemsRemoved.Count.Should().Be(1); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.ItemsAdded.Count.Should().Be(0); + _ = fixture.ItemsChanged.Count.Should().Be(1); + _ = fixture.ItemsRemoved.Count.Should().Be(1); } /// Determines whether this instance can enumerate. @@ -447,10 +451,10 @@ public void CanEnumerate() ReactiveList fixture = []; fixture.Clear(); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); foreach (var item in fixture) { - item.Should().NotBeNullOrEmpty(); + _ = item.Should().NotBeNullOrEmpty(); } } @@ -461,10 +465,10 @@ public void CanGetElementAtOrDefault() ReactiveList fixture = []; fixture.Clear(); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); Assert.Equal("one", fixture.ElementAtOrDefault(0)); Assert.Equal("two", fixture.ElementAtOrDefault(1)); - Assert.Equal(null, fixture.ElementAtOrDefault(TestData.TestValueTwo)); + Assert.Equal(null, fixture.ElementAtOrDefault(TestData.TestValueTwo)); } /// Determines whether this instance [can add items to a list then add to fixture]. @@ -474,16 +478,16 @@ public void CanAddItemsToAListThenAddToFixture() List fixture = []; fixture.Clear(); fixture.AddRange(["one", "two"]); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); ReactiveList fixture2 = []; fixture2.AddRange(fixture); - fixture2.Count.Should().Be(TestData.TestValueTwo); - fixture2.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); - fixture2.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); - fixture2.ItemsRemoved.Count.Should().Be(0); - fixture2.Items[0].Should().Be("one"); - fixture2.Items[1].Should().Be("two"); + _ = fixture2.Count.Should().Be(TestData.TestValueTwo); + _ = fixture2.ItemsAdded.Count.Should().Be(TestData.TestValueTwo); + _ = fixture2.ItemsChanged.Count.Should().Be(TestData.TestValueTwo); + _ = fixture2.ItemsRemoved.Count.Should().Be(0); + _ = fixture2.Items[0].Should().Be("one"); + _ = fixture2.Items[1].Should().Be("two"); } } diff --git a/src/ReactiveList.Test/ReactiveListConnectTests.cs b/src/ReactiveList.Test/ReactiveListConnectTests.cs index 20399d5..bacf2e7 100644 --- a/src/ReactiveList.Test/ReactiveListConnectTests.cs +++ b/src/ReactiveList.Test/ReactiveListConnectTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Linq; using CP.Primitives; using CP.Primitives.Collections; using CP.Primitives.Core; @@ -27,7 +26,7 @@ public void Connect_ReturnsObservableStream() var observable = list.Connect(); // Assert - observable.Should().NotBeNull(); + _ = observable.Should().NotBeNull(); } /// Connect emits the current snapshot for preloaded sources. @@ -39,10 +38,16 @@ public void Connect_EmitsInitialSnapshot_WhenSourceHasItems() using var subscription = list.Connect().Subscribe(receivedChanges.Add); - receivedChanges.Should().ContainSingle(); - receivedChanges[0].Count.Should().Be(TestData.TestValueThree); - receivedChanges[0].Adds.Should().Be(TestData.TestValueThree); - receivedChanges[0].Select(change => change.Current).Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = receivedChanges.Should().ContainSingle(); + _ = receivedChanges[0].Count.Should().Be(TestData.TestValueThree); + _ = receivedChanges[0].Adds.Should().Be(TestData.TestValueThree); + var currentItems = new List(receivedChanges[0].Count); + foreach (var change in receivedChanges[0]) + { + currentItems.Add(change.Current); + } + + _ = currentItems.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); } /// Connect emits add changes when items are added. @@ -58,11 +63,11 @@ public void Connect_EmitsAddChanges_WhenItemsAdded() list.Add(TestData.TestValueFortyTwo); // Assert - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(1); - receivedChanges[0].Adds.Should().Be(1); - receivedChanges[0][0].Reason.Should().Be(ChangeReason.Add); - receivedChanges[0][0].Current.Should().Be(TestData.TestValueFortyTwo); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(1); + _ = receivedChanges[0].Adds.Should().Be(1); + _ = receivedChanges[0][0].Reason.Should().Be(ChangeReason.Add); + _ = receivedChanges[0][0].Current.Should().Be(TestData.TestValueFortyTwo); } /// Connect emits batch add changes when AddRange is called. @@ -78,9 +83,9 @@ public void Connect_EmitsBatchAddChanges_WhenAddRangeCalled() list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); // Assert - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(TestData.TestValueFive); - receivedChanges[0].Adds.Should().Be(TestData.TestValueFive); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(TestData.TestValueFive); + _ = receivedChanges[0].Adds.Should().Be(TestData.TestValueFive); } /// Connect emits remove changes when items are removed. @@ -94,14 +99,14 @@ public void Connect_EmitsRemoveChanges_WhenItemsRemoved() receivedChanges.Clear(); // Act - list.Remove(TestData.TestValueTwo); + _ = list.Remove(TestData.TestValueTwo); // Assert - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(1); - receivedChanges[0].Removes.Should().Be(1); - receivedChanges[0][0].Reason.Should().Be(ChangeReason.Remove); - receivedChanges[0][0].Current.Should().Be(TestData.TestValueTwo); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(1); + _ = receivedChanges[0].Removes.Should().Be(1); + _ = receivedChanges[0][0].Reason.Should().Be(ChangeReason.Remove); + _ = receivedChanges[0][0].Current.Should().Be(TestData.TestValueTwo); } /// @@ -121,12 +126,12 @@ public void Connect_EmitsClearChanges_WhenCleared() list.Clear(); // Assert - Clear emits Remove changes for each item (DynamicData compatible behavior) - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(TestData.TestValueThree); // One Remove change per cleared item - receivedChanges[0].Removes.Should().Be(TestData.TestValueThree); - receivedChanges[0][0].Reason.Should().Be(ChangeReason.Remove); - receivedChanges[0][1].Reason.Should().Be(ChangeReason.Remove); - receivedChanges[0][TestData.TestValueTwo].Reason.Should().Be(ChangeReason.Remove); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(TestData.TestValueThree); // One Remove change per cleared item + _ = receivedChanges[0].Removes.Should().Be(TestData.TestValueThree); + _ = receivedChanges[0][0].Reason.Should().Be(ChangeReason.Remove); + _ = receivedChanges[0][1].Reason.Should().Be(ChangeReason.Remove); + _ = receivedChanges[0][TestData.TestValueTwo].Reason.Should().Be(ChangeReason.Remove); } /// Connect emits move changes when item is moved. @@ -143,13 +148,13 @@ public void Connect_EmitsMoveChanges_WhenItemMoved() list.Move(0, TestData.TestValueFour); // Assert - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(1); - receivedChanges[0].Moves.Should().Be(1); - receivedChanges[0][0].Reason.Should().Be(ChangeReason.Move); - receivedChanges[0][0].Current.Should().Be(1); - receivedChanges[0][0].CurrentIndex.Should().Be(TestData.TestValueFour); - receivedChanges[0][0].PreviousIndex.Should().Be(0); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(1); + _ = receivedChanges[0].Moves.Should().Be(1); + _ = receivedChanges[0][0].Reason.Should().Be(ChangeReason.Move); + _ = receivedChanges[0][0].Current.Should().Be(1); + _ = receivedChanges[0][0].CurrentIndex.Should().Be(TestData.TestValueFour); + _ = receivedChanges[0][0].PreviousIndex.Should().Be(0); } /// Connect emits update changes when item is updated. @@ -166,11 +171,11 @@ public void Connect_EmitsUpdateChanges_WhenItemUpdated() list.Update(TestData.TestValueTwo, TestData.TestValueTwenty); // Assert - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Count.Should().Be(1); - receivedChanges[0].Updates.Should().Be(1); - receivedChanges[0][0].Reason.Should().Be(ChangeReason.Update); - receivedChanges[0][0].Current.Should().Be(TestData.TestValueTwenty); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Count.Should().Be(1); + _ = receivedChanges[0].Updates.Should().Be(1); + _ = receivedChanges[0][0].Reason.Should().Be(ChangeReason.Update); + _ = receivedChanges[0][0].Current.Should().Be(TestData.TestValueTwenty); } /// ChangeSet correctly counts different change types. @@ -191,11 +196,11 @@ public void ChangeSet_CorrectlyCounts_DifferentChangeTypes() var changeSet = new ChangeSet(changes); // Assert - changeSet.Count.Should().Be(TestData.TestValueFive); - changeSet.Adds.Should().Be(TestData.TestValueTwo); - changeSet.Removes.Should().Be(1); - changeSet.Updates.Should().Be(1); - changeSet.Moves.Should().Be(1); + _ = changeSet.Count.Should().Be(TestData.TestValueFive); + _ = changeSet.Adds.Should().Be(TestData.TestValueTwo); + _ = changeSet.Removes.Should().Be(1); + _ = changeSet.Updates.Should().Be(1); + _ = changeSet.Moves.Should().Be(1); } /// ChangeSet can be enumerated. @@ -212,13 +217,14 @@ public void ChangeSet_CanBeEnumerated() // Act var changeSet = new ChangeSet(changes); - var items = changeSet.ToList(); + var items = new List>(changeSet.Count); + items.AddRange(changeSet); // Assert - items.Should().HaveCount(TestData.TestValueThree); - items[0].Current.Should().Be(1); - items[1].Current.Should().Be(TestData.TestValueTwo); - items[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueThree); + _ = items.Should().HaveCount(TestData.TestValueThree); + _ = items[0].Current.Should().Be(1); + _ = items[1].Current.Should().Be(TestData.TestValueTwo); + _ = items[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueThree); } /// ChangeSet indexer returns correct change. @@ -235,9 +241,9 @@ public void ChangeSet_Indexer_ReturnsCorrectChange() var changeSet = new ChangeSet(changes); // Act & Assert - changeSet[0].Current.Should().Be(TestData.TestValueTen); - changeSet[1].Current.Should().Be(TestData.TestValueTwenty); - changeSet[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueThirty); + _ = changeSet[0].Current.Should().Be(TestData.TestValueTen); + _ = changeSet[1].Current.Should().Be(TestData.TestValueTwenty); + _ = changeSet[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueThirty); } /// ChangeSet indexer throws on out of range. @@ -249,7 +255,7 @@ public void ChangeSet_Indexer_ThrowsOnOutOfRange() // Act & Assert Action readOutOfRange = () => _ = changeSet[TestData.TestValueFive]; - readOutOfRange.Should().Throw(); + _ = readOutOfRange.Should().Throw(); } /// Change factory methods create correct change types. @@ -264,27 +270,27 @@ public void Change_FactoryMethods_CreateCorrectChangeTypes() var refresh = Change.CreateRefresh(TestData.TestValueFive, TestData.TestValueTwo); // Assert - add.Reason.Should().Be(ChangeReason.Add); - add.Current.Should().Be(1); - add.CurrentIndex.Should().Be(0); - - remove.Reason.Should().Be(ChangeReason.Remove); - remove.Current.Should().Be(TestData.TestValueTwo); - remove.PreviousIndex.Should().Be(1); - - update.Reason.Should().Be(ChangeReason.Update); - update.Current.Should().Be(TestData.TestValueThree); - update.Previous.Should().Be(TestData.TestValueTwo); - update.CurrentIndex.Should().Be(1); - - move.Reason.Should().Be(ChangeReason.Move); - move.Current.Should().Be(TestData.TestValueFour); - move.CurrentIndex.Should().Be(TestData.TestValueTwo); - move.PreviousIndex.Should().Be(0); - - refresh.Reason.Should().Be(ChangeReason.Refresh); - refresh.Current.Should().Be(TestData.TestValueFive); - refresh.CurrentIndex.Should().Be(TestData.TestValueTwo); + _ = add.Reason.Should().Be(ChangeReason.Add); + _ = add.Current.Should().Be(1); + _ = add.CurrentIndex.Should().Be(0); + + _ = remove.Reason.Should().Be(ChangeReason.Remove); + _ = remove.Current.Should().Be(TestData.TestValueTwo); + _ = remove.PreviousIndex.Should().Be(1); + + _ = update.Reason.Should().Be(ChangeReason.Update); + _ = update.Current.Should().Be(TestData.TestValueThree); + _ = update.Previous.Should().Be(TestData.TestValueTwo); + _ = update.CurrentIndex.Should().Be(1); + + _ = move.Reason.Should().Be(ChangeReason.Move); + _ = move.Current.Should().Be(TestData.TestValueFour); + _ = move.CurrentIndex.Should().Be(TestData.TestValueTwo); + _ = move.PreviousIndex.Should().Be(0); + + _ = refresh.Reason.Should().Be(ChangeReason.Refresh); + _ = refresh.Current.Should().Be(TestData.TestValueFive); + _ = refresh.CurrentIndex.Should().Be(TestData.TestValueTwo); } #if NET6_0_OR_GREATER || NETFRAMEWORK @@ -299,7 +305,7 @@ public void ToArray_ReturnsSnapshot() var snapshot = list.ToArray(); // Assert - snapshot.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = snapshot.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// ToArray returns empty array for empty list. @@ -313,7 +319,7 @@ public void ToArray_ReturnsEmptyArray_ForEmptyList() var snapshot = list.ToArray(); // Assert - snapshot.Should().BeEmpty(); + _ = snapshot.Should().BeEmpty(); } #endif } diff --git a/src/ReactiveList.Test/ReactiveListCoverageTests.cs b/src/ReactiveList.Test/ReactiveListCoverageTests.cs index 5117a25..354eacd 100644 --- a/src/ReactiveList.Test/ReactiveListCoverageTests.cs +++ b/src/ReactiveList.Test/ReactiveListCoverageTests.cs @@ -7,8 +7,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; -using System.Reflection; -using System.Runtime.Serialization; +using System.Threading.Tasks; using CP.Primitives.Collections; using CP.Primitives.Core; using FluentAssertions; @@ -28,24 +27,30 @@ public void ObservablePropertiesAndMetadata_ShouldReflectChanges() var current = new List(); var removed = new List(); - using var changedSubscription = fixture.Changed.Subscribe(items => changed.Add(items.ToArray())); - using var currentSubscription = fixture.CurrentItems.Subscribe(items => current.Add(items.ToArray())); - using var removedSubscription = fixture.Removed.Subscribe(items => removed.Add(items.ToArray())); + using var changedSubscription = fixture.Changed.Subscribe(items => AddSnapshot(changed, items)); + using var currentSubscription = fixture.CurrentItems.Subscribe(items => AddSnapshot(current, items)); + using var removedSubscription = fixture.Removed.Subscribe(items => AddSnapshot(removed, items)); - fixture.IsDisposed.Should().BeFalse(); - fixture.IsFixedSize.Should().BeFalse(); - fixture.IsReadOnly.Should().BeFalse(); - fixture.IsSynchronized.Should().BeFalse(); - fixture.SyncRoot.Should().BeSameAs(fixture); + _ = fixture.IsDisposed.Should().BeFalse(); + _ = fixture.IsFixedSize.Should().BeFalse(); + _ = fixture.IsReadOnly.Should().BeFalse(); + _ = fixture.IsSynchronized.Should().BeFalse(); + _ = fixture.SyncRoot.Should().BeSameAs(fixture); fixture.Add("one"); fixture.Update("one", "uno"); - fixture.Remove("uno"); + _ = fixture.Remove("uno"); - changed.SelectMany(items => items).Should().Contain(["one", "uno"]); - current.Should().NotBeEmpty(); - current[current.Count - 1].Should().BeEmpty(); - removed.Should().ContainSingle() + var changedItems = new List(); + foreach (var snapshot in changed) + { + changedItems.AddRange(snapshot); + } + + _ = changedItems.Should().Contain(["one", "uno"]); + _ = current.Should().NotBeEmpty(); + _ = current[current.Count - 1].Should().BeEmpty(); + _ = removed.Should().ContainSingle() .Which.Should().Equal("uno"); } @@ -56,26 +61,26 @@ public void NonGenericCollectionMembers_ShouldValidateAndMutate() ReactiveList fixture = ["one", "two"]; var list = (IList)fixture; - list[0].Should().Be("one"); + _ = list[0].Should().Be("one"); list[0] = "zero"; - fixture[0].Should().Be("zero"); + _ = fixture[0].Should().Be("zero"); - list.Add(TestData.ThreeText).Should().Be(TestData.TestValueTwo); + _ = list.Add(TestData.ThreeText).Should().Be(TestData.TestValueTwo); list.Insert(1, "inserted"); - list.Contains("two").Should().BeTrue(); - list.Contains(TestData.TestValueFortyTwo).Should().BeFalse(); - list.IndexOf(TestData.ThreeText).Should().Be(TestData.TestValueThree); - list.IndexOf(TestData.TestValueFortyTwo).Should().Be(-1); + _ = list.Contains("two").Should().BeTrue(); + _ = list.Contains(TestData.TestValueFortyTwo).Should().BeFalse(); + _ = list.IndexOf(TestData.ThreeText).Should().Be(TestData.TestValueThree); + _ = list.IndexOf(TestData.TestValueFortyTwo).Should().Be(-1); list.Remove("inserted"); list.Remove(TestData.TestValueFortyTwo); var objects = new object[fixture.Count]; ((ICollection)fixture).CopyTo(objects, 0); - objects.Should().Equal("zero", "two", TestData.ThreeText); + _ = objects.Should().Equal("zero", "two", TestData.ThreeText); var typed = new string[fixture.Count]; ((ICollection)fixture).CopyTo(typed, 0); - typed.Should().Equal("zero", "two", TestData.ThreeText); + _ = typed.Should().Equal("zero", "two", TestData.ThreeText); Action addWrongType = () => list.Add(TestData.TestValueFortyTwo); Action insertWrongType = () => list.Insert(0, TestData.TestValueFortyTwo); @@ -84,53 +89,57 @@ public void NonGenericCollectionMembers_ShouldValidateAndMutate() Action copyNonZeroLowerBound = () => ((ICollection)fixture).CopyTo(Array.CreateInstance(typeof(string), [TestData.TestValueThree], [1]), 0); Action copyNegativeIndex = () => ((ICollection)fixture).CopyTo(new string[3], -1); Action copyTooSmall = () => ((ICollection)fixture).CopyTo(new string[2], 0); - Action copyInvalidArrayType = () => ((ICollection)new ReactiveList([1])).CopyTo(new string[1], 0); + Action copyInvalidArrayType = static () => + { + using var invalidFixture = new ReactiveList([1]); + ((ICollection)invalidFixture).CopyTo(new string[1], 0); + }; - addWrongType.Should().Throw(); - insertWrongType.Should().Throw(); - copyNull.Should().Throw() + _ = addWrongType.Should().Throw(); + _ = insertWrongType.Should().Throw(); + _ = copyNull.Should().Throw() .WithParameterName(TestData.ArrayParameterName); - copyMultiDimensional.Should().Throw() + _ = copyMultiDimensional.Should().Throw() .WithParameterName(TestData.ArrayParameterName); - copyNonZeroLowerBound.Should().Throw() + _ = copyNonZeroLowerBound.Should().Throw() .WithParameterName(TestData.ArrayParameterName); - copyNegativeIndex.Should().Throw() + _ = copyNegativeIndex.Should().Throw() .WithParameterName(TestData.IndexParameterName); - copyTooSmall.Should().Throw() + _ = copyTooSmall.Should().Throw() .WithParameterName(TestData.ArrayParameterName); - copyInvalidArrayType.Should().Throw(); + _ = copyInvalidArrayType.Should().Throw(); list.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); } /// Generic explicit members and empty batch branches should be no-ops. [Test] public void GenericExplicitMembersAndEmptyBatches_ShouldBehaveConsistently() { - ReactiveList emptyFromEnumerable = new([]); + using ReactiveList emptyFromEnumerable = new([]); ReactiveList fixture = [1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour]; var genericCollection = (ICollection)fixture; var genericList = (IList)fixture; - emptyFromEnumerable.Count.Should().Be(0); - genericList.IndexOf(TestData.TestValueThree).Should().Be(TestData.TestValueTwo); - ((IList)fixture).IndexOf(null).Should().Be(-1); - ((IList)fixture).Contains(null).Should().BeFalse(); + _ = emptyFromEnumerable.Count.Should().Be(0); + _ = genericList.IndexOf(TestData.TestValueThree).Should().Be(TestData.TestValueTwo); + _ = ((IList)fixture).IndexOf(null).Should().Be(-1); + _ = ((IList)fixture).Contains(null).Should().BeFalse(); fixture.AddRange(Array.Empty()); fixture.InsertRange(TestData.TestValueTwo, []); fixture.Remove([]); fixture.RemoveRange(0, 0); - fixture.Count.Should().Be(TestData.TestValueFour); + _ = fixture.Count.Should().Be(TestData.TestValueFour); genericList.RemoveAt(0); ((IList)fixture).RemoveAt(0); - fixture.Should().Equal(TestData.TestValueThree, TestData.TestValueFour); + _ = fixture.Should().Equal(TestData.TestValueThree, TestData.TestValueFour); genericCollection.Clear(); - fixture.Count.Should().Be(0); + _ = fixture.Count.Should().Be(0); } /// Reactive2DList guard branches should validate outer indexes and null row values. @@ -143,11 +152,11 @@ public void Reactive2DList_Guards_ShouldValidateOuterIndexesAndNullRows() Action addSingleBadOuter = () => grid.AddToInner(-1, "b"); Action insertNullItem = () => grid.Insert(0, (string)null!); - addManyBadOuter.Should().Throw() + _ = addManyBadOuter.Should().Throw() .WithParameterName("outerIndex"); - addSingleBadOuter.Should().Throw() + _ = addSingleBadOuter.Should().Throw() .WithParameterName("outerIndex"); - insertNullItem.Should().Throw() + _ = insertNullItem.Should().Throw() .WithParameterName("item"); } @@ -159,24 +168,24 @@ public void SpanAndMemoryHelpers_ShouldCopySnapshotsAndValidateDestination() { ReactiveList fixture = [1, TestData.TestValueTwo, TestData.TestValueThree]; - fixture.ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); - fixture.AsSpan().ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); - fixture.AsMemory().ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = fixture.ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = fixture.AsSpan().ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = fixture.AsMemory().ToArray().Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); var destination = new int[3]; fixture.CopyTo(destination.AsSpan()); - destination.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = destination.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); Action copyTooSmall = () => fixture.CopyTo(new int[2].AsSpan()); - copyTooSmall.Should().Throw() + _ = copyTooSmall.Should().Throw() .WithParameterName("destination"); fixture.AddRange(ReadOnlySpan.Empty); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueThree); int[] values = [TestData.TestValueFour, TestData.TestValueFive]; fixture.AddRange(values.AsSpan()); - fixture.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive); + _ = fixture.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive); } #endif @@ -191,25 +200,25 @@ public void ClearWithoutDeallocation_ShouldSupportSilentAndNotifyingBranches() fixture.PropertyChanged += (sender, args) => propertyNames.Add(args.PropertyName); fixture.ClearWithoutDeallocation(notifyChange: false); - propertyNames.Should().BeEmpty(); + _ = propertyNames.Should().BeEmpty(); fixture.ClearWithoutDeallocation(); - propertyNames.Should().Equal(nameof(fixture.Count), "Item[]"); + _ = propertyNames.Should().Equal(nameof(fixture.Count), "Item[]"); fixture.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); propertyNames.Clear(); fixture.ClearWithoutDeallocation(notifyChange: false); - fixture.Count.Should().Be(0); - fixture.Items.Should().BeEmpty(); - propertyNames.Should().BeEmpty(); + _ = fixture.Count.Should().Be(0); + _ = fixture.Items.Should().BeEmpty(); + _ = propertyNames.Should().BeEmpty(); fixture.AddRange([TestData.TestValueFour, TestData.TestValueFive]); fixture.ClearWithoutDeallocation(); - fixture.Count.Should().Be(0); - fixture.ItemsRemoved.Should().Equal(TestData.TestValueFour, TestData.TestValueFive); - fixture.ItemsChanged.Should().Equal(TestData.TestValueFour, TestData.TestValueFive); + _ = fixture.Count.Should().Be(0); + _ = fixture.ItemsRemoved.Should().Equal(TestData.TestValueFour, TestData.TestValueFive); + _ = fixture.ItemsChanged.Should().Equal(TestData.TestValueFour, TestData.TestValueFive); } #endif @@ -219,12 +228,12 @@ public void RemovalBranches_ShouldValidateRangesAndReportRemovedItems() { ReactiveList fixture = [.. Enumerable.Range(0, TestData.TestValueForty)]; var removed = new List(); - using var subscription = fixture.Removed.Subscribe(items => removed.Add(items.ToArray())); + using var subscription = fixture.Removed.Subscribe(items => AddSnapshot(removed, items)); fixture.Remove([1, TestData.TestValueOneHundred, TestData.TestValueThree]); - fixture.Count.Should().Be(TestData.TestValueThirtyEight); - removed.Should().ContainSingle() + _ = fixture.Count.Should().Be(TestData.TestValueThirtyEight); + _ = removed.Should().ContainSingle() .Which.Should().Equal(1, TestData.TestValueThree); Action removeManyNull = () => fixture.RemoveMany(null!); @@ -232,20 +241,20 @@ public void RemovalBranches_ShouldValidateRangesAndReportRemovedItems() Action removeRangeBadIndex = () => fixture.RemoveRange(-1, 1); Action removeRangeBadCount = () => fixture.RemoveRange(0, fixture.Count + 1); - removeManyNull.Should().Throw() + _ = removeManyNull.Should().Throw() .WithParameterName("predicate"); - removeAtInvalid.Should().Throw() + _ = removeAtInvalid.Should().Throw() .WithParameterName(TestData.IndexParameterName); - removeRangeBadIndex.Should().Throw() + _ = removeRangeBadIndex.Should().Throw() .WithParameterName(TestData.IndexParameterName); - removeRangeBadCount.Should().Throw() + _ = removeRangeBadCount.Should().Throw() .WithParameterName("count"); fixture.RemoveRange(0, TestData.TestValueTwo); - var removedCount = fixture.RemoveMany(_ => true); + var removedCount = fixture.RemoveMany(static _ => true); - removedCount.Should().Be(TestData.TestValueThirtySix); - fixture.Count.Should().Be(0); + _ = removedCount.Should().Be(TestData.TestValueThirtySix); + _ = fixture.Count.Should().Be(0); } /// CollectionChanged should use specific actions for single changes and reset for batches. @@ -257,19 +266,25 @@ public void CollectionChanged_ShouldUseSpecificActionsForSingleChangesAndResetFo fixture.CollectionChanged += (sender, args) => events.Add(args); fixture.Add("four"); - fixture.Remove("four"); + _ = fixture.Remove("four"); fixture.Move(0, 1); fixture.AddRange(["five", "six"]); - events.Select(args => args.Action).Should().Equal( + var actions = new List(events.Count); + foreach (var eventArgs in events) + { + actions.Add(eventArgs.Action); + } + + _ = actions.Should().Equal( NotifyCollectionChangedAction.Add, NotifyCollectionChangedAction.Remove, NotifyCollectionChangedAction.Move, NotifyCollectionChangedAction.Reset); - events[0].NewStartingIndex.Should().Be(TestData.TestValueThree); - events[1].OldStartingIndex.Should().Be(TestData.TestValueThree); - events[TestData.TestValueTwo].OldStartingIndex.Should().Be(0); - events[TestData.TestValueTwo].NewStartingIndex.Should().Be(1); + _ = events[0].NewStartingIndex.Should().Be(TestData.TestValueThree); + _ = events[1].OldStartingIndex.Should().Be(TestData.TestValueThree); + _ = events[TestData.TestValueTwo].OldStartingIndex.Should().Be(0); + _ = events[TestData.TestValueTwo].NewStartingIndex.Should().Be(1); } /// ReplaceAll should emit old and new batches when either side is populated. @@ -287,8 +302,8 @@ public void ReplaceAll_ShouldEmitOldAndNewBatchesWhenPresent() fixture.ReplaceAll(["one", "two"]); fixture.ReplaceAll([]); - fixture.Count.Should().Be(0); - actions.Should().Equal(CacheAction.BatchAdded, CacheAction.BatchRemoved); + _ = fixture.Count.Should().Be(0); + _ = actions.Should().Equal(CacheAction.BatchAdded, CacheAction.BatchRemoved); } /// Subscribe should delegate to CurrentItems and Dispose should release resources. @@ -300,92 +315,71 @@ public void SubscribeAndDispose_ShouldUseCurrentItemsAndReleaseResources() using var subscription = fixture.Subscribe(observer); fixture.Add(TestData.TestValueTen); - observer.Snapshots.Should().HaveCountGreaterThanOrEqualTo(TestData.TestValueTwo); - observer.Snapshots[observer.Snapshots.Count - 1].Should().Equal(TestData.TestValueTen); + _ = observer.Snapshots.Should().HaveCountGreaterThanOrEqualTo(TestData.TestValueTwo); + _ = observer.Snapshots[observer.Snapshots.Count - 1].Should().Equal(TestData.TestValueTen); fixture.Dispose(); - fixture.IsDisposed.Should().BeTrue(); + _ = fixture.IsDisposed.Should().BeTrue(); using var disposeHarness = new DisposeHarness(); disposeHarness.DisposeWithoutManagedResources(); - disposeHarness.IsDisposed.Should().BeFalse(); + _ = disposeHarness.IsDisposed.Should().BeFalse(); } - /// - /// Private notification helpers should preserve stream and range collection behavior for otherwise unreachable no-op paths. - /// + /// Public notification paths should preserve stream behavior and handle empty batch no-ops. + /// A task that represents the asynchronous test operation. [Test] - public void InternalNotificationHelpers_ShouldHandleEmptyAndRefreshBranches() + public async Task NotificationPaths_ShouldHandleEmptyAndChangedBranches() { ReactiveList fixture = []; - ReactiveList deserializedFixture = []; var stream = new List>(); - ReactiveList referenceFixture = []; - var changed = new List(); + var changed = new List(); + using var changedSubscription = fixture.Changed.Subscribe(items => AddSnapshot(changed, items)); using var streamSubscription = fixture.Stream.Subscribe(notification => { stream.Add(notification); notification.Batch?.Dispose(); }); - using var changedSubscription = referenceFixture.Changed.Subscribe(items => changed.Add(items.ToArray())); fixture.AddRange((IEnumerable)Array.Empty()); Action setInvalidIndex = () => fixture[0] = 1; - setInvalidIndex.Should().Throw() + _ = setInvalidIndex.Should().Throw() .WithParameterName(TestData.IndexParameterName); - InvokePrivate(deserializedFixture, "OnDeserialized", default(StreamingContext)); - InvokePrivate(fixture, "OnPropertyChanged", "Custom"); - InvokePrivate(fixture, "NotifyCleared", Array.Empty(), true); - InvokePrivate(fixture, "NotifyCleared", Array.Empty(), false); - InvokePrivate(fixture, "NotifyAdded", TestData.TestValueOneHundred, -1, false); - InvokePrivate(fixture, "NotifyRemoved", TestData.TestValueOneHundred, 0, false); - InvokePrivate(fixture, "NotifyChangedSingle", TestData.TestValueFortyTwo, ChangeReason.Refresh, -1, -1, default(int)); - InvokePrivate(fixture, "NotifyChangedSingle", TestData.TestValueFortyThree, (ChangeReason)TestData.TestValueInvalidEnumValue, -1, -1, default(int)); - InvokePrivate(referenceFixture, "EmitStream", CacheAction.Updated, null, null, -1, -1, null); - - var observableItems = GetPrivateField(fixture, "_observableItems"); - var observableItemsType = observableItems.GetType(); - var addRangeMethod = observableItemsType.GetMethod("AddRange") ?? throw new MissingMethodException(observableItemsType.FullName, "AddRange"); - var insertRangeMethod = observableItemsType.GetMethod("InsertRange") ?? throw new MissingMethodException(observableItemsType.FullName, "InsertRange"); - var removeRangeMethod = observableItemsType.GetMethod("RemoveRange") ?? throw new MissingMethodException(observableItemsType.FullName, "RemoveRange"); - addRangeMethod.Invoke(observableItems, [Array.Empty()]); - insertRangeMethod.Invoke(observableItems, [0, Array.Empty()]); - removeRangeMethod.Invoke(observableItems, [0, 0]); - - stream.Select(notification => notification.Action).Should().Contain( - [CacheAction.Cleared, CacheAction.Refreshed]); - changed.Any(static items => items.Length == 0).Should().BeTrue(); - } + await TUnit.Assertions.Assert.That(stream.Count).IsEqualTo(0); + await TUnit.Assertions.Assert.That(changed.Count).IsEqualTo(0); - /// Provides GetPrivateField. - /// The T type. - /// The target value. - /// The fieldName value. - /// The result. - private static object GetPrivateField(ReactiveList target, string fieldName) - where T : notnull - { - var field = typeof(ReactiveList).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingFieldException(typeof(ReactiveList).FullName, fieldName); - return field.GetValue(target) ?? throw new InvalidOperationException($"Field '{fieldName}' returned null."); + fixture.Add(TestData.TestValueFortyTwo); + fixture[0] = TestData.TestValueFortyThree; + fixture.Clear(); + + var actions = new List(stream.Count); + foreach (var notification in stream) + { + actions.Add(notification.Action); + } + + await TUnit.Assertions.Assert.That(actions.Count).IsEqualTo(TestData.TestValueThree); + await TUnit.Assertions.Assert.That(actions[0]).IsEqualTo(CacheAction.Added); + await TUnit.Assertions.Assert.That(actions[1]).IsEqualTo(CacheAction.Updated); + await TUnit.Assertions.Assert.That(actions[TestData.TestValueTwo]).IsEqualTo(CacheAction.Cleared); + await TUnit.Assertions.Assert.That(changed.Count).IsEqualTo(TestData.TestValueThree); + await TUnit.Assertions.Assert.That(changed[0][0]).IsEqualTo(TestData.TestValueFortyTwo); + await TUnit.Assertions.Assert.That(changed[1][0]).IsEqualTo(TestData.TestValueFortyThree); + await TUnit.Assertions.Assert.That(changed[TestData.TestValueTwo][0]).IsEqualTo(TestData.TestValueFortyThree); } - /// Provides InvokePrivate. - /// The T type. - /// The target value. - /// The methodName value. - /// The args value. - /// The result. - private static object? InvokePrivate(ReactiveList target, string methodName, params object?[] args) - where T : notnull + /// Adds an explicit snapshot without LINQ allocation overhead. + /// The item type. + /// The destination snapshot collection. + /// The items to snapshot. + private static void AddSnapshot(List snapshots, IEnumerable items) { - var method = typeof(ReactiveList).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMethodException(typeof(ReactiveList).FullName, methodName); - return method.Invoke(target, args); + var snapshot = new List(items); + snapshots.Add([.. snapshot]); } /// Provides DisposeHarness. @@ -417,6 +411,6 @@ public void OnError(Exception error) /// Provides OnNext. /// The value. - public void OnNext(IEnumerable value) => Snapshots.Add(value.ToArray()); + public void OnNext(IEnumerable value) => AddSnapshot(Snapshots, value); } } diff --git a/src/ReactiveList.Test/ReactiveListEditTests.cs b/src/ReactiveList.Test/ReactiveListEditTests.cs index 9728fd2..83e0c3b 100644 --- a/src/ReactiveList.Test/ReactiveListEditTests.cs +++ b/src/ReactiveList.Test/ReactiveListEditTests.cs @@ -18,17 +18,17 @@ public void Edit_ShouldAllowBatchAddOperations() { ReactiveList fixture = []; - fixture.Edit(list => + fixture.Edit(static list => { list.Add("one"); list.Add("two"); list.Add(TestData.ThreeText); }); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } /// Edit should allow batch remove operations. @@ -37,15 +37,15 @@ public void Edit_ShouldAllowBatchRemoveOperations() { ReactiveList fixture = ["one", "two", TestData.ThreeText, "four"]; - fixture.Edit(list => + fixture.Edit(static list => { - list.Remove("two"); - list.Remove("four"); + _ = list.Remove("two"); + _ = list.Remove("four"); }); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("one"); - fixture[1].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be(TestData.ThreeText); } /// Edit should allow mixed operations. @@ -54,18 +54,18 @@ public void Edit_ShouldAllowMixedOperations() { ReactiveList fixture = ["one", "two"]; - fixture.Edit(list => + fixture.Edit(static list => { list.Add(TestData.ThreeText); - list.Remove("one"); + _ = list.Remove("one"); list.Add("four"); }); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture.Should().Contain("two"); - fixture.Should().Contain(TestData.ThreeText); - fixture.Should().Contain("four"); - fixture.Should().NotContain("one"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture.Should().Contain("two"); + _ = fixture.Should().Contain(TestData.ThreeText); + _ = fixture.Should().Contain("four"); + _ = fixture.Should().NotContain("one"); } /// Edit should allow clear and repopulate. @@ -74,16 +74,16 @@ public void Edit_ShouldAllowClearAndRepopulate() { ReactiveList fixture = ["one", "two", TestData.ThreeText]; - fixture.Edit(list => + fixture.Edit(static list => { list.Clear(); list.Add("alpha"); list.Add("beta"); }); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("alpha"); - fixture[1].Should().Be("beta"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("alpha"); + _ = fixture[1].Should().Be("beta"); } /// Edit should throw when action is null. @@ -94,7 +94,7 @@ public void Edit_ShouldThrowWhenActionIsNull() var action = () => fixture.Edit(null!); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("editAction"); } @@ -120,15 +120,15 @@ public void Edit_ShouldRaisePropertyChanged() itemArrayChanges++; }; - fixture.Edit(list => + fixture.Edit(static list => { list.Add("one"); list.Add("two"); list.Add(TestData.ThreeText); }); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// Edit should allow insert at index. @@ -137,12 +137,12 @@ public void Edit_ShouldAllowInsertAtIndex() { ReactiveList fixture = ["one", TestData.ThreeText]; - fixture.Edit(list => list.Insert(1, "two")); + fixture.Edit(static list => list.Insert(1, "two")); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } /// Edit should allow remove at index. @@ -151,11 +151,11 @@ public void Edit_ShouldAllowRemoveAtIndex() { ReactiveList fixture = ["one", "two", TestData.ThreeText]; - fixture.Edit(list => list.RemoveAt(1)); + fixture.Edit(static list => list.RemoveAt(1)); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("one"); - fixture[1].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be(TestData.ThreeText); } /// Edit should allow add range. @@ -164,13 +164,13 @@ public void Edit_ShouldAllowAddRange() { ReactiveList fixture = ["one"]; - fixture.Edit(list => list.AddRange(["two", TestData.ThreeText, "four"])); + fixture.Edit(static list => list.AddRange(["two", TestData.ThreeText, "four"])); - fixture.Count.Should().Be(TestData.TestValueFour); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); - fixture[TestData.TestValueThree].Should().Be("four"); + _ = fixture.Count.Should().Be(TestData.TestValueFour); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture[TestData.TestValueThree].Should().Be("four"); } /// Edit should allow replace operation. @@ -179,17 +179,17 @@ public void Edit_ShouldAllowReplaceOperation() { ReactiveList fixture = ["one", "two", TestData.ThreeText]; - fixture.Edit(list => + fixture.Edit(static list => { var index = list.IndexOf("two"); list.RemoveAt(index); list.Insert(index, "TWO"); }); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("TWO"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("TWO"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } /// Edit should work with complex types. @@ -198,15 +198,15 @@ public void Edit_ShouldWorkWithComplexTypes() { ReactiveList fixture = []; - fixture.Edit(list => + fixture.Edit(static list => { - list.Add(new TestData("Alice", TestData.TestValueTwentyFive)); - list.Add(new TestData("Bob", TestData.TestValueThirty)); + list.Add(new("Alice", TestData.TestValueTwentyFive)); + list.Add(new("Bob", TestData.TestValueThirty)); }); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Name.Should().Be("Alice"); - fixture[1].Name.Should().Be("Bob"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Name.Should().Be("Alice"); + _ = fixture[1].Name.Should().Be("Bob"); } /// Edit should handle empty action gracefully. @@ -215,11 +215,11 @@ public void Edit_ShouldHandleEmptyActionGracefully() { ReactiveList fixture = ["one", "two"]; - fixture.Edit(_ => { }); + fixture.Edit(static _ => { }); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); } /// Edit should allow move operation. @@ -228,12 +228,12 @@ public void Edit_ShouldAllowMoveOperation() { ReactiveList fixture = ["one", "two", TestData.ThreeText]; - fixture.Edit(list => list.Move(0, TestData.TestValueTwo)); + fixture.Edit(static list => list.Move(0, TestData.TestValueTwo)); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("two"); - fixture[1].Should().Be(TestData.ThreeText); - fixture[TestData.TestValueTwo].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("two"); + _ = fixture[1].Should().Be(TestData.ThreeText); + _ = fixture[TestData.TestValueTwo].Should().Be("one"); } /// Edit should allow multiple operations in sequence. @@ -242,7 +242,7 @@ public void Edit_ShouldAllowMultipleOperationsInSequence() { ReactiveList fixture = []; - fixture.Edit(list => + fixture.Edit(static list => { for (var i = 1; i <= TestData.TestValueFive; i++) { @@ -254,7 +254,7 @@ public void Edit_ShouldAllowMultipleOperationsInSequence() list.Move(TestData.TestValueFour, 1); // Move 5 to position 1 }); - fixture.Count.Should().Be(TestData.TestValueFive); - fixture.Should().ContainInOrder(0, TestData.TestValueFive, 1, TestData.TestValueTwo, TestData.TestValueFour); + _ = fixture.Count.Should().Be(TestData.TestValueFive); + _ = fixture.Should().ContainInOrder(0, TestData.TestValueFive, 1, TestData.TestValueTwo, TestData.TestValueFour); } } diff --git a/src/ReactiveList.Test/ReactiveListExtensionsAdditionalTests.cs b/src/ReactiveList.Test/ReactiveListExtensionsAdditionalTests.cs index 44443f6..f8f666e 100644 --- a/src/ReactiveList.Test/ReactiveListExtensionsAdditionalTests.cs +++ b/src/ReactiveList.Test/ReactiveListExtensionsAdditionalTests.cs @@ -6,6 +6,9 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; +#if NET8_0_OR_GREATER +using System.Runtime.InteropServices; +#endif using System.Threading.Tasks; using CP.Primitives; using CP.Primitives.Collections; @@ -39,9 +42,9 @@ public void OnUpdate_ReturnsPreviousAndCurrentValues() list.Update(TestData.OriginalText, "updated"); // Assert - Previous should contain the original value - updates.Should().HaveCount(1); - updates[0].Previous.Should().Be(TestData.OriginalText); - updates[0].Current.Should().Be("updated"); + _ = updates.Should().HaveCount(1); + _ = updates[0].Previous.Should().Be(TestData.OriginalText); + _ = updates[0].Current.Should().Be("updated"); } /// Tests that OnUpdate does not emit for add operations. @@ -62,7 +65,7 @@ public void OnUpdate_DoesNotEmitForAddOperations() list.Add(TestData.TestValueThree); // Assert - updateCount.Should().Be(0); + _ = updateCount.Should().Be(0); } /// Tests that OnUpdate handles multiple sequential updates with previous values. @@ -84,13 +87,13 @@ public void OnUpdate_HandlesMultipleSequentialUpdates() list.Update(TestData.TestValueOneHundred, TestData.TestValueOneThousand); // Assert - Previous should contain the actual previous value - updates.Should().HaveCount(TestData.TestValueThree); - updates[0].Previous.Should().Be(1); - updates[0].Current.Should().Be(TestData.TestValueTen); - updates[1].Previous.Should().Be(TestData.TestValueTen); - updates[1].Current.Should().Be(TestData.TestValueOneHundred); - updates[TestData.TestValueTwo].Previous.Should().Be(TestData.TestValueOneHundred); - updates[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueOneThousand); + _ = updates.Should().HaveCount(TestData.TestValueThree); + _ = updates[0].Previous.Should().Be(1); + _ = updates[0].Current.Should().Be(TestData.TestValueTen); + _ = updates[1].Previous.Should().Be(TestData.TestValueTen); + _ = updates[1].Current.Should().Be(TestData.TestValueOneHundred); + _ = updates[TestData.TestValueTwo].Previous.Should().Be(TestData.TestValueOneHundred); + _ = updates[TestData.TestValueTwo].Current.Should().Be(TestData.TestValueOneThousand); } /// Tests that OnMove returns item and indices when items are moved. @@ -106,14 +109,14 @@ public void OnMove_ReturnsItemAndIndices() .Subscribe(moves.Add); // Act - list.AddRange(new[] { "a", "b", "c", "d" }); + list.AddRange(["a", "b", "c", "d"]); list.Move(0, TestData.TestValueThree); // Move "a" from index 0 to index 3 // Assert - moves.Should().HaveCount(1); - moves[0].Item.Should().Be("a"); - moves[0].OldIndex.Should().Be(0); - moves[0].NewIndex.Should().Be(TestData.TestValueThree); + _ = moves.Should().HaveCount(1); + _ = moves[0].Item.Should().Be("a"); + _ = moves[0].OldIndex.Should().Be(0); + _ = moves[0].NewIndex.Should().Be(TestData.TestValueThree); } /// Tests that OnMove does not emit for add or remove operations. @@ -131,10 +134,10 @@ public void OnMove_DoesNotEmitForAddRemove() // Act list.Add(1); list.Add(TestData.TestValueTwo); - list.Remove(1); + _ = list.Remove(1); // Assert - moveCount.Should().Be(0); + _ = moveCount.Should().Be(0); } /// Tests that OnMove handles multiple move operations. @@ -150,12 +153,12 @@ public void OnMove_HandlesMultipleMoves() .Subscribe(moves.Add); // Act - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); list.Move(0, TestData.TestValueFour); // Move 1 to end list.Move(TestData.TestValueThree, 0); // Move 1 back to start (it's now at index 3) // Assert - moves.Should().HaveCount(TestData.TestValueTwo); + _ = moves.Should().HaveCount(TestData.TestValueTwo); } /// Tests that FilterDynamic filters items based on dynamic predicate. @@ -164,7 +167,7 @@ public void FilterDynamic_FiltersBasedOnDynamicPredicate() { // Arrange using var list = new ReactiveList(); - var filterSubject = new BehaviorSignal>(_ => true); + using var filterSubject = new BehaviorSignal>(static _ => true); var receivedItems = new List(); using var subscription = list.Stream @@ -185,16 +188,16 @@ public void FilterDynamic_FiltersBasedOnDynamicPredicate() list.Add(TestData.TestValueThree); // Assert - receivedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); + _ = receivedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); // Act - change filter to only even numbers receivedItems.Clear(); - filterSubject.OnNext(x => x % TestData.TestValueTwo == 0); + filterSubject.OnNext(static x => x % TestData.TestValueTwo == 0); list.Add(TestData.TestValueFour); list.Add(TestData.TestValueFive); // Assert - only even number should be received - receivedItems.Should().BeEquivalentTo([TestData.TestValueFour]); + _ = receivedItems.Should().BeEquivalentTo([TestData.TestValueFour]); } /// Tests that FilterDynamic always passes removed items. @@ -203,7 +206,7 @@ public void FilterDynamic_AlwaysPassesRemovedItems() { // Arrange using var list = new ReactiveList(); - var filterSubject = new BehaviorSignal>(x => x > TestData.TestValueFive); + using var filterSubject = new BehaviorSignal>(static x => x > TestData.TestValueFive); var removedItems = new List(); using var subscription = list.Stream @@ -221,10 +224,10 @@ public void FilterDynamic_AlwaysPassesRemovedItems() // Act - add items (only > 5 pass filter) list.Add(TestData.TestValueThree); // filtered out on add list.Add(TestData.TestValueTen); // passes filter - list.Remove(TestData.TestValueThree); // should still emit remove + _ = list.Remove(TestData.TestValueThree); // should still emit remove // Assert - removedItems.Should().Contain(TestData.TestValueThree); + _ = removedItems.Should().Contain(TestData.TestValueThree); } /// Tests that FilterDynamic passes Cleared notifications. @@ -233,7 +236,7 @@ public void FilterDynamic_PassesClearedNotifications() { // Arrange using var list = new ReactiveList(); - var filterSubject = new BehaviorSignal>(x => x > 0); + using var filterSubject = new BehaviorSignal>(static x => x > 0); var clearReceived = false; using var subscription = list.Stream @@ -249,11 +252,11 @@ public void FilterDynamic_PassesClearedNotifications() }); // Act - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); list.Clear(); // Assert - clearReceived.Should().BeTrue(); + _ = clearReceived.Should().BeTrue(); } /// Tests that CreateView without filter contains all items. @@ -263,15 +266,15 @@ public async Task CreateView_WithoutFilter_ContainsAllItems() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); // Act using var view = list.CreateView(Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Assert - view.Count.Should().Be(TestData.TestValueFive); - view.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = view.Count.Should().Be(TestData.TestValueFive); + _ = view.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// Tests that CreateView without filter updates when source changes. @@ -281,7 +284,7 @@ public async Task CreateView_WithoutFilter_UpdatesOnSourceChange() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); using var view = list.CreateView(Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); @@ -291,7 +294,7 @@ public async Task CreateView_WithoutFilter_UpdatesOnSourceChange() await Task.Delay(TestData.TestValueFifty); // Assert - view.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour]); + _ = view.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour]); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -305,31 +308,31 @@ public async Task CreateView_WithQueryObservable_FiltersBasedOnQuery() using var list = new QuaternaryList(); list.AddRange([TestData.AppleText, TestData.BananaText, TestData.ApricotText, TestData.CherryText, "avocado"]); - var searchQuery = new BehaviorSignal(string.Empty); + using var searchQuery = new BehaviorSignal(string.Empty); // Act using var view = list.CreateView( searchQuery, - (query, item) => string.IsNullOrEmpty(query) || item.StartsWith(query, StringComparison.OrdinalIgnoreCase), + static (query, item) => string.IsNullOrEmpty(query) || item.StartsWith(query, StringComparison.OrdinalIgnoreCase), Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Initial - all items - view.Items.Count.Should().Be(TestData.TestValueFive); + _ = view.Items.Count.Should().Be(TestData.TestValueFive); // Search for "a" searchQuery.OnNext("a"); await Task.Delay(TestData.TestValueOneHundred); - view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText, "avocado"]); + _ = view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText, "avocado"]); // Search for "ap" searchQuery.OnNext("ap"); await Task.Delay(TestData.TestValueOneHundred); - view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); + _ = view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); } /// Tests that CreateView with query observable updates when source changes. @@ -341,30 +344,30 @@ public async Task CreateView_WithQueryObservable_UpdatesWhenSourceChanges() using var list = new QuaternaryList(); list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); - var thresholdQuery = new BehaviorSignal(TestData.TestValueTwo); + using var thresholdQuery = new BehaviorSignal(TestData.TestValueTwo); using var view = list.CreateView( thresholdQuery, - (threshold, item) => item > threshold, + static (threshold, item) => item > threshold, Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo([TestData.TestValueThree]); + _ = view.Items.Should().BeEquivalentTo([TestData.TestValueThree]); // Act - add item that passes filter list.Add(TestData.TestValueFive); await Task.Delay(TestData.TestValueOneHundred); // Assert - view.Items.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFive]); + _ = view.Items.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFive]); // Act - change threshold thresholdQuery.OnNext(TestData.TestValueFour); await Task.Delay(TestData.TestValueOneHundred); // Assert - view.Items.Should().BeEquivalentTo([TestData.TestValueFive]); + _ = view.Items.Should().BeEquivalentTo([TestData.TestValueFive]); } #endif @@ -377,16 +380,22 @@ public void GroupByChanges_GroupsItemsByKeySelector() var groups = new Dictionary>(); using var subscription = list.Connect() - .GroupByChanges(x => x % TestData.TestValueTwo == 0 ? "even" : "odd") + .GroupByChanges(static x => x % TestData.TestValueTwo == 0 ? "even" : "odd") .Subscribe(group => { +#if NET8_0_OR_GREATER + ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(groups, group.Key, out _); + value ??= []; + _ = group.Subscribe(value.Add); +#else if (!groups.TryGetValue(group.Key, out var value)) { value = []; - groups[group.Key] = value; + groups.Add(group.Key, value); } - group.Subscribe(value.Add); + _ = group.Subscribe(value.Add); +#endif }); // Act @@ -396,10 +405,10 @@ public void GroupByChanges_GroupsItemsByKeySelector() list.Add(TestData.TestValueFour); // Assert - groups.Should().ContainKey("odd"); - groups.Should().ContainKey("even"); - groups["odd"].Should().BeEquivalentTo([1, TestData.TestValueThree]); - groups["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour]); + _ = groups.Should().ContainKey("odd"); + _ = groups.Should().ContainKey("even"); + _ = groups["odd"].Should().BeEquivalentTo([1, TestData.TestValueThree]); + _ = groups["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour]); } /// Tests that GroupByChanges handles string keys. @@ -411,16 +420,22 @@ public void GroupByChanges_HandlesStringKeys() var groups = new Dictionary>(); using var subscription = list.Connect() - .GroupByChanges(s => s[0]) + .GroupByChanges(static s => s[0]) .Subscribe(group => { +#if NET8_0_OR_GREATER + ref var value = ref CollectionsMarshal.GetValueRefOrAddDefault(groups, group.Key, out _); + value ??= []; + _ = group.Subscribe(value.Add); +#else if (!groups.TryGetValue(group.Key, out var value)) { value = []; - groups[group.Key] = value; + groups.Add(group.Key, value); } - group.Subscribe(value.Add); + _ = group.Subscribe(value.Add); +#endif }); // Act @@ -430,9 +445,9 @@ public void GroupByChanges_HandlesStringKeys() list.Add(TestData.CherryText); // Assert - groups['a'].Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); - groups['b'].Should().BeEquivalentTo([TestData.BananaText]); - groups['c'].Should().BeEquivalentTo([TestData.CherryText]); + _ = groups['a'].Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); + _ = groups['b'].Should().BeEquivalentTo([TestData.BananaText]); + _ = groups['c'].Should().BeEquivalentTo([TestData.CherryText]); } /// Tests that GroupingByChanges creates proper groupings. @@ -444,14 +459,14 @@ public void GroupingByChanges_CreatesProperGroupings() var groupings = new List>>(); using var subscription = list.Connect() - .GroupingByChanges(x => x % TestData.TestValueTwo == 0 ? "even" : "odd") + .GroupingByChanges(static x => x % TestData.TestValueTwo == 0 ? "even" : "odd") .Subscribe(groupings.Add); // Act - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour]); // Assert - each add creates a separate changeset, which creates groupings - groupings.Should().HaveCountGreaterThan(0); + _ = groupings.Should().HaveCountGreaterThan(0); } /// Tests that GroupingByChanges handles batch operations. @@ -463,18 +478,18 @@ public void GroupingByChanges_HandlesBatchAdd() var groupings = new List>>(); using var subscription = list.Connect() - .GroupingByChanges(x => x / TestData.TestValueTen) // Group by tens + .GroupingByChanges(static x => x / TestData.TestValueTen) // Group by tens .Subscribe(groupings.Add); // Act - add items in different decades - list.AddRange(new[] { TestData.TestValueFive, TestData.TestValueFifteen, TestData.TestValueTwentyFive, TestData.TestValueSeven, TestData.TestValueSeventeen }); + list.AddRange([TestData.TestValueFive, TestData.TestValueFifteen, TestData.TestValueTwentyFive, TestData.TestValueSeven, TestData.TestValueSeventeen]); // Assert - groupings.Should().HaveCountGreaterThan(0); - var keys = groupings.Select(g => g.Key).Distinct().ToList(); - keys.Should().Contain(0); // 5, 7 - keys.Should().Contain(1); // 15, 17 - keys.Should().Contain(TestData.TestValueTwo); // 25 + _ = groupings.Should().HaveCountGreaterThan(0); + var keys = GetDistinctKeys(groupings); + _ = keys.Should().Contain(0); // 5, 7 + _ = keys.Should().Contain(1); // 15, 17 + _ = keys.Should().Contain(TestData.TestValueTwo); // 25 } /// Tests that AutoRefresh emits refresh when property changes. @@ -497,7 +512,7 @@ public void AutoRefresh_EmitsRefreshWhenPropertyChanges() item.Name = "Updated"; // Assert - refreshCount.Should().Be(1); + _ = refreshCount.Should().Be(1); } /// Tests that AutoRefresh does not emit for unrelated property changes. @@ -520,7 +535,7 @@ public void AutoRefresh_DoesNotEmitForUnrelatedPropertyChanges() item.Value = TestData.TestValueOneHundred; // Change different property // Assert - refreshCount.Should().Be(0); + _ = refreshCount.Should().Be(0); } /// Tests that AutoRefresh without property name watches all property changes. @@ -544,7 +559,7 @@ public void AutoRefresh_WithoutPropertyName_WatchesAllProperties() item.Value = TestData.TestValueTwo; // Assert - should get refresh for both property changes - refreshCount.Should().Be(TestData.TestValueTwo); + _ = refreshCount.Should().Be(TestData.TestValueTwo); } /// Tests that Connect returns observable of change sets. @@ -564,8 +579,8 @@ public void Connect_ReturnsObservableOfChangeSets() list.Add(TestData.TestValueThree); // Assert - changeSets.Should().HaveCount(TestData.TestValueThree); - changeSets.SelectMany(cs => cs.Select(c => c.Current)).Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); + _ = changeSets.Should().HaveCount(TestData.TestValueThree); + _ = GetCurrentItems(changeSets).Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); } /// Tests that Connect throws for null source. @@ -577,7 +592,7 @@ public void Connect_ThrowsForNullSource() // Act & Assert var act = () => nullSource!.Connect(); - act.Should().Throw(); + _ = act.Should().Throw(); } /// Tests that WhereItems filters notifications by predicate. @@ -589,7 +604,7 @@ public void WhereItems_FiltersNotificationsByPredicate() var receivedItems = new List(); using var subscription = list.Stream - .WhereItems(x => x > TestData.TestValueFive) + .WhereItems(static x => x > TestData.TestValueFive) .Subscribe(notification => { if (notification.Action != CacheAction.Added) @@ -607,7 +622,7 @@ public void WhereItems_FiltersNotificationsByPredicate() list.Add(TestData.TestValueTen); // Assert - only items > 5 should be received - receivedItems.Should().BeEquivalentTo([TestData.TestValueSeven, TestData.TestValueTen]); + _ = receivedItems.Should().BeEquivalentTo([TestData.TestValueSeven, TestData.TestValueTen]); } /// Tests that WhereItems passes Cleared notifications. @@ -619,7 +634,7 @@ public void WhereItems_PassesClearedNotifications() var clearedReceived = false; using var subscription = list.Stream - .WhereItems(x => x.Length > 5) + .WhereItems(static x => x.Length > 5) .Subscribe(notification => { if (notification.Action != CacheAction.Cleared) @@ -631,11 +646,11 @@ public void WhereItems_PassesClearedNotifications() }); // Act - list.AddRange(new[] { "short", "longertext", "x" }); + list.AddRange(["short", "longertext", "x"]); list.Clear(); // Assert - clearedReceived.Should().BeTrue(); + _ = clearedReceived.Should().BeTrue(); } /// Tests that WhereItems passes BatchOperation notifications. @@ -647,11 +662,11 @@ public void WhereItems_PassesBatchOperations() var batchReceived = false; using var subscription = list.Stream - .WhereItems(x => x.Length > 5) + .WhereItems(static x => x.Length > 5) .Subscribe(notification => { - if (notification.Action != CacheAction.BatchAdded && - notification.Action != CacheAction.BatchOperation) + if (notification.Action != CacheAction.BatchAdded + && notification.Action != CacheAction.BatchOperation) { return; } @@ -660,10 +675,10 @@ public void WhereItems_PassesBatchOperations() }); // Act - list.AddRange(new[] { "short", "medium", "verylongtext", "x" }); + list.AddRange(["short", "medium", "verylongtext", "x"]); // Assert - batchReceived.Should().BeTrue(); + _ = batchReceived.Should().BeTrue(); } /// Tests that WhereItems correctly filters value types including zero. @@ -675,7 +690,7 @@ public void WhereItems_HandlesValueTypesIncludingZero() var receivedItems = new List(); using var subscription = list.Stream - .WhereItems(x => x >= 0) // Filter: all non-negative numbers including 0 + .WhereItems(static x => x >= 0) // Filter: all non-negative numbers including 0 .Subscribe(notification => { if (notification.Action != CacheAction.Added) @@ -694,7 +709,7 @@ public void WhereItems_HandlesValueTypesIncludingZero() list.Add(TestData.TestValueTen); // Should be included // Assert - 0 should be correctly included - receivedItems.Should().BeEquivalentTo([0, TestData.TestValueFive, TestData.TestValueTen]); + _ = receivedItems.Should().BeEquivalentTo([0, TestData.TestValueFive, TestData.TestValueTen]); } /// Tests that SortBy sorts change sets by key selector. @@ -706,7 +721,7 @@ public void SortBy_SortsChangeSetsByKeySelector() var sortedItems = new List(); using var subscription = list.Connect() - .SortBy(x => x) + .SortBy(static x => x) .Subscribe(cs => { sortedItems.Clear(); @@ -720,7 +735,7 @@ public void SortBy_SortsChangeSetsByKeySelector() list.AddRange([TestData.TestValueFive, 1, TestData.TestValueThree, TestData.TestValueTwo, TestData.TestValueFour]); // Assert - sortedItems.Should().BeInAscendingOrder(); + _ = sortedItems.Should().BeInAscendingOrder(); } /// Tests that SortBy handles string sorting. @@ -732,7 +747,7 @@ public void SortBy_HandlesStringSorting() var sortedItems = new List(); using var subscription = list.Connect() - .SortBy(s => s.Length) + .SortBy(static s => s.Length) .Subscribe(cs => { sortedItems.Clear(); @@ -746,7 +761,7 @@ public void SortBy_HandlesStringSorting() list.AddRange(["elephant", "cat", "dog", "bird"]); // Assert - sortedItems.Select(s => s.Length).Should().BeInAscendingOrder(); + _ = GetLengths(sortedItems).Should().BeInAscendingOrder(); } /// Tests that SelectChanges transforms to different type maintaining change metadata. @@ -758,7 +773,7 @@ public void SelectChanges_TransformsToDifferentType() var transformedSets = new List>(); using var subscription = list.Connect() - .SelectChanges((int x) => $"Value:{x}") + .SelectChanges(static (int x) => $"Value:{x}") .Subscribe(transformedSets.Add); // Act @@ -766,9 +781,9 @@ public void SelectChanges_TransformsToDifferentType() list.Add(TestData.TestValueTwo); // Assert - transformedSets.Should().HaveCount(TestData.TestValueTwo); - transformedSets[0][0].Current.Should().Be("Value:1"); - transformedSets[1][0].Current.Should().Be("Value:2"); + _ = transformedSets.Should().HaveCount(TestData.TestValueTwo); + _ = transformedSets[0][0].Current.Should().Be("Value:1"); + _ = transformedSets[1][0].Current.Should().Be("Value:2"); } /// Tests that SelectChanges preserves change reason. @@ -780,7 +795,7 @@ public void SelectChanges_PreservesChangeReason() var reasons = new List(); using var subscription = list.Connect() - .SelectChanges((int x) => x.ToString()) + .SelectChanges(static (int x) => x.ToString()) .Subscribe(cs => { foreach (var change in cs) @@ -792,17 +807,69 @@ public void SelectChanges_PreservesChangeReason() // Act - use Update method (indexer does Remove+Add, not Update) list.Add(1); list.Update(1, TestData.TestValueTwo); - list.Remove(TestData.TestValueTwo); + _ = list.Remove(TestData.TestValueTwo); // Assert - reasons.Should().Contain(ChangeReason.Add); - reasons.Should().Contain(ChangeReason.Update); - reasons.Should().Contain(ChangeReason.Remove); + _ = reasons.Should().Contain(ChangeReason.Add); + _ = reasons.Should().Contain(ChangeReason.Update); + _ = reasons.Should().Contain(ChangeReason.Remove); + } + + /// Collects distinct grouping keys without allocating a LINQ pipeline. + /// The grouping key type. + /// The grouping element type. + /// The groupings. + /// The distinct keys. + private static List GetDistinctKeys(IEnumerable> groupings) + { + var keys = new List(); + foreach (var grouping in groupings) + { + if (!keys.Contains(grouping.Key)) + { + keys.Add(grouping.Key); + } + } + + return keys; + } + + /// Collects the current items from change sets. + /// The item type. + /// The change sets. + /// The current items. + private static List GetCurrentItems(IEnumerable> changeSets) + { + var items = new List(); + foreach (var changeSet in changeSets) + { + foreach (var change in changeSet) + { + items.Add(change.Current); + } + } + + return items; + } + + /// Gets the lengths of the supplied strings. + /// The strings. + /// The string lengths. + private static List GetLengths(IEnumerable items) + { + var lengths = new List(); + foreach (var item in items) + { + lengths.Add(item.Length); + } + + return lengths; } /// Test class that implements INotifyPropertyChanged. private sealed class NotifyingItem : INotifyPropertyChanged { + /// public event PropertyChangedEventHandler? PropertyChanged; /// Gets or sets Value. diff --git a/src/ReactiveList.Test/ReactiveListExtensionsTests.cs b/src/ReactiveList.Test/ReactiveListExtensionsTests.cs index f962dd4..3be886d 100644 --- a/src/ReactiveList.Test/ReactiveListExtensionsTests.cs +++ b/src/ReactiveList.Test/ReactiveListExtensionsTests.cs @@ -26,7 +26,7 @@ public void WhereChanges_FiltersChangesByPredicate() var addedItems = new List(); using var subscription = list.Connect() - .WhereChanges(c => c.Current > TestData.TestValueFive) + .WhereChanges(static c => c.Current > TestData.TestValueFive) .Subscribe((Action>)(cs => { for (var i = 0; i < cs.Count; i++) @@ -42,7 +42,7 @@ public void WhereChanges_FiltersChangesByPredicate() list.Add(TestData.TestValueTen); // Assert - addedItems.Should().BeEquivalentTo([TestData.TestValueSeven, TestData.TestValueTen]); + _ = addedItems.Should().BeEquivalentTo([TestData.TestValueSeven, TestData.TestValueTen]); } /// Tests that WhereReason filters by specific change reason. @@ -60,10 +60,10 @@ public void WhereReason_FiltersAddOnly() // Act list.Add("one"); list.Add("two"); - list.Remove("one"); + _ = list.Remove("one"); // Assert - should see 2 adds, not the remove - addCount.Should().Be(TestData.TestValueTwo); + _ = addCount.Should().Be(TestData.TestValueTwo); } /// Tests that OnAdd returns only added items. @@ -84,7 +84,7 @@ public void OnAdd_ReturnsAddedItems() list.Add(TestData.TestValueThree); // Assert - addedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); + _ = addedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); } /// Tests that OnRemove returns only removed items. @@ -102,10 +102,10 @@ public void OnRemove_ReturnsRemovedItems() // Act list.Add(1); list.Add(TestData.TestValueTwo); - list.Remove(1); + _ = list.Remove(1); // Assert - removedItems.Should().BeEquivalentTo([1]); + _ = removedItems.Should().BeEquivalentTo([1]); } /// Tests that SelectChanges transforms items correctly using change selector. @@ -118,7 +118,7 @@ public void SelectChanges_TransformsItems() // Use the overload that takes Func, TResult> to get individual transformed items using var subscription = list.Connect() - .SelectChanges((c) => $"Item_{c.Current}") + .SelectChanges(static c => $"Item_{c.Current}") .Subscribe(transformedItems.Add); // Act @@ -127,7 +127,7 @@ public void SelectChanges_TransformsItems() list.Add(TestData.TestValueThree); // Assert - transformedItems.Should().BeEquivalentTo(["Item_1", "Item_2", "Item_3"]); + _ = transformedItems.Should().BeEquivalentTo(["Item_1", "Item_2", "Item_3"]); } #if NET6_0_OR_GREATER || NETFRAMEWORK @@ -138,17 +138,34 @@ public async Task CreateView_CreatesFilteredView() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix, TestData.TestValueSeven, TestData.TestValueEight, TestData.TestValueNine, TestData.TestValueTen }); + list.AddRange([ + 1, + TestData.TestValueTwo, + TestData.TestValueThree, + TestData.TestValueFour, + TestData.TestValueFive, + TestData.TestValueSix, + TestData.TestValueSeven, + TestData.TestValueEight, + TestData.TestValueNine, + TestData.TestValueTen, + ]); // Act - using var view = list.CreateView(x => x > TestData.TestValueFive, Sequencer.Immediate, 0); + using var view = list.CreateView(static x => x > TestData.TestValueFive, Sequencer.Immediate, 0); // Allow time for initial sync await Task.Delay(TestData.TestValueFifty); // Assert - view.Count.Should().Be(TestData.TestValueFive); - view.Should().BeEquivalentTo([TestData.TestValueSix, TestData.TestValueSeven, TestData.TestValueEight, TestData.TestValueNine, TestData.TestValueTen]); + _ = view.Count.Should().Be(TestData.TestValueFive); + _ = view.Should().BeEquivalentTo([ + TestData.TestValueSix, + TestData.TestValueSeven, + TestData.TestValueEight, + TestData.TestValueNine, + TestData.TestValueTen, + ]); } /// Tests that CreateView updates when source changes. @@ -158,9 +175,9 @@ public async Task CreateView_UpdatesOnSourceChange() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); - using var view = list.CreateView(x => x > 1, Sequencer.Immediate, 0); + using var view = list.CreateView(static x => x > 1, Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Act @@ -168,7 +185,7 @@ public async Task CreateView_UpdatesOnSourceChange() await Task.Delay(TestData.TestValueOneHundred); // Assert - view.Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFive]); + _ = view.Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFive]); } /// Tests that DynamicFilteredView updates when filter changes. @@ -178,21 +195,21 @@ public async Task DynamicCreateView_UpdatesOnFilterChange() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); - var filterSubject = new BehaviorSignal>(_ => true); + using var filterSubject = new BehaviorSignal>(static _ => true); using var view = list.CreateView(filterSubject, Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); - view.Count.Should().Be(TestData.TestValueFive); + _ = view.Count.Should().Be(TestData.TestValueFive); // Act - change filter - filterSubject.OnNext(x => x > TestData.TestValueThree); + filterSubject.OnNext(static x => x > TestData.TestValueThree); await Task.Delay(TestData.TestValueOneHundred); // Assert - view.Should().BeEquivalentTo([TestData.TestValueFour, TestData.TestValueFive]); + _ = view.Should().BeEquivalentTo([TestData.TestValueFour, TestData.TestValueFive]); } /// Tests that SortBy creates a sorted view. @@ -202,14 +219,23 @@ public async Task SortBy_CreatesSortedView() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { TestData.TestValueFive, TestData.TestValueTwo, TestData.TestValueEight, 1, TestData.TestValueNine, TestData.TestValueThree }); + list.AddRange([ + TestData.TestValueFive, + TestData.TestValueTwo, + TestData.TestValueEight, + 1, + TestData.TestValueNine, + TestData.TestValueThree, + ]); // Act - sort ascending using var view = list.SortBy(Comparer.Default, Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Assert - view.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFive, TestData.TestValueEight, TestData.TestValueNine], options => options.WithStrictOrdering()); + _ = view.Should().BeEquivalentTo( + [1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFive, TestData.TestValueEight, TestData.TestValueNine], + static options => options.WithStrictOrdering()); } /// Tests that SortBy with key selector creates a sorted view. @@ -219,15 +245,15 @@ public async Task SortBy_WithKeySelector_CreatesSortedView() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { "banana", "apple", "cherry" }); + list.AddRange(["banana", "apple", "cherry"]); // Act - sort by length - using var view = list.SortBy((s) => s.Length, scheduler: Sequencer.Immediate, throttleMs: 0); + using var view = list.SortBy(static s => s.Length, scheduler: Sequencer.Immediate, throttleMs: 0); await Task.Delay(TestData.TestValueFifty); // Assert (apple=5, cherry=6, banana=6 - but banana comes before cherry alphabetically when lengths equal) - view.Count.Should().Be(TestData.TestValueThree); - view[0].Should().Be("apple"); + _ = view.Count.Should().Be(TestData.TestValueThree); + _ = view[0].Should().Be("apple"); } /// Tests that GroupBy creates a grouped view. @@ -237,18 +263,25 @@ public async Task GroupBy_CreatesGroupedView() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix }); + list.AddRange([ + 1, + TestData.TestValueTwo, + TestData.TestValueThree, + TestData.TestValueFour, + TestData.TestValueFive, + TestData.TestValueSix, + ]); // Act - group by even/odd - using var view = list.GroupBy((x) => x % TestData.TestValueTwo == 0 ? "even" : "odd", Sequencer.Immediate, 0); + using var view = list.GroupBy(static x => x % TestData.TestValueTwo == 0 ? "even" : "odd", Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Assert - view.Count.Should().Be(TestData.TestValueTwo); - view.ContainsKey("odd").Should().BeTrue(); - view.ContainsKey("even").Should().BeTrue(); - view["odd"].Should().BeEquivalentTo([1, TestData.TestValueThree, TestData.TestValueFive]); - view["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour, TestData.TestValueSix]); + _ = view.Count.Should().Be(TestData.TestValueTwo); + _ = view.ContainsKey("odd").Should().BeTrue(); + _ = view.ContainsKey("even").Should().BeTrue(); + _ = view["odd"].Should().BeEquivalentTo([1, TestData.TestValueThree, TestData.TestValueFive]); + _ = view["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour, TestData.TestValueSix]); } /// Tests that GroupBy updates when items are added. @@ -258,9 +291,9 @@ public async Task GroupBy_UpdatesOnAdd() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); - using var view = list.GroupBy((x) => x % TestData.TestValueTwo == 0 ? "even" : "odd", Sequencer.Immediate, 0); + using var view = list.GroupBy(static x => x % TestData.TestValueTwo == 0 ? "even" : "odd", Sequencer.Immediate, 0); await Task.Delay(TestData.TestValueFifty); // Act @@ -268,7 +301,7 @@ public async Task GroupBy_UpdatesOnAdd() await Task.Delay(TestData.TestValueOneHundred); // Assert - view["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour]); + _ = view["even"].Should().BeEquivalentTo([TestData.TestValueTwo, TestData.TestValueFour]); } /// Tests that AddRange with ReadOnlySpan works correctly. @@ -283,8 +316,8 @@ public void AddRange_WithSpan_AddsItems() list.AddRange(items); // Assert - list.Count.Should().Be(TestData.TestValueFive); - list.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = list.Count.Should().Be(TestData.TestValueFive); + _ = list.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// Tests that CopyTo with Span works correctly. @@ -293,14 +326,14 @@ public void CopyTo_WithSpan_CopiesItems() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); Span destination = stackalloc int[5]; // Act list.CopyTo(destination); // Assert - destination.ToArray().Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = destination.ToArray().Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// Tests that AsSpan returns correct data. @@ -309,16 +342,16 @@ public void AsSpan_ReturnsItems() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); // Act var span = list.AsSpan(); // Assert - span.Length.Should().Be(TestData.TestValueThree); - span[0].Should().Be(1); - span[1].Should().Be(TestData.TestValueTwo); - span[TestData.TestValueTwo].Should().Be(TestData.TestValueThree); + _ = span.Length.Should().Be(TestData.TestValueThree); + _ = span[0].Should().Be(1); + _ = span[1].Should().Be(TestData.TestValueTwo); + _ = span[TestData.TestValueTwo].Should().Be(TestData.TestValueThree); } /// Tests that AsMemory returns correct data. @@ -327,16 +360,16 @@ public void AsMemory_ReturnsItems() { // Arrange using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestData.TestValueTwo, TestData.TestValueThree }); + list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); // Act var memory = list.AsMemory(); // Assert - memory.Length.Should().Be(TestData.TestValueThree); - memory.Span[0].Should().Be(1); - memory.Span[1].Should().Be(TestData.TestValueTwo); - memory.Span[TestData.TestValueTwo].Should().Be(TestData.TestValueThree); + _ = memory.Length.Should().Be(TestData.TestValueThree); + _ = memory.Span[0].Should().Be(1); + _ = memory.Span[1].Should().Be(TestData.TestValueTwo); + _ = memory.Span[TestData.TestValueTwo].Should().Be(TestData.TestValueThree); } #endif } diff --git a/src/ReactiveList.Test/ReactiveListMoveTests.cs b/src/ReactiveList.Test/ReactiveListMoveTests.cs index b2a047a..970d140 100644 --- a/src/ReactiveList.Test/ReactiveListMoveTests.cs +++ b/src/ReactiveList.Test/ReactiveListMoveTests.cs @@ -20,11 +20,11 @@ public void Move_ShouldReorderItemForwardInList() fixture.Move(0, TestData.TestValueTwo); - fixture.Count.Should().Be(TestData.TestValueFour); - fixture[0].Should().Be("two"); - fixture[1].Should().Be(TestData.ThreeText); - fixture[TestData.TestValueTwo].Should().Be("one"); - fixture[TestData.TestValueThree].Should().Be("four"); + _ = fixture.Count.Should().Be(TestData.TestValueFour); + _ = fixture[0].Should().Be("two"); + _ = fixture[1].Should().Be(TestData.ThreeText); + _ = fixture[TestData.TestValueTwo].Should().Be("one"); + _ = fixture[TestData.TestValueThree].Should().Be("four"); } /// Move should reorder item backward in list. @@ -35,11 +35,11 @@ public void Move_ShouldReorderItemBackwardInList() fixture.Move(TestData.TestValueThree, 1); - fixture.Count.Should().Be(TestData.TestValueFour); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("four"); - fixture[TestData.TestValueTwo].Should().Be("two"); - fixture[TestData.TestValueThree].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueFour); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("four"); + _ = fixture[TestData.TestValueTwo].Should().Be("two"); + _ = fixture[TestData.TestValueThree].Should().Be(TestData.ThreeText); } /// Move should handle moving to first position. @@ -50,10 +50,10 @@ public void Move_ShouldHandleMovingToFirstPosition() fixture.Move(TestData.TestValueTwo, 0); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be(TestData.ThreeText); - fixture[1].Should().Be("one"); - fixture[TestData.TestValueTwo].Should().Be("two"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be(TestData.ThreeText); + _ = fixture[1].Should().Be("one"); + _ = fixture[TestData.TestValueTwo].Should().Be("two"); } /// Move should handle moving to last position. @@ -64,10 +64,10 @@ public void Move_ShouldHandleMovingToLastPosition() fixture.Move(0, TestData.TestValueTwo); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("two"); - fixture[1].Should().Be(TestData.ThreeText); - fixture[TestData.TestValueTwo].Should().Be("one"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("two"); + _ = fixture[1].Should().Be(TestData.ThreeText); + _ = fixture[TestData.TestValueTwo].Should().Be("one"); } /// Move should do nothing when same index. @@ -78,10 +78,10 @@ public void Move_ShouldDoNothingWhenSameIndex() fixture.Move(1, 1); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("one"); - fixture[1].Should().Be("two"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be("two"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } /// Move should throw when old index is negative. @@ -92,7 +92,7 @@ public void Move_ShouldThrowWhenOldIndexIsNegative() var action = () => fixture.Move(-1, 1); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("oldIndex"); } @@ -104,7 +104,7 @@ public void Move_ShouldThrowWhenOldIndexExceedsCount() var action = () => fixture.Move(TestData.TestValueThree, 1); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("oldIndex"); } @@ -116,7 +116,7 @@ public void Move_ShouldThrowWhenNewIndexIsNegative() var action = () => fixture.Move(1, -1); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("newIndex"); } @@ -128,7 +128,7 @@ public void Move_ShouldThrowWhenNewIndexExceedsCount() var action = () => fixture.Move(1, TestData.TestValueThree); - action.Should().Throw() + _ = action.Should().Throw() .WithParameterName("newIndex"); } @@ -142,7 +142,7 @@ public void Move_ShouldRaisePropertyChangedForItemArray() fixture.Move(0, TestData.TestValueTwo); - propertyNames.Should().Contain("Item[]"); + _ = propertyNames.Should().Contain("Item[]"); } /// Move should work with complex types. @@ -158,10 +158,10 @@ public void Move_ShouldWorkWithComplexTypes() fixture.Move(TestData.TestValueTwo, 0); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Name.Should().Be("Charlie"); - fixture[1].Name.Should().Be("Alice"); - fixture[TestData.TestValueTwo].Name.Should().Be("Bob"); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Name.Should().Be("Charlie"); + _ = fixture[1].Name.Should().Be("Alice"); + _ = fixture[TestData.TestValueTwo].Name.Should().Be("Bob"); } /// Move should handle adjacent positions forward. @@ -172,10 +172,10 @@ public void Move_ShouldHandleAdjacentPositionsForward() fixture.Move(0, 1); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("two"); - fixture[1].Should().Be("one"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("two"); + _ = fixture[1].Should().Be("one"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } /// Move should handle adjacent positions backward. @@ -186,9 +186,9 @@ public void Move_ShouldHandleAdjacentPositionsBackward() fixture.Move(1, 0); - fixture.Count.Should().Be(TestData.TestValueThree); - fixture[0].Should().Be("two"); - fixture[1].Should().Be("one"); - fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueThree); + _ = fixture[0].Should().Be("two"); + _ = fixture[1].Should().Be("one"); + _ = fixture[TestData.TestValueTwo].Should().Be(TestData.ThreeText); } } diff --git a/src/ReactiveList.Test/ReactiveListNotificationComplianceTests.cs b/src/ReactiveList.Test/ReactiveListNotificationComplianceTests.cs index ba75a19..b57f7d6 100644 --- a/src/ReactiveList.Test/ReactiveListNotificationComplianceTests.cs +++ b/src/ReactiveList.Test/ReactiveListNotificationComplianceTests.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; -using System.Linq; using System.Threading; using System.Threading.Tasks; using CP.Primitives.Collections; @@ -31,14 +30,26 @@ public void IndexerSet_ShouldEmitSingleReplaceAndNoCountPropertyChange() list[1] = TestData.TestValueTwenty; - list.Should().Equal(1, TestData.TestValueTwenty, TestData.TestValueThree); - collectionEvents.Should().ContainSingle(); - collectionEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Replace); + _ = list.Should().Equal(1, TestData.TestValueTwenty, TestData.TestValueThree); + _ = collectionEvents.Should().ContainSingle(); + _ = collectionEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Replace); var oldItems = collectionEvents[0].OldItems ?? throw new InvalidOperationException("Replace notification did not include old items."); var newItems = collectionEvents[0].NewItems ?? throw new InvalidOperationException("Replace notification did not include new items."); - oldItems.Cast().Should().Equal(TestData.TestValueTwo); - newItems.Cast().Should().Equal(TestData.TestValueTwenty); - propertyNames.Should().Equal(TestData.IndexerPropertyName); + var oldValues = new List(oldItems.Count); + foreach (var item in oldItems) + { + oldValues.Add((int)item!); + } + + var newValues = new List(newItems.Count); + foreach (var item in newItems) + { + newValues.Add((int)item!); + } + + _ = oldValues.Should().Equal(TestData.TestValueTwo); + _ = newValues.Should().Equal(TestData.TestValueTwenty); + _ = propertyNames.Should().Equal(TestData.IndexerPropertyName); } /// Bulk operations on the UI-facing Items collection should coalesce to one collection notification. @@ -52,18 +63,18 @@ public void BulkOperations_ShouldRaiseSingleItemsCollectionChangedNotification() var values = new[] { 1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour }; list.AddRange(values.AsSpan()); - itemEvents.Should().ContainSingle(); - itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); + _ = itemEvents.Should().ContainSingle(); + _ = itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); itemEvents.Clear(); list.Remove([1, TestData.TestValueThree]); - itemEvents.Should().ContainSingle(); - itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); + _ = itemEvents.Should().ContainSingle(); + _ = itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); itemEvents.Clear(); - list.RemoveMany(static item => item > 0); - itemEvents.Should().ContainSingle(); - itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); + _ = list.RemoveMany(static item => item > 0); + _ = itemEvents.Should().ContainSingle(); + _ = itemEvents[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); } /// ReplaceAll to empty should not suppress tracking for the following notification. @@ -72,14 +83,14 @@ public void ReplaceAllToEmpty_ShouldNotSuppressNextNotification() { using var list = new ReactiveList(["seed"]); var snapshots = new List(); - using var subscription = list.CurrentItems.Subscribe(items => snapshots.Add(items.ToArray())); + using var subscription = list.CurrentItems.Subscribe(items => AddSnapshot(snapshots, items)); list.ReplaceAll([]); list.Add("next"); - list.ItemsAdded.Should().Equal("next"); - list.ItemsChanged.Should().Equal("next"); - snapshots[snapshots.Count - 1].Should().Equal("next"); + _ = list.ItemsAdded.Should().Equal("next"); + _ = list.ItemsChanged.Should().Equal("next"); + _ = snapshots[snapshots.Count - 1].Should().Equal("next"); } /// Dynamic views should not block construction when no initial filter has been published. @@ -95,9 +106,9 @@ public void DynamicReactiveView_WithColdFilterSubject_ShouldConstructImmediately TimeSpan.Zero, Sequencer.Immediate); - view.Items.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); + _ = view.Items.Should().Equal(1, TestData.TestValueTwo, TestData.TestValueThree); filters.OnNext(static item => item > 1); - view.Items.Should().Equal(TestData.TestValueTwo, TestData.TestValueThree); + _ = view.Items.Should().Equal(TestData.TestValueTwo, TestData.TestValueThree); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -120,10 +131,10 @@ public async Task DynamicSecondaryIndexView_WithColdKeysSubject_ShouldConstructI Sequencer.Immediate, TimeSpan.Zero); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); keys.OnNext(["north"]); await Task.Delay(TestData.TestValueTwentyFive); - view.Items.Should().ContainSingle().Which.Should().Be(north); + _ = view.Items.Should().ContainSingle().Which.Should().Be(north); } /// Quaternary collections should raise INPC notifications for UI-bound count/indexer properties. @@ -136,8 +147,8 @@ public void QuaternaryCollections_ShouldRaisePropertyChangedForMutations() list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); - listProperties.Should().Contain(nameof(list.Count)); - listProperties.Should().Contain(TestData.IndexerPropertyName); + _ = listProperties.Should().Contain(nameof(list.Count)); + _ = listProperties.Should().Contain(TestData.IndexerPropertyName); using var dictionary = new QuaternaryDictionary(); var dictionaryProperties = new List(); @@ -145,8 +156,8 @@ public void QuaternaryCollections_ShouldRaisePropertyChangedForMutations() dictionary.AddRange([new KeyValuePair(1, "one")]); - dictionaryProperties.Should().Contain(nameof(dictionary.Count)); - dictionaryProperties.Should().Contain(TestData.IndexerPropertyName); + _ = dictionaryProperties.Should().Contain(nameof(dictionary.Count)); + _ = dictionaryProperties.Should().Contain(TestData.IndexerPropertyName); } /// Optimized quaternary list range removal should preserve multiset semantics for duplicate values. @@ -158,8 +169,8 @@ public void QuaternaryList_RemoveRange_ShouldRemoveOnlyRequestedDuplicateCount() list.RemoveRange([1, 1, TestData.TestValueFour]); - list.Count.Should().Be(TestData.TestValueThree); - list.ToArray().Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); + _ = list.Count.Should().Be(TestData.TestValueThree); + _ = list.ToArray().Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree]); } /// Dictionary range operations should keep count exact for overwrites and no-op removals. @@ -169,9 +180,10 @@ public void QuaternaryDictionary_RangeOperations_ShouldMaintainCountAndSkipNoOpR using var dictionary = new QuaternaryDictionary(); var notifications = 0; using var received = new ManualResetEventSlim(); - using var subscription = dictionary.Stream.Subscribe(_ => + using var subscription = dictionary.Stream.Subscribe(notification => { - Interlocked.Increment(ref notifications); + _ = notification; + _ = Interlocked.Increment(ref notifications); received.Set(); }); @@ -182,24 +194,33 @@ public void QuaternaryDictionary_RangeOperations_ShouldMaintainCountAndSkipNoOpR new KeyValuePair(TestData.TestValueTwo, "two") ]); - dictionary.Count.Should().Be(TestData.TestValueTwo); - received.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - notifications.Should().Be(1); + _ = dictionary.Count.Should().Be(TestData.TestValueTwo); + _ = received.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = notifications.Should().Be(1); received.Reset(); dictionary.RemoveKeys([TestData.TestValueNinetyNine]); - dictionary.Count.Should().Be(TestData.TestValueTwo); - received.Wait(TimeSpan.FromMilliseconds(TestData.TestValueFifty)).Should().BeFalse(); - notifications.Should().Be(1); + _ = dictionary.Count.Should().Be(TestData.TestValueTwo); + _ = received.Wait(TimeSpan.FromMilliseconds(TestData.TestValueFifty)).Should().BeFalse(); + _ = notifications.Should().Be(1); received.Reset(); dictionary.RemoveKeys([1]); - dictionary.Count.Should().Be(1); - received.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); - notifications.Should().Be(TestData.TestValueTwo); + _ = dictionary.Count.Should().Be(1); + _ = received.Wait(TimeSpan.FromSeconds(1)).Should().BeTrue(); + _ = notifications.Should().Be(TestData.TestValueTwo); } #endif + /// Adds an explicit snapshot without LINQ allocation overhead. + /// The destination snapshot collection. + /// The items to snapshot. + private static void AddSnapshot(List snapshots, IEnumerable items) + { + var snapshot = new List(items); + snapshots.Add([.. snapshot]); + } + /// Provides IndexedItem. /// The Id value. /// The Region value. diff --git a/src/ReactiveList.Test/ReactiveListRemoveTests.cs b/src/ReactiveList.Test/ReactiveListRemoveTests.cs index 8466947..84da0bb 100644 --- a/src/ReactiveList.Test/ReactiveListRemoveTests.cs +++ b/src/ReactiveList.Test/ReactiveListRemoveTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System; +using System.Collections.Generic; using CP.Primitives; using CP.Primitives.Collections; using CP.Primitives.Core; @@ -22,11 +23,11 @@ public void Remove_ShouldRemoveExistingItem_String() var result = fixture.Remove("two"); - result.Should().BeTrue(); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Should().Contain("one"); - fixture.Should().Contain(TestData.ThreeText); - fixture.Should().NotContain("two"); + _ = result.Should().BeTrue(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Should().Contain("one"); + _ = fixture.Should().Contain(TestData.ThreeText); + _ = fixture.Should().NotContain("two"); } /// Remove should return false for non-existing item for string type. @@ -37,8 +38,8 @@ public void Remove_ShouldReturnFalseForNonExistingItem_String() var result = fixture.Remove(TestData.ThreeText); - result.Should().BeFalse(); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = result.Should().BeFalse(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); } /// Remove should raise property changed for string type. @@ -63,10 +64,10 @@ public void Remove_ShouldRaisePropertyChanged_String() itemArrayChanges++; }; - fixture.Remove("two"); + _ = fixture.Remove("two"); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// Remove should remove existing item for int type. @@ -77,11 +78,11 @@ public void Remove_ShouldRemoveExistingItem_Int() var result = fixture.Remove(TestData.TestValueTwo); - result.Should().BeTrue(); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Should().Contain(1); - fixture.Should().Contain(TestData.TestValueThree); - fixture.Should().NotContain(TestData.TestValueTwo); + _ = result.Should().BeTrue(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Should().Contain(1); + _ = fixture.Should().Contain(TestData.TestValueThree); + _ = fixture.Should().NotContain(TestData.TestValueTwo); } /// Remove should return false for non-existing item for int type. @@ -92,8 +93,8 @@ public void Remove_ShouldReturnFalseForNonExistingItem_Int() var result = fixture.Remove(TestData.TestValueThree); - result.Should().BeFalse(); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = result.Should().BeFalse(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); } /// Remove should raise property changed for int type. @@ -118,10 +119,10 @@ public void Remove_ShouldRaisePropertyChanged_Int() itemArrayChanges++; }; - fixture.Remove(TestData.TestValueTwo); + _ = fixture.Remove(TestData.TestValueTwo); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// Remove should remove existing item for TestData type. @@ -132,11 +133,11 @@ public void Remove_ShouldRemoveExistingItem_TestData() var itemToRemove = fixture[1]; var result = fixture.Remove(itemToRemove); - result.Should().BeTrue(); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Should().Contain(d => d.Name == TestData.AliceName); - fixture.Should().Contain(d => d.Name == TestData.CharlieName); - fixture.Should().NotContain(d => d.Name == "Bob"); + _ = result.Should().BeTrue(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Should().Contain(static d => d.Name == TestData.AliceName); + _ = fixture.Should().Contain(static d => d.Name == TestData.CharlieName); + _ = fixture.Should().NotContain(static d => d.Name == "Bob"); } /// Remove should return false for non-existing item for TestData type. @@ -147,8 +148,8 @@ public void Remove_ShouldReturnFalseForNonExistingItem_TestData() var result = fixture.Remove(new TestData(TestData.CharlieName, TestData.TestValueThirtyFive)); - result.Should().BeFalse(); - fixture.Count.Should().Be(TestData.TestValueTwo); + _ = result.Should().BeFalse(); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); } /// Remove should raise property changed for TestData type. @@ -174,10 +175,10 @@ public void Remove_ShouldRaisePropertyChanged_TestData() }; var itemToRemove = fixture[1]; - fixture.Remove(itemToRemove); + _ = fixture.Remove(itemToRemove); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// RemoveAt should remove item at index for string type. @@ -188,9 +189,9 @@ public void RemoveAt_ShouldRemoveItemAtIndex_String() fixture.RemoveAt(1); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be("one"); - fixture[1].Should().Be(TestData.ThreeText); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be("one"); + _ = fixture[1].Should().Be(TestData.ThreeText); } /// RemoveAt should throw for invalid index for string type. @@ -201,7 +202,7 @@ public void RemoveAt_ShouldThrowForInvalidIndex_String() var action = () => fixture.RemoveAt(TestData.TestValueFive); - action.Should().Throw(); + _ = action.Should().Throw(); } /// RemoveAt should raise property changed for string type. @@ -228,8 +229,8 @@ public void RemoveAt_ShouldRaisePropertyChanged_String() fixture.RemoveAt(1); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// RemoveAt should remove item at index for int type. @@ -240,9 +241,9 @@ public void RemoveAt_ShouldRemoveItemAtIndex_Int() fixture.RemoveAt(1); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Should().Be(1); - fixture[1].Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Should().Be(1); + _ = fixture[1].Should().Be(TestData.TestValueThree); } /// RemoveAt should throw for invalid index for int type. @@ -253,7 +254,7 @@ public void RemoveAt_ShouldThrowForInvalidIndex_Int() var action = () => fixture.RemoveAt(TestData.TestValueFive); - action.Should().Throw(); + _ = action.Should().Throw(); } /// RemoveAt should raise property changed for int type. @@ -280,8 +281,8 @@ public void RemoveAt_ShouldRaisePropertyChanged_Int() fixture.RemoveAt(1); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// RemoveAt should remove item at index for TestData type. @@ -292,9 +293,9 @@ public void RemoveAt_ShouldRemoveItemAtIndex_TestData() fixture.RemoveAt(1); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture[0].Name.Should().Be(TestData.AliceName); - fixture[1].Name.Should().Be(TestData.CharlieName); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture[0].Name.Should().Be(TestData.AliceName); + _ = fixture[1].Name.Should().Be(TestData.CharlieName); } /// RemoveAt should throw for invalid index for TestData type. @@ -305,7 +306,7 @@ public void RemoveAt_ShouldThrowForInvalidIndex_TestData() var action = () => fixture.RemoveAt(TestData.TestValueFive); - action.Should().Throw(); + _ = action.Should().Throw(); } /// RemoveAt should raise property changed for TestData type. @@ -332,8 +333,8 @@ public void RemoveAt_ShouldRaisePropertyChanged_TestData() fixture.RemoveAt(1); - countChanges.Should().Be(1); - itemArrayChanges.Should().Be(1); + _ = countChanges.Should().Be(1); + _ = itemArrayChanges.Should().Be(1); } /// RemoveMany should remove items matching predicate for string type. @@ -342,15 +343,15 @@ public void RemoveMany_ShouldRemoveMatchingItems_String() { ReactiveList fixture = ["apple", "banana", "apricot", "cherry", "avocado"]; - var removed = fixture.RemoveMany(s => s.StartsWith("a")); + var removed = fixture.RemoveMany(static s => s.Length > 0 && s[0] == 'a'); - removed.Should().Be(TestData.TestValueThree); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Should().Contain("banana"); - fixture.Should().Contain("cherry"); - fixture.Should().NotContain("apple"); - fixture.Should().NotContain("apricot"); - fixture.Should().NotContain("avocado"); + _ = removed.Should().Be(TestData.TestValueThree); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Should().Contain("banana"); + _ = fixture.Should().Contain("cherry"); + _ = fixture.Should().NotContain("apple"); + _ = fixture.Should().NotContain("apricot"); + _ = fixture.Should().NotContain("avocado"); } /// RemoveMany should return zero when no items match predicate. @@ -359,10 +360,10 @@ public void RemoveMany_ShouldReturnZeroWhenNoMatch() { ReactiveList fixture = ["one", "two", TestData.ThreeText]; - var removed = fixture.RemoveMany(s => s.StartsWith("z")); + var removed = fixture.RemoveMany(static s => s.Length > 0 && s[0] == 'z'); - removed.Should().Be(0); - fixture.Count.Should().Be(TestData.TestValueThree); + _ = removed.Should().Be(0); + _ = fixture.Count.Should().Be(TestData.TestValueThree); } /// RemoveMany should throw ArgumentNullException for null predicate. @@ -373,14 +374,26 @@ public void RemoveMany_ShouldThrowForNullPredicate() var action = () => fixture.RemoveMany(null!); - action.Should().Throw(); + _ = action.Should().Throw(); } /// RemoveMany should raise property changed events. [Test] public void RemoveMany_ShouldRaisePropertyChanged() { - ReactiveList fixture = [1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive, TestData.TestValueSix, TestData.TestValueSeven, TestData.TestValueEight, TestData.TestValueNine, TestData.TestValueTen]; + ReactiveList fixture = + [ + 1, + TestData.TestValueTwo, + TestData.TestValueThree, + TestData.TestValueFour, + TestData.TestValueFive, + TestData.TestValueSix, + TestData.TestValueSeven, + TestData.TestValueEight, + TestData.TestValueNine, + TestData.TestValueTen + ]; var countChanges = 0; fixture.PropertyChanged += (_, args) => { @@ -392,11 +405,11 @@ public void RemoveMany_ShouldRaisePropertyChanged() countChanges++; }; - var removed = fixture.RemoveMany(x => x % TestData.TestValueTwo == 0); + var removed = fixture.RemoveMany(static x => x % TestData.TestValueTwo == 0); - removed.Should().Be(TestData.TestValueFive); - countChanges.Should().Be(1); - fixture.Should().BeEquivalentTo([1, TestData.TestValueThree, TestData.TestValueFive, TestData.TestValueSeven, TestData.TestValueNine]); + _ = removed.Should().Be(TestData.TestValueFive); + _ = countChanges.Should().Be(1); + _ = fixture.Should().BeEquivalentTo([1, TestData.TestValueThree, TestData.TestValueFive, TestData.TestValueSeven, TestData.TestValueNine]); } /// RemoveMany should emit change notification via Connect. @@ -404,15 +417,15 @@ public void RemoveMany_ShouldRaisePropertyChanged() public void RemoveMany_ShouldEmitChangeNotification() { using var fixture = new ReactiveList([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); - var receivedChanges = new System.Collections.Generic.List>(); + var receivedChanges = new List>(); using var subscription = fixture.Connect().Subscribe(receivedChanges.Add); receivedChanges.Clear(); - var removed = fixture.RemoveMany(x => x > TestData.TestValueThree); + var removed = fixture.RemoveMany(static x => x > TestData.TestValueThree); - removed.Should().Be(TestData.TestValueTwo); - receivedChanges.Should().HaveCount(1); - receivedChanges[0].Removes.Should().Be(TestData.TestValueTwo); + _ = removed.Should().Be(TestData.TestValueTwo); + _ = receivedChanges.Should().HaveCount(1); + _ = receivedChanges[0].Removes.Should().Be(TestData.TestValueTwo); } /// RemoveMany should work with complex types. @@ -427,11 +440,11 @@ public void RemoveMany_ShouldWorkWithComplexTypes() new("Diana", TestData.TestValueForty) ]; - var removed = fixture.RemoveMany(p => p.Age >= TestData.TestValueThirtyFive); + var removed = fixture.RemoveMany(static p => p.Age >= TestData.TestValueThirtyFive); - removed.Should().Be(TestData.TestValueTwo); - fixture.Count.Should().Be(TestData.TestValueTwo); - fixture.Should().Contain(p => p.Name == TestData.AliceName); - fixture.Should().Contain(p => p.Name == "Bob"); + _ = removed.Should().Be(TestData.TestValueTwo); + _ = fixture.Count.Should().Be(TestData.TestValueTwo); + _ = fixture.Should().Contain(static p => p.Name == TestData.AliceName); + _ = fixture.Should().Contain(static p => p.Name == "Bob"); } } diff --git a/src/ReactiveList.Test/ReactiveListSerializationTests.cs b/src/ReactiveList.Test/ReactiveListSerializationTests.cs index 19c4716..ce87a06 100644 --- a/src/ReactiveList.Test/ReactiveListSerializationTests.cs +++ b/src/ReactiveList.Test/ReactiveListSerializationTests.cs @@ -1,10 +1,9 @@ // Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved. // Chris Pulman and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#if NET48 + using System; using System.IO; -using System.Linq; using System.Runtime.Serialization; using CP.Primitives.Collections; using FluentAssertions; @@ -25,10 +24,10 @@ public void ReactiveList_ShouldBeSerializable() var deserialized = RoundTrip(list); - deserialized.Count.Should().Be(TestValueThree); - deserialized[0].Should().Be("one"); - deserialized[1].Should().Be("two"); - deserialized[TestValueTwo].Should().Be("three"); + _ = deserialized.Count.Should().Be(TestValueThree); + _ = deserialized[0].Should().Be("one"); + _ = deserialized[1].Should().Be("two"); + _ = deserialized[TestValueTwo].Should().Be("three"); } /// Deserialized ReactiveList should work normally. @@ -42,17 +41,17 @@ public void DeserializedReactiveList_ShouldWorkNormally() // Test that we can add items deserialized.Add(TestValueFour); - deserialized.Count.Should().Be(TestValueFour); + _ = deserialized.Count.Should().Be(TestValueFour); // Test that Items property works - deserialized.Items.Should().BeEquivalentTo([1, TestValueTwo, TestValueThree, TestValueFour]); + _ = deserialized.Items.Should().BeEquivalentTo([1, TestValueTwo, TestValueThree, TestValueFour]); // Test that observables work var addedItems = Array.Empty(); - deserialized.Added.Subscribe(items => addedItems = items.ToArray()); + using var subscription = deserialized.Added.Subscribe(items => addedItems = [.. items]); deserialized.Add(TestValueFive); - addedItems.Should().BeEquivalentTo([TestValueFive]); + _ = addedItems.Should().BeEquivalentTo([TestValueFive]); } /// Deserialized ReactiveList should support remove operations. @@ -64,9 +63,9 @@ public void DeserializedReactiveList_ShouldSupportRemoveOperations() var deserialized = RoundTrip(list); - deserialized.Remove("b"); - deserialized.Count.Should().Be(TestValueTwo); - deserialized.Items.Should().BeEquivalentTo(["a", "c"]); + _ = deserialized.Remove("b"); + _ = deserialized.Count.Should().Be(TestValueTwo); + _ = deserialized.Items.Should().BeEquivalentTo(["a", "c"]); } /// Deserialized ReactiveList should support clear operations. @@ -79,8 +78,8 @@ public void DeserializedReactiveList_ShouldSupportClearOperations() var deserialized = RoundTrip(list); deserialized.Clear(); - deserialized.Count.Should().Be(0); - deserialized.Items.Should().BeEmpty(); + _ = deserialized.Count.Should().Be(0); + _ = deserialized.Items.Should().BeEmpty(); } /// Empty ReactiveList should be serializable. @@ -91,27 +90,23 @@ public void EmptyReactiveList_ShouldBeSerializable() var deserialized = RoundTrip(list); - deserialized.Count.Should().Be(0); - deserialized.Items.Should().BeEmpty(); + _ = deserialized.Count.Should().Be(0); + _ = deserialized.Items.Should().BeEmpty(); } /// ReactiveList with complex types should be serializable. [Test] public void ReactiveListWithComplexTypes_ShouldBeSerializable() { - var list = new ReactiveList - { - new("Alice", TestValueThirty), - new("Bob", TestValueTwentyFive) - }; + var list = new ReactiveList { new("Alice", TestValueThirty), new("Bob", TestValueTwentyFive) }; var deserialized = RoundTrip(list); - deserialized.Count.Should().Be(TestValueTwo); - deserialized[0].Name.Should().Be("Alice"); - deserialized[0].Age.Should().Be(TestValueThirty); - deserialized[1].Name.Should().Be("Bob"); - deserialized[1].Age.Should().Be(TestValueTwentyFive); + _ = deserialized.Count.Should().Be(TestValueTwo); + _ = deserialized[0].Name.Should().Be("Alice"); + _ = deserialized[0].Age.Should().Be(TestValueThirty); + _ = deserialized[1].Name.Should().Be("Bob"); + _ = deserialized[1].Age.Should().Be(TestValueTwentyFive); } /// Round-trips a value through the .NET Framework serializer. @@ -134,4 +129,3 @@ private static T RoundTrip(T value) throw new InvalidOperationException($"Expected a deserialized {typeof(T).FullName} instance."); } } -#endif diff --git a/src/ReactiveList.Test/ReactiveListVersionTests.cs b/src/ReactiveList.Test/ReactiveListVersionTests.cs index 9103784..7e1b3f7 100644 --- a/src/ReactiveList.Test/ReactiveListVersionTests.cs +++ b/src/ReactiveList.Test/ReactiveListVersionTests.cs @@ -2,7 +2,6 @@ // Chris Pulman and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Linq; using CP.Primitives; using CP.Primitives.Collections; using FluentAssertions; @@ -25,7 +24,7 @@ public void Version_IncrementsOnAdd() list.Add(1); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } /// Tests that Version increments when adding a range. @@ -40,7 +39,7 @@ public void Version_IncrementsOnAddRange() list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree]); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } /// Tests that Version increments when removing an item. @@ -52,10 +51,10 @@ public void Version_IncrementsOnRemove() var initialVersion = list.Version; // Act - list.Remove(TestData.TestValueTwo); + _ = list.Remove(TestData.TestValueTwo); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } /// Tests that Version increments when clearing. @@ -70,7 +69,7 @@ public void Version_IncrementsOnClear() list.Clear(); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } /// Tests that Version increments when updating. @@ -85,7 +84,7 @@ public void Version_IncrementsOnUpdate() list.Update(TestData.TestValueTwo, TestData.TestValueTwenty); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } /// Tests that Version increments when moving. @@ -100,7 +99,7 @@ public void Version_IncrementsOnMove() list.Move(0, TestData.TestValueTwo); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } #if NET6_0_OR_GREATER || NETFRAMEWORK @@ -110,15 +109,21 @@ public void ClearWithoutDeallocation_ClearsItemsPreservesCapacity() { // Arrange using var list = new ReactiveList(); - list.AddRange(Enumerable.Range(1, TestData.TestValueOneHundred).ToArray()); + var items = new int[TestData.TestValueOneHundred]; + for (var i = 0; i < items.Length; i++) + { + items[i] = i + 1; + } + + list.AddRange(items); var countBefore = list.Count; // Act list.ClearWithoutDeallocation(); // Assert - list.Count.Should().Be(0); - countBefore.Should().Be(TestData.TestValueOneHundred); + _ = list.Count.Should().Be(0); + _ = countBefore.Should().Be(TestData.TestValueOneHundred); } /// Tests that ClearWithoutDeallocation emits change notification. @@ -130,14 +135,14 @@ public void ClearWithoutDeallocation_EmitsChangeNotification() var changeReceived = false; using var subscription = list.Connect().Subscribe( _ => changeReceived = true, - _ => { }, - () => { }); + static _ => { }, + static () => { }); // Act list.ClearWithoutDeallocation(); // Assert - changeReceived.Should().BeTrue(); + _ = changeReceived.Should().BeTrue(); } /// Tests that ClearWithoutDeallocation with notifyChange=false does not emit. @@ -149,16 +154,16 @@ public void ClearWithoutDeallocation_WithNotifyFalse_DoesNotEmit() var changeCount = 0; using var subscription = list.Connect().Subscribe( _ => changeCount++, - _ => { }, - () => { }); + static _ => { }, + static () => { }); var countBefore = changeCount; // Act list.ClearWithoutDeallocation(notifyChange: false); // Assert - list.Count.Should().Be(0); - changeCount.Should().Be(countBefore); // No additional changes + _ = list.Count.Should().Be(0); + _ = changeCount.Should().Be(countBefore); // No additional changes } /// Tests that ClearWithoutDeallocation increments version. @@ -173,7 +178,7 @@ public void ClearWithoutDeallocation_IncrementsVersion() list.ClearWithoutDeallocation(); // Assert - list.Version.Should().Be(initialVersion + 1); + _ = list.Version.Should().Be(initialVersion + 1); } #endif } diff --git a/src/ReactiveList.Test/ReactiveViewTests.cs b/src/ReactiveList.Test/ReactiveViewTests.cs index 9830b74..074aa20 100644 --- a/src/ReactiveList.Test/ReactiveViewTests.cs +++ b/src/ReactiveList.Test/ReactiveViewTests.cs @@ -4,6 +4,7 @@ using System; using System.Buffers; +using System.ComponentModel; using System.Threading.Tasks; using CP.Primitives.Core; using CP.Primitives.Views; @@ -15,18 +16,24 @@ namespace ReactiveList.Test; /// Tests for ReactiveView. public class ReactiveViewTests { + /// The notification timeout in seconds. + private const int NotificationTimeoutSeconds = 5; + + /// The maximum time to wait for a buffered view notification. + private static readonly TimeSpan NotificationTimeout = TimeSpan.FromSeconds(NotificationTimeoutSeconds); + /// Constructor should throw when stream is null. [Test] public void Constructor_WithNullStream_ShouldThrow() { - var act = () => new ReactiveView( + var act = static () => new ReactiveView( null!, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName("stream"); } @@ -43,7 +50,7 @@ public void Constructor_WithNullFilter_ShouldThrow() TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName("filter"); } @@ -57,11 +64,11 @@ public void Constructor_WithSnapshot_ShouldLoadItems() using var view = new ReactiveView( subject, snapshot, - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - view.Items.Should().BeEquivalentTo(["one", "two", TestData.ThreeText]); + _ = view.Items.Should().BeEquivalentTo(["one", "two", TestData.ThreeText]); } /// Constructor should filter snapshot items. @@ -74,11 +81,11 @@ public void Constructor_WithFilter_ShouldFilterSnapshot() using var view = new ReactiveView( subject, snapshot, - s => s.StartsWith("a"), + static s => s.Length > 0 && s[0] == 'a', TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); + _ = view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); } /// Constructor with null snapshot should not throw. @@ -92,12 +99,12 @@ public void Constructor_WithNullSnapshot_ShouldNotThrow() using var view = new ReactiveView( subject, null!, - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); }; - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// Items property should be read-only. @@ -109,11 +116,11 @@ public void Items_ShouldBeReadOnly() using var view = new ReactiveView( subject, ["test"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - view.Items.Should().BeOfType>(); + _ = view.Items.Should().BeOfType>(); } /// Added notification should add item to view. @@ -126,15 +133,15 @@ public async Task AddedNotification_ShouldAddItemToView() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - subject.OnNext(new CacheNotify(CacheAction.Added, "newItem")); + subject.OnNext(new(CacheAction.Added, "newItem")); await Task.Delay(TestData.TestValueFifty); // Wait for buffer - view.Items.Should().Contain("newItem"); + _ = view.Items.Should().Contain("newItem"); } /// Added notification with filter should only add matching items. @@ -147,16 +154,16 @@ public async Task AddedNotification_WithFilter_ShouldOnlyAddMatchingItems() using var view = new ReactiveView( subject, [], - s => s.Length > 3, + static s => s.Length > 3, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - subject.OnNext(new CacheNotify(CacheAction.Added, "ab")); - subject.OnNext(new CacheNotify(CacheAction.Added, "abcd")); + subject.OnNext(new(CacheAction.Added, "ab")); + subject.OnNext(new(CacheAction.Added, "abcd")); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo(["abcd"]); + _ = view.Items.Should().BeEquivalentTo(["abcd"]); } /// Removed notification should remove item from view. @@ -169,15 +176,15 @@ public async Task RemovedNotification_ShouldRemoveItemFromView() using var view = new ReactiveView( subject, ["one", "two", TestData.ThreeText], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - subject.OnNext(new CacheNotify(CacheAction.Removed, "two")); + subject.OnNext(new(CacheAction.Removed, "two")); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo(["one", TestData.ThreeText]); + _ = view.Items.Should().BeEquivalentTo(["one", TestData.ThreeText]); } /// Cleared notification should clear view. @@ -190,15 +197,15 @@ public async Task ClearedNotification_ShouldClearView() using var view = new ReactiveView( subject, ["one", "two", TestData.ThreeText], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - subject.OnNext(new CacheNotify(CacheAction.Cleared, null)); + subject.OnNext(new(CacheAction.Cleared, null)); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); } /// BatchOperation notification should add batch items. @@ -211,7 +218,7 @@ public async Task BatchOperationNotification_ShouldAddBatchItems() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); @@ -221,11 +228,11 @@ public async Task BatchOperationNotification_ShouldAddBatchItems() array[TestData.TestValueTwo] = "item3"; var batch = new PooledBatch(array, TestData.TestValueThree); - subject.OnNext(new CacheNotify(CacheAction.BatchOperation, null, batch)); + subject.OnNext(new(CacheAction.BatchOperation, null, batch)); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo(["item1", "item2", "item3"]); + _ = view.Items.Should().BeEquivalentTo(["item1", "item2", "item3"]); } /// BatchOperation with filter should only add matching items. @@ -238,7 +245,7 @@ public async Task BatchOperationNotification_WithFilter_ShouldFilterItems() using var view = new ReactiveView( subject, [], - s => s.StartsWith("a"), + static s => s.Length > 0 && s[0] == 'a', TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); @@ -248,11 +255,11 @@ public async Task BatchOperationNotification_WithFilter_ShouldFilterItems() array[TestData.TestValueTwo] = TestData.ApricotText; var batch = new PooledBatch(array, TestData.TestValueThree); - subject.OnNext(new CacheNotify(CacheAction.BatchOperation, null, batch)); + subject.OnNext(new(CacheAction.BatchOperation, null, batch)); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); + _ = view.Items.Should().BeEquivalentTo([TestData.AppleText, TestData.ApricotText]); } /// ToProperty should set property. @@ -265,14 +272,14 @@ public void ToProperty_ShouldSetProperty() using var view = new ReactiveView( subject, ["test"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// ToProperty should throw when setter is null. @@ -284,13 +291,13 @@ public void ToProperty_WithNullSetter_ShouldThrow() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var act = () => view.ToProperty(null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName("propertySetter"); } @@ -303,13 +310,13 @@ public void Dispose_ShouldCleanUpSubscription() var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var act = view.Dispose; - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// Multiple dispose should be safe. @@ -321,14 +328,14 @@ public void Dispose_MultipleCalls_ShouldBeSafe() var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); view.Dispose(); var act = view.Dispose; - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// PropertyChanged should fire when items updated. @@ -337,30 +344,70 @@ public void Dispose_MultipleCalls_ShouldBeSafe() public async Task PropertyChanged_ShouldFireWhenItemsUpdated() { var subject = new Signal>(); - var propertyChangedFired = false; + var propertyChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); view.PropertyChanged += (_, e) => { - if (e.PropertyName != "Items") + if (e.PropertyName != nameof(view.Items)) { return; } - propertyChangedFired = true; + _ = propertyChanged.TrySetResult(true); }; - subject.OnNext(new CacheNotify(CacheAction.Added, "test")); + subject.OnNext(new(CacheAction.Added, "test")); + + await AssertCompletesAsync(propertyChanged.Task); + await TUnit.Assertions.Assert.That(await propertyChanged.Task).IsTrue(); + } + + /// PropertyChanged should preserve facade sender and unsubscription semantics. + /// A representing the asynchronous unit test. + [Test] + public async Task PropertyChanged_ShouldRelayFacadeSenderAndAllowUnsubscription() + { + var subject = new Signal>(); + var notificationCount = 0; + object? sender = null; + using var view = new ReactiveView( + subject, + [], + static _ => true, + TimeSpan.FromMilliseconds(TestData.TestValueTen), + Sequencer.Immediate); + + PropertyChangedEventHandler handler = (eventSender, eventArgs) => + { + if (eventArgs.PropertyName != nameof(view.Items)) + { + return; + } + + sender = eventSender; + notificationCount++; + }; + + view.PropertyChanged += handler; + subject.OnNext(new(CacheAction.Added, "first")); await Task.Delay(TestData.TestValueFifty); - propertyChangedFired.Should().BeTrue(); + await TUnit.Assertions.Assert.That(ReferenceEquals(sender, view)).IsTrue(); + await TUnit.Assertions.Assert.That(notificationCount).IsEqualTo(1); + + view.PropertyChanged -= handler; + subject.OnNext(new(CacheAction.Added, "second")); + await Task.Delay(TestData.TestValueFifty); + + await TUnit.Assertions.Assert.That(notificationCount).IsEqualTo(1); } /// Added notification with null item should not add anything. @@ -373,15 +420,15 @@ public async Task AddedNotification_WithNullItem_ShouldNotAdd() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - subject.OnNext(new CacheNotify(CacheAction.Added, null)); + subject.OnNext(new(CacheAction.Added, null)); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); } /// Removed notification with null item should not throw. @@ -394,13 +441,13 @@ public async Task RemovedNotification_WithNullItem_ShouldNotThrow() using var view = new ReactiveView( subject, ["test"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var act = async () => { - subject.OnNext(new CacheNotify(CacheAction.Removed, null)); + subject.OnNext(new(CacheAction.Removed, null)); await Task.Delay(TestData.TestValueFifty); }; @@ -417,13 +464,13 @@ public async Task BatchNotification_WithNullBatch_ShouldNotThrow() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var act = async () => { - subject.OnNext(new CacheNotify(CacheAction.BatchOperation, null, null)); + subject.OnNext(new(CacheAction.BatchOperation, null)); await Task.Delay(TestData.TestValueFifty); }; @@ -436,22 +483,35 @@ public async Task BatchNotification_WithNullBatch_ShouldNotThrow() public async Task View_ShouldBufferMultipleNotifications() { var subject = new Signal>(); + var propertyChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueFifty), Sequencer.Immediate); - // Send multiple notifications quickly - subject.OnNext(new CacheNotify(CacheAction.Added, "one")); - subject.OnNext(new CacheNotify(CacheAction.Added, "two")); - subject.OnNext(new CacheNotify(CacheAction.Added, TestData.ThreeText)); + view.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(view.Items)) + { + return; + } - await Task.Delay(TestData.TestValueOneHundred); + _ = propertyChanged.TrySetResult(true); + }; - view.Items.Should().BeEquivalentTo(["one", "two", TestData.ThreeText]); + // Send multiple notifications quickly + subject.OnNext(new(CacheAction.Added, "one")); + subject.OnNext(new(CacheAction.Added, "two")); + subject.OnNext(new(CacheAction.Added, TestData.ThreeText)); + + await AssertCompletesAsync(propertyChanged.Task); + await TUnit.Assertions.Assert.That(view.Items.Count).IsEqualTo(TestData.TestValueThree); + await TUnit.Assertions.Assert.That(view.Items[0]).IsEqualTo("one"); + await TUnit.Assertions.Assert.That(view.Items[1]).IsEqualTo("two"); + await TUnit.Assertions.Assert.That(view.Items[TestData.TestValueTwo]).IsEqualTo(TestData.ThreeText); } /// Updated action should not add or remove. @@ -464,15 +524,24 @@ public async Task UpdatedAction_ShouldNotChangeItems() using var view = new ReactiveView( subject, ["original"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); // Updated action is not handled in ApplyChange, so items should remain - subject.OnNext(new CacheNotify(CacheAction.Updated, "updated")); + subject.OnNext(new(CacheAction.Updated, "updated")); await Task.Delay(TestData.TestValueFifty); - view.Items.Should().BeEquivalentTo(["original"]); + _ = view.Items.Should().BeEquivalentTo(["original"]); + } + + /// Waits for an asynchronous view notification without relying on a scheduler-sensitive fixed delay. + /// The notification task to await. + /// A task that completes when the notification arrives or the timeout is asserted. + private static async Task AssertCompletesAsync(Task task) + { + var completed = await Task.WhenAny(task, Task.Delay(NotificationTimeout)); + await TUnit.Assertions.Assert.That(ReferenceEquals(completed, task)).IsTrue(); } } diff --git a/src/ReactiveList.Test/SecondaryIndexTests.cs b/src/ReactiveList.Test/SecondaryIndexTests.cs index 6e7aa13..5f9b35a 100644 --- a/src/ReactiveList.Test/SecondaryIndexTests.cs +++ b/src/ReactiveList.Test/SecondaryIndexTests.cs @@ -5,7 +5,6 @@ #if NET6_0_OR_GREATER || NETFRAMEWORK using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using CP.Primitives.Core; using FluentAssertions; @@ -20,64 +19,73 @@ public class SecondaryIndexTests [Test] public void OnAdded_ShouldAddItemToIndex() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person = new Person(1, "John", TestData.EngineeringDepartment); index.OnAdded(person); - index.Lookup(TestData.EngineeringDepartment).Should().Contain(person); + _ = index.Lookup(TestData.EngineeringDepartment).Should().Contain(person); } /// OnAdded should add multiple items with same key. [Test] public void OnAdded_WithSameKey_ShouldAddMultipleItems() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person1 = new Person(1, "John", TestData.EngineeringDepartment); var person2 = new Person(TestData.TestValueTwo, "Jane", TestData.EngineeringDepartment); index.OnAdded(person1); index.OnAdded(person2); - var result = index.Lookup(TestData.EngineeringDepartment).ToList(); - result.Should().HaveCount(TestData.TestValueTwo); - result.Should().Contain(person1); - result.Should().Contain(person2); + var count = 0; + var containsFirst = false; + var containsSecond = false; + foreach (var person in index.Lookup(TestData.EngineeringDepartment)) + { + count++; + containsFirst |= person == person1; + containsSecond |= person == person2; + } + + _ = count.Should().Be(TestData.TestValueTwo); + _ = containsFirst.Should().BeTrue(); + _ = containsSecond.Should().BeTrue(); } /// OnAdded should handle items with different keys. [Test] public void OnAdded_WithDifferentKeys_ShouldIndexSeparately() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person1 = new Person(1, "John", TestData.EngineeringDepartment); var person2 = new Person(TestData.TestValueTwo, "Jane", TestData.SalesDepartment); index.OnAdded(person1); index.OnAdded(person2); - index.Lookup(TestData.EngineeringDepartment).Should().ContainSingle().Which.Should().Be(person1); - index.Lookup(TestData.SalesDepartment).Should().ContainSingle().Which.Should().Be(person2); + _ = index.Lookup(TestData.EngineeringDepartment).Should().ContainSingle().Which.Should().Be(person1); + _ = index.Lookup(TestData.SalesDepartment).Should().ContainSingle().Which.Should().Be(person2); } /// OnRemoved should remove item from index. [Test] public void OnRemoved_ShouldRemoveItemFromIndex() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person = new Person(1, "John", TestData.EngineeringDepartment); index.OnAdded(person); index.OnRemoved(person); - index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); + _ = index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); } /// OnRemoved should only remove specified item. [Test] public void OnRemoved_ShouldOnlyRemoveSpecifiedItem() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person1 = new Person(1, "John", TestData.EngineeringDepartment); var person2 = new Person(TestData.TestValueTwo, "Jane", TestData.EngineeringDepartment); index.OnAdded(person1); @@ -85,92 +93,100 @@ public void OnRemoved_ShouldOnlyRemoveSpecifiedItem() index.OnRemoved(person1); - var result = index.Lookup(TestData.EngineeringDepartment).ToList(); - result.Should().ContainSingle().Which.Should().Be(person2); + var count = 0; + Person? remainingPerson = null; + foreach (var person in index.Lookup(TestData.EngineeringDepartment)) + { + count++; + remainingPerson = person; + } + + _ = count.Should().Be(1); + _ = remainingPerson.Should().Be(person2); } /// OnRemoved should handle non-existing item gracefully. [Test] public void OnRemoved_WithNonExistingItem_ShouldNotThrow() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var person = new Person(1, "John", TestData.EngineeringDepartment); var act = () => index.OnRemoved(person); - act.Should().NotThrow(); + _ = act.Should().NotThrow(); } /// OnUpdated should update index with new key. [Test] public void OnUpdated_ShouldUpdateIndex() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var oldPerson = new Person(1, "John", TestData.EngineeringDepartment); var newPerson = new Person(1, "John", TestData.SalesDepartment); index.OnAdded(oldPerson); index.OnUpdated(oldPerson, newPerson); - index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); - index.Lookup(TestData.SalesDepartment).Should().ContainSingle().Which.Should().Be(newPerson); + _ = index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); + _ = index.Lookup(TestData.SalesDepartment).Should().ContainSingle().Which.Should().Be(newPerson); } /// Lookup should return empty for non-existing key. [Test] public void Lookup_WithNonExistingKey_ShouldReturnEmpty() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var result = index.Lookup("NonExisting"); - result.Should().BeEmpty(); + _ = result.Should().BeEmpty(); } /// Clear should remove all items. [Test] public void Clear_ShouldRemoveAllItems() { - var index = new SecondaryIndex(p => p.Department); - index.OnAdded(new Person(1, "John", TestData.EngineeringDepartment)); - index.OnAdded(new Person(TestData.TestValueTwo, "Jane", TestData.SalesDepartment)); - index.OnAdded(new Person(TestData.TestValueThree, "Bob", "Marketing")); + var index = new SecondaryIndex(static p => p.Department); + index.OnAdded(new(1, "John", TestData.EngineeringDepartment)); + index.OnAdded(new(TestData.TestValueTwo, "Jane", TestData.SalesDepartment)); + index.OnAdded(new(TestData.TestValueThree, "Bob", "Marketing")); index.Clear(); - index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); - index.Lookup(TestData.SalesDepartment).Should().BeEmpty(); - index.Lookup("Marketing").Should().BeEmpty(); + _ = index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); + _ = index.Lookup(TestData.SalesDepartment).Should().BeEmpty(); + _ = index.Lookup("Marketing").Should().BeEmpty(); } /// Index should handle integer keys. [Test] public void Index_WithIntegerKey_ShouldWork() { - var index = new SecondaryIndex(p => p.Id); + var index = new SecondaryIndex(static p => p.Id); var person = new Person(TestData.TestValueFortyTwo, "John", TestData.EngineeringDepartment); index.OnAdded(person); - index.Lookup(TestData.TestValueFortyTwo).Should().ContainSingle().Which.Should().Be(person); + _ = index.Lookup(TestData.TestValueFortyTwo).Should().ContainSingle().Which.Should().Be(person); } /// Index should distribute items across shards. [Test] public void Index_ShouldDistributeAcrossShards() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); // Add many items with different keys to ensure shard distribution for (var i = 0; i < TestData.TestValueOneHundred; i++) { - index.OnAdded(new Person(i, $"Person{i}", $"Dept{i}")); + index.OnAdded(new(i, $"Person{i}", $"Dept{i}")); } // Verify all items can be looked up for (var i = 0; i < TestData.TestValueOneHundred; i++) { - index.Lookup($"Dept{i}").Should().ContainSingle(); + _ = index.Lookup($"Dept{i}").Should().ContainSingle(); } } @@ -179,18 +195,18 @@ public void Index_ShouldDistributeAcrossShards() [Test] public async Task Index_ShouldBeThreadSafeForConcurrentAdds() { - var index = new SecondaryIndex(p => p.Department); + var index = new SecondaryIndex(static p => p.Department); var tasks = new List(); for (var i = 0; i < TestData.TestValueOneHundred; i++) { var id = i; - tasks.Add(Task.Run(() => index.OnAdded(new Person(id, $"Person{id}", TestData.EngineeringDepartment)))); + tasks.Add(Task.Run(() => index.OnAdded(new(id, $"Person{id}", TestData.EngineeringDepartment)))); } - await Task.WhenAll(tasks.ToArray()); + await Task.WhenAll([.. tasks]); - index.Lookup(TestData.EngineeringDepartment).Should().HaveCount(TestData.TestValueOneHundred); + _ = index.Lookup(TestData.EngineeringDepartment).Should().HaveCount(TestData.TestValueOneHundred); } /// Index should be thread-safe for concurrent removes. @@ -198,20 +214,28 @@ public async Task Index_ShouldBeThreadSafeForConcurrentAdds() [Test] public async Task Index_ShouldBeThreadSafeForConcurrentRemoves() { - var index = new SecondaryIndex(p => p.Department); - var persons = Enumerable.Range(0, TestData.TestValueOneHundred) - .Select(i => new Person(i, $"Person{i}", TestData.EngineeringDepartment)) - .ToList(); + var index = new SecondaryIndex(static p => p.Department); + var persons = new List(TestData.TestValueOneHundred); + for (var i = 0; i < TestData.TestValueOneHundred; i++) + { + persons.Add(new(i, $"Person{i}", TestData.EngineeringDepartment)); + } foreach (var person in persons) { index.OnAdded(person); } - var tasks = persons.Select(p => Task.Run(() => index.OnRemoved(p))).ToArray(); + var tasks = new Task[persons.Count]; + for (var i = 0; i < persons.Count; i++) + { + var person = persons[i]; + tasks[i] = Task.Run(() => index.OnRemoved(person)); + } + await Task.WhenAll(tasks); - index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); + _ = index.Lookup(TestData.EngineeringDepartment).Should().BeEmpty(); } /// Provides Person. diff --git a/src/ReactiveList.Test/TUnitAssemblyInfo.cs b/src/ReactiveList.Test/TUnitAssemblyInfo.cs index d440ae0..f746f19 100644 --- a/src/ReactiveList.Test/TUnitAssemblyInfo.cs +++ b/src/ReactiveList.Test/TUnitAssemblyInfo.cs @@ -4,4 +4,5 @@ using TUnit.Core; +// The tests share process-wide resources and therefore run serially. [assembly: NotInParallel] diff --git a/src/ReactiveList.Test/TestData.cs b/src/ReactiveList.Test/TestData.cs index 16883bb..6aa92c7 100644 --- a/src/ReactiveList.Test/TestData.cs +++ b/src/ReactiveList.Test/TestData.cs @@ -2,14 +2,12 @@ // Chris Pulman and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System; - namespace ReactiveList.Test; /// Test data class for unit tests. /// The name value. /// The age value. -[Serializable] +[System.Serializable] internal sealed class TestData(string name, int age) { /// Gets the shared alice name test value. @@ -178,8 +176,8 @@ internal sealed class TestData(string name, int age) internal const int TestValueOneThousand = 1000; /// Gets the age. - public int Age { get; } = age; + internal int Age { get; } = age; /// Gets the name. - public string Name { get; } = name; + internal string Name { get; } = name; } diff --git a/src/ReactiveList.Test/ViewCoverageTests.cs b/src/ReactiveList.Test/ViewCoverageTests.cs index 4fc5886..dc2594d 100644 --- a/src/ReactiveList.Test/ViewCoverageTests.cs +++ b/src/ReactiveList.Test/ViewCoverageTests.cs @@ -8,8 +8,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; -using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using CP.Primitives.Collections; @@ -25,13 +23,32 @@ namespace ReactiveList.Tests; /// Coverage tests for reactive view implementations. public class ViewCoverageTests { + /// Initial values used by filtered-view transition tests. + private static readonly int[] FilteredInitialItems = + [ + TestConstants.TestValueTwo, + TestConstants.TestValueThree, + TestConstants.TestValueFour + ]; + + /// Initial values used by sorted-view transition tests. + private static readonly int[] SortedInitialItems = [TestConstants.TestValueThree, 1]; + + /// Initial values used by dynamic filtered-view transition tests. + private static readonly int[] DynamicFilteredInitialItems = + [ + 1, + TestConstants.TestValueTwo, + TestConstants.TestValueThree + ]; + /// Filtered views should track update transitions and refreshes. /// A representing the asynchronous unit test. [Test] public async Task FilteredReactiveView_UpdateTransitions_ShouldAddRemoveReplaceAndRefresh() { using var list = new ReactiveList(); - list.AddRange(new[] { TestConstants.TestValueTwo, TestConstants.TestValueThree, TestConstants.TestValueFour }); + list.AddRange(FilteredInitialItems); using var view = new FilteredReactiveView( list, @@ -39,40 +56,45 @@ public async Task FilteredReactiveView_UpdateTransitions_ShouldAddRemoveReplaceA Sequencer.Immediate, TimeSpan.Zero); - view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); - view[0].Should().Be(TestConstants.TestValueTwo); - ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); + _ = view[0].Should().Be(TestConstants.TestValueTwo); + _ = ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); var filteredProperties = new List(); - view.PropertyChanged += (_, args) => filteredProperties.Add(args.PropertyName); + object? filteredPropertySender = null; + view.PropertyChanged += (sender, args) => + { + filteredPropertySender = sender; + filteredProperties.Add(args.PropertyName); + }; list.Update(TestConstants.TestValueTwo, TestConstants.TestValueFive); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueFour); list.Update(TestConstants.TestValueThree, TestConstants.TestValueSix); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); list.Update(TestConstants.TestValueFour, TestConstants.TestValueEight); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); list.Move(TestConstants.TestValueTwo, 0); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); view.Refresh(); - view.ToArray().Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueEight, TestConstants.TestValueSix); - list.Remove(TestConstants.TestValueSix); + _ = list.Remove(TestConstants.TestValueSix); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueEight); + _ = view.Items.Should().Equal(TestConstants.TestValueEight); list.Clear(); await WaitForPipeline(); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(new Change(ChangeReason.Clear, default))); - view.Items.Should().BeEmpty(); - filteredProperties.Should().Contain(nameof(view.Count)); + _ = view.Items.Should().BeEmpty(); + _ = filteredProperties.Should().Contain(nameof(view.Count)); + _ = filteredPropertySender.Should().BeSameAs(view); } /// Sorted views should maintain comparer order through source changes. @@ -81,7 +103,7 @@ public async Task FilteredReactiveView_UpdateTransitions_ShouldAddRemoveReplaceA public async Task SortedReactiveView_Changes_ShouldKeepItemsSorted() { using var list = new ReactiveList(); - list.AddRange(new[] { TestConstants.TestValueThree, 1 }); + list.AddRange(SortedInitialItems); using var view = new SortedReactiveView( list, @@ -89,35 +111,52 @@ public async Task SortedReactiveView_Changes_ShouldKeepItemsSorted() Sequencer.Immediate, TimeSpan.Zero); - view.Items.Should().Equal(1, TestConstants.TestValueThree); - view[1].Should().Be(TestConstants.TestValueThree); - ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + _ = view.Items.Should().Equal(1, TestConstants.TestValueThree); + _ = view[1].Should().Be(TestConstants.TestValueThree); + _ = ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + var sortedCollectionNotifications = 0; + object? sortedCollectionSender = null; + NotifyCollectionChangedEventHandler sortedCollectionHandler = (sender, _) => + { + sortedCollectionSender = sender; + sortedCollectionNotifications++; + }; + view.CollectionChanged += sortedCollectionHandler; + view.CollectionChanged += sortedCollectionHandler; + view.CollectionChanged -= sortedCollectionHandler; list.Add(TestConstants.TestValueTwo); await WaitForPipeline(); - view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = sortedCollectionNotifications.Should().Be(1); + _ = sortedCollectionSender.Should().BeSameAs(view); + view.CollectionChanged -= sortedCollectionHandler; list.Add(TestConstants.TestValueTwo); await WaitForPipeline(); - view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = sortedCollectionNotifications.Should().Be(1); list.Update(TestConstants.TestValueThree, 0); await WaitForPipeline(); - view.Items.Should().Equal(0, 1, TestConstants.TestValueTwo, TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(0, 1, TestConstants.TestValueTwo, TestConstants.TestValueTwo); list.Move(0, TestConstants.TestValueTwo); await WaitForPipeline(); - view.Items.Should().Equal(0, 1, TestConstants.TestValueTwo, TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(0, 1, TestConstants.TestValueTwo, TestConstants.TestValueTwo); - list.Remove(1); + _ = list.Remove(1); await WaitForPipeline(); - view.Items.Should().Equal(0, TestConstants.TestValueTwo, TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(0, TestConstants.TestValueTwo, TestConstants.TestValueTwo); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(new Change(ChangeReason.Clear, default))); - view.Items.Should().BeEmpty(); + list.Clear(); + await WaitForPipeline(); + _ = view.Items.Should().BeEmpty(); + list.AddRange([0, TestConstants.TestValueTwo, TestConstants.TestValueTwo]); + await WaitForPipeline(); view.Refresh(); - view.Items.Should().Equal(0, TestConstants.TestValueTwo, TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(0, TestConstants.TestValueTwo, TestConstants.TestValueTwo); } /// Grouped views should expose dictionary members and update group membership. @@ -135,59 +174,62 @@ public async Task GroupedReactiveView_DictionarySurfaceAndUpdates_ShouldTrackGro static item => item.Region, Sequencer.Immediate, TimeSpan.Zero); - - view.Keys.Should().BeEquivalentTo([TestConstants.NorthRegion, TestConstants.SouthRegion]); - view.Values.SelectMany(static group => group).Should().BeEquivalentTo([north, south]); - view[TestConstants.NorthRegion].Should().ContainSingle().Which.Should().Be(north); - view.TryGetValue(TestConstants.NorthRegion, out var northGroup).Should().BeTrue(); - northGroup.Should().ContainSingle().Which.Should().Be(north); - view.TryGetValue(TestConstants.MissingKey, out var missing).Should().BeFalse(); - missing.Should().BeEmpty(); - ((IEnumerable)view).Cast>>() - .Should().HaveCount(TestConstants.TestValueTwo); - ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + object? groupedCollectionSender = null; + object? groupedPropertySender = null; + view.CollectionChanged += (sender, _) => groupedCollectionSender = sender; + view.PropertyChanged += (sender, _) => groupedPropertySender = sender; + + _ = view.Keys.Should().BeEquivalentTo([TestConstants.NorthRegion, TestConstants.SouthRegion]); + _ = FlattenGroups(view.Values).Should().BeEquivalentTo([north, south]); + _ = view[TestConstants.NorthRegion].Should().ContainSingle().Which.Should().Be(north); + _ = view.TryGetValue(TestConstants.NorthRegion, out var northGroup).Should().BeTrue(); + _ = northGroup.Should().ContainSingle().Which.Should().Be(north); + _ = view.TryGetValue(TestConstants.MissingKey, out var missing).Should().BeFalse(); + _ = missing.Should().BeEmpty(); + _ = CountEntries((IEnumerable)view).Should().Be(TestConstants.TestValueTwo); + _ = ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); view.Refresh(); var changedScore = north with { Score = TestConstants.TestValueTen }; list.Update(north, changedScore); await WaitForPipeline(); - view[TestConstants.NorthRegion].Should().ContainSingle().Which.Should().Be(changedScore); + _ = view[TestConstants.NorthRegion].Should().ContainSingle().Which.Should().Be(changedScore); var movedRegion = changedScore with { Region = TestConstants.SouthRegion }; list.Update(changedScore, movedRegion); await WaitForPipeline(); - view.ContainsKey(TestConstants.NorthRegion).Should().BeFalse(); - view[TestConstants.SouthRegion].Should().BeEquivalentTo([south, movedRegion]); + _ = view.ContainsKey(TestConstants.NorthRegion).Should().BeFalse(); + _ = view[TestConstants.SouthRegion].Should().BeEquivalentTo([south, movedRegion]); - list.Remove(south); + _ = list.Remove(south); await WaitForPipeline(); - view[TestConstants.SouthRegion].Should().ContainSingle().Which.Should().Be(movedRegion); + _ = view[TestConstants.SouthRegion].Should().ContainSingle().Which.Should().Be(movedRegion); - list.Remove(movedRegion); + _ = list.Remove(movedRegion); await WaitForPipeline(); - view.Should().BeEmpty(); + _ = view.Should().BeEmpty(); list.Add(north); await WaitForPipeline(); list.Clear(); await WaitForPipeline(); - view.Should().BeEmpty(); + _ = view.Should().BeEmpty(); var west = new ViewItem(TestConstants.TestValueThree, "west"); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(new Change(ChangeReason.Update, west))); - view.ContainsKey("west").Should().BeTrue(); + list.Add(west); + await WaitForPipeline(); + _ = view.ContainsKey("west").Should().BeTrue(); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(new Change(ChangeReason.Clear, default!))); - view.Should().BeEmpty(); + list.Clear(); + await WaitForPipeline(); + _ = view.Should().BeEmpty(); list.Add(north); await WaitForPipeline(); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(Change.CreateRefresh(north))); - view.ContainsKey(TestConstants.NorthRegion).Should().BeTrue(); - - InvokePrivate(view, "RemoveFromGroup", new ViewItem(TestConstants.TestValueFourHundredFour, TestConstants.MissingKey)); - ((IList)GetPrivateField(view, "_groupCollection")).Clear(); - InvokePrivate(view, "RemoveFromGroup", north); + view.Refresh(); + _ = view.ContainsKey(TestConstants.NorthRegion).Should().BeTrue(); + _ = groupedCollectionSender.Should().BeSameAs(view); + _ = groupedPropertySender.Should().BeSameAs(view); } /// Dynamic filtered views should rebuild on filter changes and track source changes. @@ -196,7 +238,7 @@ public async Task GroupedReactiveView_DictionarySurfaceAndUpdates_ShouldTrackGro public async Task DynamicFilteredReactiveView_FilterAndSourceChanges_ShouldRebuildAndTrackTransitions() { using var list = new ReactiveList(); - list.AddRange(new[] { 1, TestConstants.TestValueTwo, TestConstants.TestValueThree }); + list.AddRange(DynamicFilteredInitialItems); using var filters = new BehaviorSignal>(static item => item >= TestConstants.TestValueTwo); using var view = new DynamicFilteredReactiveView( @@ -206,52 +248,51 @@ public async Task DynamicFilteredReactiveView_FilterAndSourceChanges_ShouldRebui TimeSpan.Zero); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueThree); - view[0].Should().Be(TestConstants.TestValueTwo); - ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = view[0].Should().Be(TestConstants.TestValueTwo); + _ = ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); var dynamicFilteredProperties = new List(); view.PropertyChanged += (_, args) => dynamicFilteredProperties.Add(args.PropertyName); filters.OnNext(null!); await WaitForPipeline(); - view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueThree); + _ = view.Items.Should().Equal(1, TestConstants.TestValueTwo, TestConstants.TestValueThree); filters.OnNext(static item => item % TestConstants.TestValueTwo == 0); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo); list.Add(TestConstants.TestValueFour); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); list.Update(TestConstants.TestValueTwo, TestConstants.TestValueFive); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueFour); list.Update(1, TestConstants.TestValueSix); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); view.Refresh(); - view.Items.Should().Equal(TestConstants.TestValueSix, TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueSix, TestConstants.TestValueFour); list.Update(TestConstants.TestValueFour, TestConstants.TestValueEight); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueSix, TestConstants.TestValueEight); + _ = view.Items.Should().Equal(TestConstants.TestValueSix, TestConstants.TestValueEight); - list.Remove(TestConstants.TestValueSix); + _ = list.Remove(TestConstants.TestValueSix); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueEight); + _ = view.Items.Should().Equal(TestConstants.TestValueEight); list.Move(TestConstants.TestValueTwo, 0); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueEight); + _ = view.Items.Should().Equal(TestConstants.TestValueEight); list.Clear(); await WaitForPipeline(); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new ChangeSet(new Change(ChangeReason.Clear, default))); - view.Items.Should().BeEmpty(); - dynamicFilteredProperties.Should().Contain(nameof(view.Count)); + _ = view.Items.Should().BeEmpty(); + _ = dynamicFilteredProperties.Should().Contain(nameof(view.Count)); } /// Dynamic reactive views should apply single and batch stream actions. @@ -270,44 +311,42 @@ public async Task DynamicReactiveView_StreamActions_ShouldApplyCurrentFilterAndB var dynamicProperties = new List(); view.PropertyChanged += (_, args) => dynamicProperties.Add(args.PropertyName); - view.Items.Should().Equal(TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo); source.AddItem(TestConstants.TestValueFour); - source.Emit(new CacheNotify(CacheAction.Added, TestConstants.TestValueFour)); + source.Emit(new(CacheAction.Added, TestConstants.TestValueFour)); source.AddItem(TestConstants.TestValueFive); - source.Emit(new CacheNotify(CacheAction.Added, TestConstants.TestValueFive)); + source.Emit(new(CacheAction.Added, TestConstants.TestValueFive)); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueTwo, TestConstants.TestValueFour); source.RemoveItem(TestConstants.TestValueTwo); - source.Emit(new CacheNotify(CacheAction.Removed, TestConstants.TestValueTwo)); + source.Emit(new(CacheAction.Removed, TestConstants.TestValueTwo)); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour); + _ = view.Items.Should().Equal(TestConstants.TestValueFour); source.AddItems([TestConstants.TestValueSix, TestConstants.TestValueSeven]); - source.Emit(new CacheNotify(CacheAction.BatchAdded, default, CreateBatch(TestConstants.TestValueSix, TestConstants.TestValueSeven))); + source.Emit(new(CacheAction.BatchAdded, default, CreateBatch(TestConstants.TestValueSix, TestConstants.TestValueSeven))); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); source.RemoveItems([TestConstants.TestValueFour, TestConstants.TestValueSix]); - source.Emit(new CacheNotify(CacheAction.BatchRemoved, default, CreateBatch(TestConstants.TestValueFour, TestConstants.TestValueSix))); + source.Emit(new(CacheAction.BatchRemoved, default, CreateBatch(TestConstants.TestValueFour, TestConstants.TestValueSix))); await WaitForPipeline(); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); source.ClearItems(); - source.Emit(new CacheNotify(CacheAction.Cleared, default)); + source.Emit(new(CacheAction.Cleared, default)); await WaitForPipeline(); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); filters.OnNext(static _ => true); await WaitForPipeline(); source.AddItem(TestConstants.TestValueNine); - source.Emit(new CacheNotify(CacheAction.Added, TestConstants.TestValueNine)); + source.Emit(new(CacheAction.Added, TestConstants.TestValueNine)); await WaitForPipeline(); - view.Items.Should().Equal(TestConstants.TestValueNine); - dynamicProperties.Should().Contain(nameof(view.Items)); - view.Dispose(); - view.Dispose(); + _ = view.Items.Should().Equal(TestConstants.TestValueNine); + _ = dynamicProperties.Should().Contain(nameof(view.Items)); } /// Complex changes buffered with later additions should rebuild exactly once from the final source state. @@ -330,30 +369,30 @@ public async Task DynamicReactiveView_BufferedUpdateAndAdd_ShouldNotDuplicateIte return; } - applied.TrySetResult(true); + _ = applied.TrySetResult(true); }; source.RemoveItem(TestConstants.TestValueTwo); source.AddItem(TestConstants.TestValueFour); - source.Emit(new CacheNotify( + source.Emit(new( CacheAction.Updated, TestConstants.TestValueFour, Previous: TestConstants.TestValueTwo)); source.AddItem(TestConstants.TestValueSix); - source.Emit(new CacheNotify(CacheAction.Added, TestConstants.TestValueSix)); + source.Emit(new(CacheAction.Added, TestConstants.TestValueSix)); await applied.Task; - view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); + _ = view.Items.Should().Equal(TestConstants.TestValueFour, TestConstants.TestValueSix); applied = new(TaskCreationOptions.RunContinuationsAsynchronously); source.ClearItems(); source.AddItem(TestConstants.TestValueEight); - source.Emit(new CacheNotify(CacheAction.BatchOperation, default)); + source.Emit(new(CacheAction.BatchOperation, default)); await applied.Task; - view.Items.Should().Equal(TestConstants.TestValueEight); + _ = view.Items.Should().Equal(TestConstants.TestValueEight); } /// Dynamic reactive views should use the default include-all filter when null filters are emitted. @@ -370,13 +409,13 @@ public async Task DynamicReactiveView_NullFilters_ShouldUseDefaultIncludeAllFilt TimeSpan.Zero, Sequencer.Immediate); - view.Items.Should().Equal(1); + _ = view.Items.Should().Equal(1); source.AddItem(TestConstants.TestValueTwo); filters.OnNext(null!); await WaitForPipeline(); - view.Items.Should().Equal(1, TestConstants.TestValueTwo); + _ = view.Items.Should().Equal(1, TestConstants.TestValueTwo); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -389,50 +428,50 @@ public async Task SecondaryIndexReactiveView_DictionaryUpdates_ShouldRemoveValue using var dictionary = new QuaternaryDictionary(); var north = new ViewItem(1, TestConstants.NorthRegion); dictionary.Add(1, north); - dictionary.Add(TestConstants.TestValueTwo, new ViewItem(TestConstants.TestValueTwo, TestConstants.SouthRegion)); + dictionary.Add(TestConstants.TestValueTwo, new(TestConstants.TestValueTwo, TestConstants.SouthRegion)); dictionary.AddValueIndex(TestConstants.RegionPropertyName, static item => item.Region); - using var view = SecondaryIndexReactiveView.Create( + using var view = SecondaryIndexReactiveView.Create( dictionary, TestConstants.RegionPropertyName, TestConstants.NorthRegion, Sequencer.Immediate, TimeSpan.Zero); - view.Items.Should().ContainSingle().Which.Should().Be(north); - view.Count.Should().Be(1); - view[0].Should().Be(north); - view.ToProperty(out var outCollection).Should().BeSameAs(view); - outCollection.Should().BeSameAs(view.Items); - view.ToProperty(collection => collection.Should().BeSameAs(view.Items)).Should().BeSameAs(view); + _ = view.Items.Should().ContainSingle().Which.Should().Be(north); + _ = view.Count.Should().Be(1); + _ = view[0].Should().Be(north); + _ = view.ToProperty(out var outCollection).Should().BeSameAs(view); + _ = outCollection.Should().BeSameAs(view.Items); + _ = view.ToProperty(collection => collection.Should().BeSameAs(view.Items)).Should().BeSameAs(view); view.Refresh(); - view.GetEnumerator().MoveNext().Should().BeTrue(); - ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); + _ = view.GetEnumerator().MoveNext().Should().BeTrue(); + _ = ((IEnumerable)view).GetEnumerator().MoveNext().Should().BeTrue(); var secondaryProperties = new List(); view.PropertyChanged += (_, args) => secondaryProperties.Add(args.PropertyName); - InvokePrivate(view, TestConstants.SourceChangedMethodName, new CacheNotify>(CacheAction.Refreshed, default)); - view.Items.Should().ContainSingle().Which.Should().Be(north); + view.Refresh(); + _ = view.Items.Should().ContainSingle().Which.Should().Be(north); dictionary.AddOrUpdate(1, north with { Region = TestConstants.SouthRegion }); await WaitForPipeline(); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); var newNorth = new ViewItem(TestConstants.TestValueThree, TestConstants.NorthRegion); dictionary.AddOrUpdate(TestConstants.TestValueThree, newNorth); await WaitForPipeline(); - view.Items.Should().ContainSingle().Which.Should().Be(newNorth); + _ = view.Items.Should().ContainSingle().Which.Should().Be(newNorth); - dictionary.Remove(TestConstants.TestValueThree); + _ = dictionary.Remove(TestConstants.TestValueThree); await WaitForPipeline(); - view.Items.Should().BeEmpty(); + _ = view.Items.Should().BeEmpty(); - dictionary.AddOrUpdate(TestConstants.TestValueFour, new ViewItem(TestConstants.TestValueFour, TestConstants.NorthRegion)); + dictionary.AddOrUpdate(TestConstants.TestValueFour, new(TestConstants.TestValueFour, TestConstants.NorthRegion)); await WaitForPipeline(); dictionary.Clear(); await WaitForPipeline(); - view.Items.Should().BeEmpty(); - secondaryProperties.Should().Contain(nameof(view.Count)); + _ = view.Items.Should().BeEmpty(); + _ = secondaryProperties.Should().Contain(nameof(view.Count)); } /// Dynamic secondary-index views should track key changes and dictionary updates. @@ -455,99 +494,30 @@ public async Task DynamicSecondaryIndexViews_KeyChangesAndDictionaryUpdates_Shou Sequencer.Immediate, TimeSpan.Zero); - listView.Items.Should().ContainSingle().Which.Should().Be(north); - listView.Count.Should().Be(1); - listView[0].Should().Be(north); - listView.ToProperty(out var listOutCollection).Should().BeSameAs(listView); - listOutCollection.Should().BeSameAs(listView.Items); - listView.ToProperty(collection => collection.Should().BeSameAs(listView.Items)).Should().BeSameAs(listView); - listView.Refresh(); - listView.GetEnumerator().MoveNext().Should().BeTrue(); - ((IEnumerable)listView).GetEnumerator().MoveNext().Should().BeTrue(); var listViewProperties = new List(); listView.PropertyChanged += (_, args) => listViewProperties.Add(args.PropertyName); - - list.Remove(north); - await WaitForPipeline(); - listView.Items.Should().BeEmpty(); - - list.Add(north); - await WaitForPipeline(); - listView.Items.Should().ContainSingle().Which.Should().Be(north); - - listKeys.OnNext([TestConstants.SouthRegion]); - await WaitForPipeline(); - listView.Items.Should().ContainSingle().Which.Should().Be(south); - - var secondSouth = new ViewItem(TestConstants.TestValueThree, TestConstants.SouthRegion); - list.Add(secondSouth); - await WaitForPipeline(); - listView.Items.Should().BeEquivalentTo([south, secondSouth]); - - list.ReplaceAll([north]); - await WaitForPipeline(); - listView.Items.Should().BeEmpty(); - listViewProperties.Should().Contain(nameof(listView.Count)); + await VerifyDynamicSecondaryListView(list, listView, listKeys, north, south, listViewProperties); using var dictionary = new QuaternaryDictionary { { 1, north } }; dictionary.Add(TestConstants.TestValueTwo, south); dictionary.AddValueIndex(TestConstants.RegionPropertyName, static item => item.Region); using var dictionaryKeys = new BehaviorSignal([TestConstants.NorthRegion]); - using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( dictionary, TestConstants.RegionPropertyName, dictionaryKeys, Sequencer.Immediate, TimeSpan.Zero); - dictionaryView.Items.Should().ContainSingle().Which.Should().Be(new KeyValuePair(1, north)); - dictionaryView.Count.Should().Be(1); - dictionaryView[0].Key.Should().Be(1); - dictionaryView.ToProperty(out var dictionaryOutCollection).Should().BeSameAs(dictionaryView); - dictionaryOutCollection.Should().BeSameAs(dictionaryView.Items); - dictionaryView.ToProperty(collection => collection.Should().BeSameAs(dictionaryView.Items)).Should().BeSameAs(dictionaryView); - dictionaryView.Refresh(); - dictionaryView.GetEnumerator().MoveNext().Should().BeTrue(); - ((IEnumerable)dictionaryView).GetEnumerator().MoveNext().Should().BeTrue(); var dictionaryViewProperties = new List(); dictionaryView.PropertyChanged += (_, args) => dictionaryViewProperties.Add(args.PropertyName); - - dictionary.AddOrUpdate(1, north with { Region = TestConstants.SouthRegion }); - await WaitForPipeline(); - dictionaryView.Items.Should().BeEmpty(); - - dictionary.AddOrUpdate(TestConstants.TestValueFour, new ViewItem(TestConstants.TestValueFour, TestConstants.NorthRegion)); - await WaitForPipeline(); - dictionaryView.Items.Select(static item => item.Key).Should().Contain(TestConstants.TestValueFour); - - dictionary.AddOrUpdate(TestConstants.TestValueFour, new ViewItem(TestConstants.TestValueFour, TestConstants.NorthRegion, Score: 10)); - await WaitForPipeline(); - dictionaryView.Items.Single(static item => item.Key == TestConstants.TestValueFour).Value.Score.Should().Be(TestConstants.TestValueTen); - - dictionaryKeys.OnNext([TestConstants.SouthRegion]); - await WaitForPipeline(); - dictionaryView.Items.Select(static item => item.Key).Should().BeEquivalentTo([1, TestConstants.TestValueTwo]); - - dictionary.AddOrUpdate(TestConstants.TestValueFive, new ViewItem(TestConstants.TestValueFive, TestConstants.NorthRegion)); - await WaitForPipeline(); - dictionary.AddOrUpdate(TestConstants.TestValueFive, new ViewItem(TestConstants.TestValueFive, TestConstants.SouthRegion)); - await WaitForPipeline(); - dictionaryView.Items.Select(static item => item.Key).Should().Contain(TestConstants.TestValueFive); - - var thirdSouth = new ViewItem(TestConstants.TestValueThree, TestConstants.SouthRegion); - dictionary.AddOrUpdate(TestConstants.TestValueThree, thirdSouth); - await WaitForPipeline(); - dictionaryView.Items.Select(static item => item.Key).Should().Contain(TestConstants.TestValueThree); - - dictionary.Remove(1); - await WaitForPipeline(); - dictionaryView.Items.Select(static item => item.Key).Should().NotContain(1); - - dictionary.Clear(); - await WaitForPipeline(); - dictionaryView.Items.Should().BeEmpty(); - dictionaryViewProperties.Should().Contain(nameof(dictionaryView.Count)); + await VerifyDynamicSecondaryDictionaryView( + dictionary, + dictionaryView, + dictionaryKeys, + north, + dictionaryViewProperties); } /// Dynamic view constructors should ignore initial probe errors and keep default state. @@ -561,16 +531,16 @@ public void DynamicViews_InitialProbeErrors_ShouldUseDefaultValues() TimeSpan.Zero, Sequencer.Immediate); - dynamicView.Items.Should().Equal(1); + _ = dynamicView.Items.Should().Equal(1); using var twoValueDynamicView = new DynamicReactiveView( source, new TwoValueObservable>(static item => item == 1, static _ => false), TimeSpan.Zero, Sequencer.Immediate); - twoValueDynamicView.Items.Should().BeEmpty(); + _ = twoValueDynamicView.Items.Should().BeEmpty(); - using var list = new QuaternaryList { new(1, TestConstants.NorthRegion) }; + using var list = new QuaternaryList { new(TestConstants.NorthRegion) }; list.AddIndex(TestConstants.RegionPropertyName, static item => item.Region); using var listView = new DynamicSecondaryIndexReactiveView( list, @@ -579,7 +549,7 @@ public void DynamicViews_InitialProbeErrors_ShouldUseDefaultValues() Sequencer.Immediate, TimeSpan.Zero); - listView.Items.Should().BeEmpty(); + _ = listView.Items.Should().BeEmpty(); using var twoValueListView = new DynamicSecondaryIndexReactiveView( list, TestConstants.RegionPropertyName, @@ -587,35 +557,36 @@ public void DynamicViews_InitialProbeErrors_ShouldUseDefaultValues() Sequencer.Immediate, TimeSpan.Zero); - twoValueListView.Items.Should().BeEmpty(); + _ = twoValueListView.Items.Should().BeEmpty(); - using var dictionary = new QuaternaryDictionary { { 1, new MutableViewItem(1, TestConstants.NorthRegion) } }; + using var dictionary = new QuaternaryDictionary { { 1, new MutableViewItem(TestConstants.NorthRegion) } }; dictionary.AddValueIndex(TestConstants.RegionPropertyName, static item => item.Region); - using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( dictionary, TestConstants.RegionPropertyName, new FirstSubscriptionErrorObservable(), Sequencer.Immediate, TimeSpan.Zero); - dictionaryView.Items.Should().BeEmpty(); - using var twoValueDictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( + _ = dictionaryView.Items.Should().BeEmpty(); + using var twoValueDictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( dictionary, TestConstants.RegionPropertyName, new TwoValueObservable([TestConstants.NorthRegion], [TestConstants.SouthRegion]), Sequencer.Immediate, TimeSpan.Zero); - twoValueDictionaryView.Items.Should().BeEmpty(); + _ = twoValueDictionaryView.Items.Should().BeEmpty(); } /// Dynamic secondary-index views should handle mutable update transitions directly. + /// A representing the asynchronous unit test. [Test] - public void DynamicSecondaryIndexViews_MutableUpdates_ShouldAddRemoveClearAndRebuild() + public async Task DynamicSecondaryIndexViews_MutableUpdates_ShouldAddRemoveClearAndRebuild() { using var list = new QuaternaryList(); - var listNorth = new MutableViewItem(1, TestConstants.NorthRegion); - var listSouth = new MutableViewItem(TestConstants.TestValueTwo, TestConstants.SouthRegion); + var listNorth = new MutableViewItem(TestConstants.NorthRegion); + var listSouth = new MutableViewItem(TestConstants.SouthRegion); list.Add(listNorth); list.Add(listSouth); list.AddIndex(TestConstants.RegionPropertyName, static item => item.Region); @@ -627,128 +598,289 @@ public void DynamicSecondaryIndexViews_MutableUpdates_ShouldAddRemoveClearAndReb Sequencer.Immediate, TimeSpan.Zero); - listView.Items.Should().ContainSingle().Which.Should().BeSameAs(listNorth); - - listNorth.Region = TestConstants.SouthRegion; - InvokePrivate(listView, TestConstants.SourceChangedMethodName, new CacheNotify(CacheAction.Updated, listNorth)); - listView.Items.Should().BeEmpty(); - - listSouth.Region = TestConstants.NorthRegion; - InvokePrivate(listView, TestConstants.SourceChangedMethodName, new CacheNotify(CacheAction.Updated, listSouth)); - listView.Items.Should().ContainSingle().Which.Should().BeSameAs(listSouth); - - InvokePrivate(listView, TestConstants.SourceChangedMethodName, new CacheNotify(CacheAction.Removed, listSouth)); - listView.Items.Should().BeEmpty(); - - InvokePrivate(listView, TestConstants.SourceChangedMethodName, new CacheNotify(CacheAction.BatchOperation, default)); - listView.Items.Count.Should().Be(1); - - InvokePrivate(listView, TestConstants.SourceChangedMethodName, new CacheNotify(CacheAction.Cleared, default)); - listView.Items.Should().BeEmpty(); + _ = listView.Items.Should().ContainSingle().Which.Should().BeSameAs(listNorth); + await VerifyMutableSecondaryListView(list, listView, listNorth, listSouth); using var dictionary = new QuaternaryDictionary(); - var dictionaryNorth = new MutableViewItem(1, TestConstants.NorthRegion); - var dictionarySouth = new MutableViewItem(TestConstants.TestValueTwo, TestConstants.SouthRegion); + var dictionaryNorth = new MutableViewItem(TestConstants.NorthRegion); + var dictionarySouth = new MutableViewItem(TestConstants.SouthRegion); dictionary.Add(1, dictionaryNorth); dictionary.Add(TestConstants.TestValueTwo, dictionarySouth); dictionary.AddValueIndex(TestConstants.RegionPropertyName, static item => item.Region); using var dictionaryKeys = new BehaviorSignal([TestConstants.NorthRegion]); - using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var dictionaryView = DynamicSecondaryIndexDictionaryReactiveView.Create( dictionary, TestConstants.RegionPropertyName, dictionaryKeys, Sequencer.Immediate, TimeSpan.Zero); - dictionaryView.Items.Should().ContainSingle() + _ = dictionaryView.Items.Should().ContainSingle() .Which.Value.Should().BeSameAs(dictionaryNorth); + await VerifyMutableSecondaryDictionaryView( + dictionary, + dictionaryView, + dictionaryNorth, + dictionarySouth); + } + + /// Verifies public dictionary operations flow through a dynamic secondary-index view. + /// The source dictionary. + /// The view under test. + /// The selected secondary-index keys. + /// The initial matching item. + /// The property notifications captured from the view. + /// A representing the asynchronous verification. + private static async Task VerifyDynamicSecondaryDictionaryView( + QuaternaryDictionary dictionary, + DynamicSecondaryIndexDictionaryReactiveView dictionaryView, + BehaviorSignal dictionaryKeys, + ViewItem north, + List dictionaryViewProperties) + { + _ = dictionaryView.Items.Should().ContainSingle().Which.Should().Be(new KeyValuePair(1, north)); + _ = dictionaryView.Count.Should().Be(1); + _ = dictionaryView[0].Key.Should().Be(1); + _ = dictionaryView.ToProperty(out var dictionaryOutCollection).Should().BeSameAs(dictionaryView); + _ = dictionaryOutCollection.Should().BeSameAs(dictionaryView.Items); + _ = dictionaryView.ToProperty(collection => collection.Should().BeSameAs(dictionaryView.Items)).Should().BeSameAs(dictionaryView); + dictionaryView.Refresh(); + _ = dictionaryView.GetEnumerator().MoveNext().Should().BeTrue(); + _ = ((IEnumerable)dictionaryView).GetEnumerator().MoveNext().Should().BeTrue(); + dictionary.AddOrUpdate(1, north with { Region = TestConstants.SouthRegion }); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().BeEmpty(); + + dictionary.AddOrUpdate(TestConstants.TestValueFour, new(TestConstants.TestValueFour, TestConstants.NorthRegion)); + await WaitForPipeline(); + _ = GetKeys(dictionaryView.Items).Should().Contain(TestConstants.TestValueFour); + + dictionary.AddOrUpdate(TestConstants.TestValueFour, new(TestConstants.TestValueFour, TestConstants.NorthRegion, Score: 10)); + await WaitForPipeline(); + _ = FindByKey(dictionaryView.Items, TestConstants.TestValueFour).Value.Score.Should().Be(TestConstants.TestValueTen); + + dictionaryKeys.OnNext([TestConstants.SouthRegion]); + await WaitForPipeline(); + _ = GetKeys(dictionaryView.Items).Should().BeEquivalentTo([1, TestConstants.TestValueTwo]); + dictionary.AddOrUpdate(TestConstants.TestValueFive, new(TestConstants.TestValueFive, TestConstants.NorthRegion)); + await WaitForPipeline(); + dictionary.AddOrUpdate(TestConstants.TestValueFive, new(TestConstants.TestValueFive, TestConstants.SouthRegion)); + await WaitForPipeline(); + _ = GetKeys(dictionaryView.Items).Should().Contain(TestConstants.TestValueFive); + + var thirdSouth = new ViewItem(TestConstants.TestValueThree, TestConstants.SouthRegion); + dictionary.AddOrUpdate(TestConstants.TestValueThree, thirdSouth); + await WaitForPipeline(); + _ = GetKeys(dictionaryView.Items).Should().Contain(TestConstants.TestValueThree); + + _ = dictionary.Remove(1); + await WaitForPipeline(); + _ = GetKeys(dictionaryView.Items).Should().NotContain(1); + + dictionary.Clear(); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().BeEmpty(); + _ = dictionaryViewProperties.Should().Contain(nameof(dictionaryView.Count)); + } + + /// Verifies public list operations flow through a dynamic secondary-index view. + /// The source list. + /// The view under test. + /// The selected secondary-index keys. + /// The initially matching item. + /// The item selected after the key changes. + /// The property notifications captured from the view. + /// A representing the asynchronous verification. + private static async Task VerifyDynamicSecondaryListView( + QuaternaryList list, + DynamicSecondaryIndexReactiveView listView, + BehaviorSignal listKeys, + ViewItem north, + ViewItem south, + List listViewProperties) + { + _ = listView.Items.Should().ContainSingle().Which.Should().Be(north); + _ = listView.Count.Should().Be(1); + _ = listView[0].Should().Be(north); + _ = listView.ToProperty(out var listOutCollection).Should().BeSameAs(listView); + _ = listOutCollection.Should().BeSameAs(listView.Items); + _ = listView.ToProperty(collection => collection.Should().BeSameAs(listView.Items)).Should().BeSameAs(listView); + listView.Refresh(); + _ = listView.GetEnumerator().MoveNext().Should().BeTrue(); + _ = ((IEnumerable)listView).GetEnumerator().MoveNext().Should().BeTrue(); + _ = list.Remove(north); + await WaitForPipeline(); + _ = listView.Items.Should().BeEmpty(); + list.Add(north); + await WaitForPipeline(); + _ = listView.Items.Should().ContainSingle().Which.Should().Be(north); + listKeys.OnNext([TestConstants.SouthRegion]); + await WaitForPipeline(); + _ = listView.Items.Should().ContainSingle().Which.Should().Be(south); + var secondSouth = new ViewItem(TestConstants.TestValueThree, TestConstants.SouthRegion); + list.Add(secondSouth); + await WaitForPipeline(); + _ = listView.Items.Should().BeEquivalentTo([south, secondSouth]); + list.ReplaceAll([north]); + await WaitForPipeline(); + _ = listView.Items.Should().BeEmpty(); + _ = listViewProperties.Should().Contain(nameof(listView.Count)); + } + + /// Verifies mutable dictionary items through public update, remove, add, and clear operations. + /// The source dictionary. + /// The dynamic view under test. + /// The initially matching item. + /// The item mutated into the selected index. + /// A representing the asynchronous verification. + private static async Task VerifyMutableSecondaryDictionaryView( + QuaternaryDictionary dictionary, + DynamicSecondaryIndexDictionaryReactiveView dictionaryView, + MutableViewItem dictionaryNorth, + MutableViewItem dictionarySouth) + { dictionaryNorth.Region = TestConstants.SouthRegion; - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Updated, - new KeyValuePair(1, dictionaryNorth))); - dictionaryView.Items.Should().BeEmpty(); + dictionary.AddOrUpdate(1, dictionaryNorth); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().BeEmpty(); dictionarySouth.Region = TestConstants.NorthRegion; - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Updated, - new KeyValuePair(TestConstants.TestValueTwo, dictionarySouth))); - dictionaryView.Items.Should().ContainSingle() + dictionary.AddOrUpdate(TestConstants.TestValueTwo, dictionarySouth); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().ContainSingle() .Which.Value.Should().BeSameAs(dictionarySouth); dictionarySouth.Score = TestConstants.TestValueTen; - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Updated, - new KeyValuePair(TestConstants.TestValueTwo, dictionarySouth))); - dictionaryView.Items.Single().Value.Score.Should().Be(TestConstants.TestValueTen); + dictionary.AddOrUpdate(TestConstants.TestValueTwo, dictionarySouth); + await WaitForPipeline(); + _ = dictionaryView.Items[0].Value.Score.Should().Be(TestConstants.TestValueTen); - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Removed, - new KeyValuePair(TestConstants.TestValueTwo, dictionarySouth))); - dictionaryView.Items.Should().BeEmpty(); + _ = dictionary.Remove(TestConstants.TestValueTwo); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().BeEmpty(); - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Refreshed, - default)); - dictionaryView.Items.Count.Should().Be(1); + dictionary.AddOrUpdate(TestConstants.TestValueTwo, dictionarySouth); + await WaitForPipeline(); + await TUnit.Assertions.Assert.That(dictionaryView.Items.Count).IsEqualTo(1); - InvokePrivate(dictionaryView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Cleared, - default)); - dictionaryView.Items.Should().BeEmpty(); + dictionary.Clear(); + await WaitForPipeline(); + _ = dictionaryView.Items.Should().BeEmpty(); - using var nullableKeyDictionary = new QuaternaryDictionary - { - { "north-1", new MutableViewItem(TestConstants.TestValueThree, TestConstants.NorthRegion) }, - }; + using var nullableKeyDictionary = new QuaternaryDictionary { { "north-1", new MutableViewItem(TestConstants.NorthRegion) }, }; nullableKeyDictionary.AddValueIndex(TestConstants.RegionPropertyName, static item => item.Region); using var nullableKeyKeys = new BehaviorSignal([TestConstants.NorthRegion]); - using var nullableKeyView = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var nullableKeyView = DynamicSecondaryIndexDictionaryReactiveView.Create( nullableKeyDictionary, TestConstants.RegionPropertyName, nullableKeyKeys, Sequencer.Immediate, TimeSpan.Zero); - InvokePrivate(nullableKeyView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Removed, - new KeyValuePair(null!, new MutableViewItem(TestConstants.TestValueFour, TestConstants.NorthRegion)))); - nullableKeyView.Items.Count.Should().Be(1); + await TUnit.Assertions.Assert.That(nullableKeyDictionary.Remove(TestConstants.MissingKey)).IsFalse(); + await TUnit.Assertions.Assert.That(nullableKeyView.Items.Count).IsEqualTo(1); + } + + /// Verifies mutable list items through public rebuild, remove, add, and clear operations. + /// The source list. + /// The dynamic view under test. + /// The initially matching item. + /// The item mutated into the selected index. + /// A representing the asynchronous verification. + private static async Task VerifyMutableSecondaryListView( + QuaternaryList list, + DynamicSecondaryIndexReactiveView listView, + MutableViewItem listNorth, + MutableViewItem listSouth) + { + listNorth.Region = TestConstants.SouthRegion; + list.ReplaceAll([listNorth, listSouth]); + await WaitForPipeline(); + _ = listView.Items.Should().BeEmpty(); + + listSouth.Region = TestConstants.NorthRegion; + list.ReplaceAll([listNorth, listSouth]); + await WaitForPipeline(); + _ = listView.Items.Should().ContainSingle().Which.Should().BeSameAs(listSouth); + + _ = list.Remove(listSouth); + await WaitForPipeline(); + _ = listView.Items.Should().BeEmpty(); - InvokePrivate(nullableKeyView, TestConstants.SourceChangedMethodName, new CacheNotify>( - CacheAction.Removed, - new KeyValuePair(TestConstants.MissingKey, new MutableViewItem(TestConstants.TestValueFive, TestConstants.NorthRegion)))); - nullableKeyView.Items.Count.Should().Be(1); + list.Add(listSouth); + await WaitForPipeline(); + await TUnit.Assertions.Assert.That(listView.Items.Count).IsEqualTo(1); + + list.Clear(); + await WaitForPipeline(); + _ = listView.Items.Should().BeEmpty(); } #endif /// Provides WaitForPipeline. /// The result. - private static async Task WaitForPipeline() => await Task.Delay(TestConstants.TestValueThirty); + private static Task WaitForPipeline() => Task.Delay(TestConstants.TestValueThirty); - /// Provides InvokePrivate. - /// The target value. - /// The methodName value. - /// The args value. - /// The result. - private static object? InvokePrivate(object target, string methodName, params object?[] args) + /// Counts entries exposed through a non-generic collection surface. + /// The entries to count. + /// The number of exposed entries. + private static int CountEntries(IEnumerable items) { - var targetType = target.GetType(); - var method = targetType.GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMethodException(targetType.FullName, methodName); - return method.Invoke(target, args); + var count = 0; + foreach (var _ in items) + { + count++; + } + + return count; } - /// Provides GetPrivateField. - /// The target value. - /// The fieldName value. - /// The result. - private static object GetPrivateField(object target, string fieldName) + /// Finds a dictionary-view entry by its key. + /// The current dictionary-view entries. + /// The key to locate. + /// The matching entry. + private static KeyValuePair FindByKey( + IEnumerable> items, + int key) { - var targetType = target.GetType(); - var field = targetType.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingFieldException(targetType.FullName, fieldName); - return field.GetValue(target) ?? throw new InvalidOperationException($"Field '{fieldName}' returned null."); + foreach (var item in items) + { + if (item.Key == key) + { + return item; + } + } + + throw new KeyNotFoundException($"No view item exists for key '{key}'."); + } + + /// Flattens grouped items without relying on a LINQ iterator. + /// The groups to flatten. + /// All items in group enumeration order. + private static List FlattenGroups(IEnumerable> groups) + { + var items = new List(); + foreach (var group in groups) + { + items.AddRange(group); + } + + return items; + } + + /// Gets dictionary-view keys without allocating a LINQ iterator. + /// The current dictionary-view entries. + /// The current entry keys. + private static List GetKeys(IEnumerable> items) + { + var keys = new List(); + foreach (var item in items) + { + keys.Add(item.Key); + } + + return keys; } /// Provides CreateBatch. @@ -759,17 +891,13 @@ private static PooledBatch CreateBatch(params T[] items) { var array = ArrayPool.Shared.Rent(items.Length); Array.Copy(items, array, items.Length); - return new PooledBatch(array, items.Length); + return new(array, items.Length); } /// Provides MutableViewItem. - /// The id value. /// The region value. - private sealed class MutableViewItem(int id, string region) + private sealed class MutableViewItem(string region) { - /// Gets Id. - public int Id { get; } = id; - /// Gets or sets Region. public string Region { get; set; } = region; @@ -781,6 +909,7 @@ private sealed class MutableViewItem(int id, string region) /// The T type. private sealed class FirstSubscriptionErrorObservable : IObservable { + /// The number of subscriptions received by this observable. private int _subscriptions; /// Provides Subscribe. @@ -819,14 +948,17 @@ public IDisposable Subscribe(IObserver observer) private sealed class ReactiveSourceHarness : IReactiveSource where T : notnull { + /// The mutable source items exposed by this harness. private readonly List _items; + /// The source notification stream. private readonly Signal> _stream = new(); /// Initializes a new instance of the ReactiveSourceHarness class. /// The items value. public ReactiveSourceHarness(IEnumerable items) => _items = new(items); + /// Occurs when the harness collection changes. public event NotifyCollectionChangedEventHandler? CollectionChanged; /// Gets Count. @@ -850,6 +982,7 @@ public void AddItem(T item) { _items.Add(item); Version++; + RaiseReset(); } /// Provides AddItems. @@ -858,6 +991,7 @@ public void AddItems(IEnumerable items) { _items.AddRange(items); Version++; + RaiseReset(); } /// Provides ClearItems. @@ -865,6 +999,7 @@ public void ClearItems() { _items.Clear(); Version++; + RaiseReset(); } /// Provides Dispose. @@ -891,8 +1026,9 @@ public void Dispose() /// The item value. public void RemoveItem(T item) { - _items.Remove(item); + _ = _items.Remove(item); Version++; + RaiseReset(); } /// Provides RemoveItems. @@ -901,10 +1037,11 @@ public void RemoveItems(IEnumerable items) { foreach (var item in items) { - _items.Remove(item); + _ = _items.Remove(item); } Version++; + RaiseReset(); } /// Provides ToArray. @@ -913,8 +1050,8 @@ public void RemoveItems(IEnumerable items) IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - /// Provides RaiseReset. - public void RaiseReset() => + /// Raises a reset notification after the harness mutates. + private void RaiseReset() => CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } diff --git a/src/ReactiveList.Test/ViewToPropertyTests.cs b/src/ReactiveList.Test/ViewToPropertyTests.cs index af8a187..cb0259b 100644 --- a/src/ReactiveList.Test/ViewToPropertyTests.cs +++ b/src/ReactiveList.Test/ViewToPropertyTests.cs @@ -26,14 +26,14 @@ public void ReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameInstance using var view = new ReactiveView( subject, ["test"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// ReactiveView ToProperty with action setter should throw when setter is null. @@ -45,13 +45,13 @@ public void ReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() using var view = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -64,14 +64,14 @@ public void ReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance( using var view = new ReactiveView( subject, ["test"], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -80,7 +80,7 @@ public void ReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance( public void DynamicReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameInstance() { using var list = new QuaternaryList { "test" }; - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); ReadOnlyObservableCollection? capturedItems = null; using var view = new DynamicReactiveView( @@ -91,8 +91,8 @@ public void DynamicReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameI var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// DynamicReactiveView ToProperty with action setter should throw when setter is null. @@ -100,7 +100,7 @@ public void DynamicReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameI public void DynamicReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() { using var list = new QuaternaryList(); - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); using var view = new DynamicReactiveView( list, @@ -110,7 +110,7 @@ public void DynamicReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -119,7 +119,7 @@ public void DynamicReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() public void DynamicReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance() { using var list = new QuaternaryList { "test" }; - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); using var view = new DynamicReactiveView( list, @@ -129,8 +129,8 @@ public void DynamicReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameIn var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } #endif @@ -151,9 +151,9 @@ public void SortedReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameIn var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); - capturedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree], options => options.WithStrictOrdering()); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); + _ = capturedItems.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree], static options => options.WithStrictOrdering()); } /// SortedReactiveView ToProperty with action setter should throw when setter is null. @@ -170,7 +170,7 @@ public void SortedReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -190,9 +190,9 @@ public void SortedReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameIns var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); - collection.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree], options => options.WithStrictOrdering()); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); + _ = collection.Should().BeEquivalentTo([1, TestData.TestValueTwo, TestData.TestValueThree], static options => options.WithStrictOrdering()); } /// FilteredReactiveView ToProperty with action setter should set property and return same instance. @@ -205,15 +205,15 @@ public void FilteredReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSame using var view = new FilteredReactiveView( list, - x => x > TestData.TestValueTwo, + static x => x > TestData.TestValueTwo, Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); - capturedItems.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); + _ = capturedItems.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// FilteredReactiveView ToProperty with action setter should throw when setter is null. @@ -224,13 +224,13 @@ public void FilteredReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() using var view = new FilteredReactiveView( list, - _ => true, + static _ => true, Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -243,15 +243,15 @@ public void FilteredReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameI using var view = new FilteredReactiveView( list, - x => x > TestData.TestValueTwo, + static x => x > TestData.TestValueTwo, Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); - collection.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); + _ = collection.Should().BeEquivalentTo([TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); } /// GroupedReactiveView ToProperty with action setter should set property and return same instance. @@ -264,15 +264,15 @@ public void GroupedReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameI using var view = new GroupedReactiveView( list, - s => s[0], + static s => s[0], Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var result = view.ToProperty(groups => capturedGroups = groups); - result.Should().BeSameAs(view); - capturedGroups.Should().BeSameAs(view.Groups); - capturedGroups.Should().HaveCount(TestData.TestValueTwo); + _ = result.Should().BeSameAs(view); + _ = capturedGroups.Should().BeSameAs(view.Groups); + _ = capturedGroups.Should().HaveCount(TestData.TestValueTwo); } /// GroupedReactiveView ToProperty with action setter should throw when setter is null. @@ -283,13 +283,13 @@ public void GroupedReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() using var view = new GroupedReactiveView( list, - s => s[0], + static s => s[0], Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var act = () => view.ToProperty((Action>>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -302,15 +302,15 @@ public void GroupedReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameIn using var view = new GroupedReactiveView( list, - s => s[0], + static s => s[0], Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Groups); - collection.Should().HaveCount(TestData.TestValueTwo); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Groups); + _ = collection.Should().HaveCount(TestData.TestValueTwo); } /// GroupedReactiveView Items property should be same as Groups property. @@ -321,11 +321,11 @@ public void GroupedReactiveView_Items_ShouldBeSameAsGroups() using var view = new GroupedReactiveView( list, - s => s[0], + static s => s[0], Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); - view.Items.Should().BeSameAs(view.Groups); + _ = view.Items.Should().BeSameAs(view.Groups); } /// DynamicFilteredReactiveView ToProperty with action setter should set property and return same instance. @@ -334,7 +334,7 @@ public void DynamicFilteredReactiveView_ToPropertyAction_ShouldSetPropertyAndRet { using var list = new ReactiveList(); list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); - var filterSubject = new BehaviorSignal>(x => x > TestData.TestValueTwo); + var filterSubject = new BehaviorSignal>(static x => x > TestData.TestValueTwo); ReadOnlyObservableCollection? capturedItems = null; using var view = new DynamicFilteredReactiveView( @@ -345,8 +345,8 @@ public void DynamicFilteredReactiveView_ToPropertyAction_ShouldSetPropertyAndRet var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// DynamicFilteredReactiveView ToProperty with action setter should throw when setter is null. @@ -354,7 +354,7 @@ public void DynamicFilteredReactiveView_ToPropertyAction_ShouldSetPropertyAndRet public void DynamicFilteredReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() { using var list = new ReactiveList(); - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); using var view = new DynamicFilteredReactiveView( list, @@ -364,7 +364,7 @@ public void DynamicFilteredReactiveView_ToPropertyAction_WithNullSetter_ShouldTh var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -374,7 +374,7 @@ public void DynamicFilteredReactiveView_ToPropertyOut_ShouldSetCollectionAndRetu { using var list = new ReactiveList(); list.AddRange([1, TestData.TestValueTwo, TestData.TestValueThree, TestData.TestValueFour, TestData.TestValueFive]); - var filterSubject = new BehaviorSignal>(x => x > TestData.TestValueTwo); + var filterSubject = new BehaviorSignal>(static x => x > TestData.TestValueTwo); using var view = new DynamicFilteredReactiveView( list, @@ -384,8 +384,8 @@ public void DynamicFilteredReactiveView_ToPropertyOut_ShouldSetCollectionAndRetu var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -394,13 +394,13 @@ public void DynamicFilteredReactiveView_ToPropertyOut_ShouldSetCollectionAndRetu public void SecondaryIndexReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameInstance() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); dict[1] = new(TestData.AliceName, "A"); dict[TestData.TestValueTwo] = new("Bob", "B"); dict[TestData.TestValueThree] = new("Charlie", "A"); ReadOnlyObservableCollection? capturedItems = null; - using var view = SecondaryIndexReactiveView.Create( + using var view = SecondaryIndexReactiveView.Create( dict, TestData.CategoryPropertyName, "A", @@ -409,9 +409,9 @@ public void SecondaryIndexReactiveView_ToPropertyAction_ShouldSetPropertyAndRetu var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); - capturedItems.Should().HaveCount(TestData.TestValueTwo); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); + _ = capturedItems.Should().HaveCount(TestData.TestValueTwo); } /// SecondaryIndexReactiveView ToProperty with action setter should throw when setter is null. @@ -419,9 +419,9 @@ public void SecondaryIndexReactiveView_ToPropertyAction_ShouldSetPropertyAndRetu public void SecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); - using var view = SecondaryIndexReactiveView.Create( + using var view = SecondaryIndexReactiveView.Create( dict, TestData.CategoryPropertyName, "A", @@ -430,7 +430,7 @@ public void SecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_ShouldThr var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -439,11 +439,11 @@ public void SecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_ShouldThr public void SecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); dict[1] = new(TestData.AliceName, "A"); dict[TestData.TestValueTwo] = new("Bob", "B"); - using var view = SecondaryIndexReactiveView.Create( + using var view = SecondaryIndexReactiveView.Create( dict, TestData.CategoryPropertyName, "A", @@ -452,8 +452,8 @@ public void SecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionAndRetur var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } /// DynamicSecondaryIndexReactiveView ToProperty with action setter should set property and return same instance. @@ -461,9 +461,9 @@ public void SecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionAndRetur public void DynamicSecondaryIndexReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameInstance() { using var list = new QuaternaryList(); - list.AddIndex(TestData.CategoryPropertyName, p => p.Category); - list.Add(new TestPerson(TestData.AliceName, "A")); - list.Add(new TestPerson("Bob", "B")); + list.AddIndex(TestData.CategoryPropertyName, static p => p.Category); + list.Add(new(TestData.AliceName, "A")); + list.Add(new("Bob", "B")); var keysSubject = new BehaviorSignal(["A"]); ReadOnlyObservableCollection? capturedItems = null; @@ -476,8 +476,8 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyAction_ShouldSetProperty var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// DynamicSecondaryIndexReactiveView ToProperty with action setter should throw when setter is null. @@ -485,7 +485,7 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyAction_ShouldSetProperty public void DynamicSecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() { using var list = new QuaternaryList(); - list.AddIndex(TestData.CategoryPropertyName, p => p.Category); + list.AddIndex(TestData.CategoryPropertyName, static p => p.Category); var keysSubject = new BehaviorSignal(["A"]); using var view = new DynamicSecondaryIndexReactiveView( @@ -497,7 +497,7 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_Sh var act = () => view.ToProperty((Action>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -506,8 +506,8 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyAction_WithNullSetter_Sh public void DynamicSecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance() { using var list = new QuaternaryList(); - list.AddIndex(TestData.CategoryPropertyName, p => p.Category); - list.Add(new TestPerson(TestData.AliceName, "A")); + list.AddIndex(TestData.CategoryPropertyName, static p => p.Category); + list.Add(new(TestData.AliceName, "A")); var keysSubject = new BehaviorSignal(["A"]); using var view = new DynamicSecondaryIndexReactiveView( @@ -519,8 +519,8 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionA var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } /// DynamicSecondaryIndexDictionaryReactiveView ToProperty with action setter should set property and return same instance. @@ -528,13 +528,13 @@ public void DynamicSecondaryIndexReactiveView_ToPropertyOut_ShouldSetCollectionA public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_ShouldSetPropertyAndReturnSameInstance() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); dict[1] = new(TestData.AliceName, "A"); dict[TestData.TestValueTwo] = new("Bob", "B"); var keysSubject = new BehaviorSignal(["A"]); ReadOnlyObservableCollection>? capturedItems = null; - using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( dict, TestData.CategoryPropertyName, keysSubject, @@ -543,8 +543,8 @@ public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_ShouldS var result = view.ToProperty(items => capturedItems = items); - result.Should().BeSameAs(view); - capturedItems.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = capturedItems.Should().BeSameAs(view.Items); } /// DynamicSecondaryIndexDictionaryReactiveView ToProperty with action setter should throw when setter is null. @@ -552,10 +552,10 @@ public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_ShouldS public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_WithNullSetter_ShouldThrow() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); var keysSubject = new BehaviorSignal(["A"]); - using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( dict, TestData.CategoryPropertyName, keysSubject, @@ -564,7 +564,7 @@ public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_WithNul var act = () => view.ToProperty((Action>>)null!); - act.Should().Throw() + _ = act.Should().Throw() .WithParameterName(TestData.PropertySetterFieldName); } @@ -575,11 +575,11 @@ public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyAction_WithNul public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyOut_ShouldSetCollectionAndReturnSameInstance() { using var dict = new QuaternaryDictionary(); - dict.AddValueIndex(TestData.CategoryPropertyName, p => p.Category); + dict.AddValueIndex(TestData.CategoryPropertyName, static p => p.Category); dict[1] = new(TestData.AliceName, "A"); var keysSubject = new BehaviorSignal(["A"]); - using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( + using var view = DynamicSecondaryIndexDictionaryReactiveView.Create( dict, TestData.CategoryPropertyName, keysSubject, @@ -588,8 +588,8 @@ public void DynamicSecondaryIndexDictionaryReactiveView_ToPropertyOut_ShouldSetC var result = view.ToProperty(out var collection); - result.Should().BeSameAs(view); - collection.Should().BeSameAs(view.Items); + _ = result.Should().BeSameAs(view); + _ = collection.Should().BeSameAs(view.Items); } #endif @@ -602,11 +602,11 @@ public void AllViews_ShouldImplementIReactiveViewInterface() using var reactiveView = new ReactiveView( subject, [], - _ => true, + static _ => true, TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - reactiveView.Should().BeAssignableTo, string>>(); + _ = reactiveView.Should().BeAssignableTo, string>>(); } /// SortedReactiveView should implement IReactiveView interface. @@ -621,7 +621,7 @@ public void SortedReactiveView_ShouldImplementIReactiveViewInterface() Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); - view.Should().BeAssignableTo, int>>(); + _ = view.Should().BeAssignableTo, int>>(); } /// FilteredReactiveView should implement IReactiveView interface. @@ -632,11 +632,11 @@ public void FilteredReactiveView_ShouldImplementIReactiveViewInterface() using var view = new FilteredReactiveView( list, - _ => true, + static _ => true, Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); - view.Should().BeAssignableTo, int>>(); + _ = view.Should().BeAssignableTo, int>>(); } /// GroupedReactiveView should implement IReactiveView interface. @@ -647,11 +647,11 @@ public void GroupedReactiveView_ShouldImplementIReactiveViewInterface() using var view = new GroupedReactiveView( list, - s => s[0], + static s => s[0], Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); - view.Should().BeAssignableTo, ReactiveGroup>>(); + _ = view.Should().BeAssignableTo, ReactiveGroup>>(); } /// DynamicFilteredReactiveView should implement IReactiveView interface. @@ -659,7 +659,7 @@ public void GroupedReactiveView_ShouldImplementIReactiveViewInterface() public void DynamicFilteredReactiveView_ShouldImplementIReactiveViewInterface() { using var list = new ReactiveList(); - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); using var view = new DynamicFilteredReactiveView( list, @@ -667,7 +667,7 @@ public void DynamicFilteredReactiveView_ShouldImplementIReactiveViewInterface() Sequencer.Immediate, TimeSpan.FromMilliseconds(TestData.TestValueTen)); - view.Should().BeAssignableTo, int>>(); + _ = view.Should().BeAssignableTo, int>>(); } #if NET8_0_OR_GREATER || NETFRAMEWORK @@ -676,7 +676,7 @@ public void DynamicFilteredReactiveView_ShouldImplementIReactiveViewInterface() public void DynamicReactiveView_ShouldImplementIReactiveViewInterface() { using var list = new QuaternaryList(); - var filterSubject = new BehaviorSignal>(_ => true); + var filterSubject = new BehaviorSignal>(static _ => true); using var view = new DynamicReactiveView( list, @@ -684,7 +684,7 @@ public void DynamicReactiveView_ShouldImplementIReactiveViewInterface() TimeSpan.FromMilliseconds(TestData.TestValueTen), Sequencer.Immediate); - view.Should().BeAssignableTo, string>>(); + _ = view.Should().BeAssignableTo, string>>(); } /// Test helper record for testing person types. diff --git a/src/ReactiveList/Collections/IQuad.cs b/src/ReactiveList/Collections/IQuad.cs index 0ec7b4f..694946f 100644 --- a/src/ReactiveList/Collections/IQuad.cs +++ b/src/ReactiveList/Collections/IQuad.cs @@ -19,6 +19,10 @@ public interface IQuad : IEnumerable, IDisposable /// Gets the number of elements contained in the shard. int Count { get; } + /// Returns an enumerator that iterates through the shard. + /// An enumerator for the shard. + new IEnumerator GetEnumerator(); + /// Removes all items from the shard. /// After calling this method, the shard will be empty. This method does not modify the /// capacity of the shard, if applicable. diff --git a/src/ReactiveList/Collections/IQuaternaryDictionary.cs b/src/ReactiveList/Collections/IQuaternaryDictionary.cs index 4ee6a99..5fb33fa 100644 --- a/src/ReactiveList/Collections/IQuaternaryDictionary.cs +++ b/src/ReactiveList/Collections/IQuaternaryDictionary.cs @@ -20,6 +20,16 @@ namespace CP.Primitives.Collections; public interface IQuaternaryDictionary : IDictionary, IReactiveSource> where TKey : notnull { + /// Gets the number of key/value pairs contained in the dictionary. + new int Count { get; } + + /// Gets a value indicating whether the dictionary is read-only. + new bool IsReadOnly { get; } + + /// Returns an enumerator that iterates through the dictionary. + /// An enumerator for the dictionary. + new IEnumerator> GetEnumerator(); + /// Adds a new element with the specified key and value, or updates the value if the key already exists. /// The key of the element to add or update. Cannot be null. /// The value to set for the specified key. diff --git a/src/ReactiveList/Collections/IQuaternaryList.cs b/src/ReactiveList/Collections/IQuaternaryList.cs index b52a1e1..1602003 100644 --- a/src/ReactiveList/Collections/IQuaternaryList.cs +++ b/src/ReactiveList/Collections/IQuaternaryList.cs @@ -19,6 +19,16 @@ namespace CP.Primitives.Collections; public interface IQuaternaryList : ICollection, IReactiveSource where T : notnull { + /// Gets the number of elements contained in the list. + new int Count { get; } + + /// Gets a value indicating whether the list is read-only. + new bool IsReadOnly { get; } + + /// Returns an enumerator that iterates through the list. + /// An enumerator for the list. + new IEnumerator GetEnumerator(); + /// Adds an index to the collection using the specified name and key selector. /// If an index with the specified name already exists, this method may throw an exception or /// overwrite the existing index, depending on the implementation. Indexes can improve lookup performance for diff --git a/src/ReactiveList/Collections/IReactiveList.cs b/src/ReactiveList/Collections/IReactiveList.cs index de4e4f9..a11aa54 100644 --- a/src/ReactiveList/Collections/IReactiveList.cs +++ b/src/ReactiveList/Collections/IReactiveList.cs @@ -19,6 +19,12 @@ namespace CP.Primitives.Collections; public interface IReactiveList : IList, IList, IReadOnlyList, IReactiveSource, INotifyPropertyChanged where T : notnull { + /// Gets the number of elements contained in the list. + new int Count { get; } + + /// Gets a value indicating whether the list is read-only. + new bool IsReadOnly { get; } + /// Gets the added. /// /// The added. @@ -67,6 +73,15 @@ public interface IReactiveList : IList, IList, IReadOnlyList, IReactive /// IObservable> Removed { get; } + /// Gets or sets the element at the specified index. + /// The zero-based index of the element to get or set. + /// The element at the specified index. + new T this[int index] { get; set; } + + /// Returns an enumerator that iterates through the list. + /// An enumerator for the list. + new IEnumerator GetEnumerator(); + /// Adds the range. /// The items. void AddRange(IEnumerable items); diff --git a/src/ReactiveList/Collections/IReactiveSource.cs b/src/ReactiveList/Collections/IReactiveSource.cs index ad7c464..dbb0b1e 100644 --- a/src/ReactiveList/Collections/IReactiveSource.cs +++ b/src/ReactiveList/Collections/IReactiveSource.cs @@ -42,6 +42,10 @@ public interface IReactiveSource : IEnumerable, INotifyCollectionChanged, /// IObservable> Stream { get; } + /// Returns an enumerator that iterates through the source. + /// An enumerator for the source. + new IEnumerator GetEnumerator(); + #if NET6_0_OR_GREATER || NETFRAMEWORK /// Creates a snapshot of current items as an array. diff --git a/src/ReactiveList/Collections/QuadDictionary.cs b/src/ReactiveList/Collections/QuadDictionary.cs index 359f49c..3ad25ba 100644 --- a/src/ReactiveList/Collections/QuadDictionary.cs +++ b/src/ReactiveList/Collections/QuadDictionary.cs @@ -27,6 +27,7 @@ public sealed class QuadDictionary : IQuadMinimum ArrayPool size as a power of two. private const int MinimumSize = 16; + /// Maximum occupied fraction before the table grows. private const double LoadFactor = 0.72; /// Capacity multiplier used when growing pooled storage. @@ -38,8 +39,10 @@ public sealed class QuadDictionary : IQuadMarks the end of a bucket chain. private const int EndOfChain = -1; + /// Comparer used to hash and compare keys. private readonly IEqualityComparer _comparer; + /// Pooled storage for dictionary entries. private Entry[] _entries; /// Bucket value is the 1-based entry index, with zero representing empty. @@ -48,13 +51,16 @@ public sealed class QuadDictionary : IQuadCurrent bucket array length as a power of two. private int _bucketsLength; + /// The next unused entry position. private int _entryIndex; + /// The occupied entry count at which the table grows. private int _resizeThreshold; /// Head of the free list, or -1 when there are no free entries. private int _freeList; + /// The number of reusable entries in the free list. private int _freeCount; /// Initializes a new instance of the class with default settings. @@ -66,8 +72,8 @@ public QuadDictionary() } /// Initializes a new instance of the class. - /// Optional equality comparer for keys. - public QuadDictionary(IEqualityComparer? comparer = null) + /// The equality comparer for keys, or to use the default comparer. + public QuadDictionary(IEqualityComparer? comparer) { _comparer = comparer ?? EqualityComparer.Default; _buckets = ArrayPool.Shared.Rent(MinimumSize); @@ -346,7 +352,11 @@ public void EnsureCapacity(int capacity) IEnumerator> IEnumerable>.GetEnumerator() => new EnumeratorWrapper(this); /// - IEnumerator IEnumerable.GetEnumerator() => new EnumeratorWrapper(this); + IEnumerator> IQuad>.GetEnumerator() => + ((IEnumerable>)this).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => ((IQuad>)this).GetEnumerator(); /// Copies all keys to a list. /// The list to copy to. @@ -398,7 +408,7 @@ public void CopyTo(List> list) continue; // Skip free entries (Next < -1 means in freelist) } - list.Add(new KeyValuePair(entry.GetKey(), entry.GetValue())); + list.Add(new(entry.GetKey(), entry.GetValue())); } } @@ -407,7 +417,7 @@ public void Dispose() { if (_buckets is not null) { - ArrayPool.Shared.Return(_buckets, clearArray: false); + ArrayPool.Shared.Return(_buckets); _buckets = null!; } @@ -498,7 +508,7 @@ private void ResizeTo(int newSize) bucket = i + 1; } - ArrayPool.Shared.Return(_buckets, clearArray: false); + ArrayPool.Shared.Return(_buckets); ArrayPool.Shared.Return(_entries, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); _entries = newEntries; @@ -522,10 +532,13 @@ private void ResizeTo(int newSize) /// Enumerates the elements of a . public struct Enumerator : IEquatable { + /// The dictionary being enumerated. private readonly QuadDictionary _dictionary; + /// The next entry position to inspect. private int _index; + /// The current key/value pair. private KeyValuePair _current; /// Initializes a new instance of the struct. @@ -558,7 +571,8 @@ public bool MoveNext() { while (_index < _dictionary._entryIndex) { - ref var entry = ref _dictionary._entries[_index++]; + ref var entry = ref _dictionary._entries[_index]; + _index++; // Skip free entries (Next < -1 means in freelist) if (entry.GetNext() < EndOfChain) @@ -591,9 +605,9 @@ public bool TryGetNext(out KeyValuePair current) /// public readonly bool Equals(Enumerator other) => - ReferenceEquals(_dictionary, other._dictionary) && - _index == other._index && - EqualityComparer>.Default.Equals(_current, other._current); + ReferenceEquals(_dictionary, other._dictionary) + && _index == other._index + && EqualityComparer>.Default.Equals(_current, other._current); /// public override readonly bool Equals(object? obj) => obj is Enumerator other && Equals(other); @@ -608,10 +622,13 @@ public readonly bool Equals(Enumerator other) => /// intended for internal use and is not thread-safe. private struct EnumeratorWrapper : IEnumerator> { + /// The dictionary being enumerated. private readonly QuadDictionary _dictionary; + /// The next entry position to inspect. private int _index; + /// The current key/value pair. private KeyValuePair _current; /// Initializes a new instance of the struct. @@ -639,7 +656,8 @@ public bool MoveNext() { while (_index < _dictionary._entryIndex) { - ref var entry = ref _dictionary._entries[_index++]; + ref var entry = ref _dictionary._entries[_index]; + _index++; // Skip free entries (Next < -1 means in freelist) if (entry.GetNext() < EndOfChain) @@ -684,12 +702,16 @@ public readonly void Dispose() [DebuggerDisplay("HashCode = {_hashCode}, Key = {_key}, Value = {_value}, Next = {_next}")] private struct Entry { + /// The stored key hash code. private uint _hashCode; + /// The stored key. private TKey _key; + /// The stored value. private TValue _value; + /// The next entry in the bucket chain or encoded free-list link. private int _next; /// Gets a mutable reference to the stored value. diff --git a/src/ReactiveList/Collections/QuadList.cs b/src/ReactiveList/Collections/QuadList.cs index 4bbdecf..06622cf 100644 --- a/src/ReactiveList/Collections/QuadList.cs +++ b/src/ReactiveList/Collections/QuadList.cs @@ -17,15 +17,19 @@ namespace CP.Primitives.Collections; /// /// The type of elements in the list. [SkipLocalsInit] -public sealed class QuadList : IDisposable, IQuad +public sealed class QuadList : IQuad where T : notnull { + /// Minimum pooled array capacity. private const int MinimumSize = 16; + /// Multiplier applied when the pooled array grows. private const int CapacityGrowthFactor = 2; + /// The pooled array containing the list items. private T[] _items; + /// The number of items currently stored. private int _count; /// Initializes a new instance of the class. @@ -202,7 +206,10 @@ public void EnsureCapacity(int capacity) IEnumerator IEnumerable.GetEnumerator() => new QuadListEnumerator(this); /// - IEnumerator IEnumerable.GetEnumerator() => new QuadListEnumerator(this); + IEnumerator IQuad.GetEnumerator() => ((IEnumerable)this).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => ((IQuad)this).GetEnumerator(); /// Returns the internal array to the pool and releases resources. public void Dispose() @@ -219,7 +226,11 @@ public void Dispose() /// Adds data for the AddAssumeCapacity operation. /// The item value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void AddAssumeCapacity(T item) => _items[_count++] = item; + internal void AddAssumeCapacity(T item) + { + _items[_count] = item; + _count++; + } /// Removes data for the RemoveMatching operation. /// The removeCounts value. @@ -239,7 +250,7 @@ internal int RemoveMatching(Dictionary removeCounts, List? removedIte { if (remaining == 1) { - removeCounts.Remove(item); + _ = removeCounts.Remove(item); } else { @@ -311,8 +322,10 @@ private void Grow(int minCapacity) /// Enumerates the elements of a . public struct Enumerator : IEquatable { + /// The list being enumerated. private readonly QuadList _list; + /// The current position in the list. private int _index; /// Initializes a new instance of the struct. @@ -370,8 +383,10 @@ public bool MoveNext() /// Wrapper to implement IEnumerator for foreach support. private struct QuadListEnumerator : IEnumerator { + /// The list being enumerated. private readonly QuadList _list; + /// The current position in the list. private int _index; /// Initializes a new instance of the struct. diff --git a/src/ReactiveList/Collections/QuaternaryBase.cs b/src/ReactiveList/Collections/QuaternaryBase.cs index 335d8b9..99639ca 100644 --- a/src/ReactiveList/Collections/QuaternaryBase.cs +++ b/src/ReactiveList/Collections/QuaternaryBase.cs @@ -22,32 +22,48 @@ public abstract class QuaternaryBase : IReactiveSource, IN where TItem : notnull { /// The number of shards used for partitioning. - protected const int ShardCount = 4; + protected static readonly int ShardCount = GetShardCount(); + /// The fixed number of shards used by every closed collection type. + private const int FixedShardCount = 4; + + /// The property name used when the collection indexer changes. private const string ItemArray = "Item[]"; + /// Cached property-change arguments for . private static readonly PropertyChangedEventArgs CountPropertyChangedEventArgs = new(nameof(Count)); + /// Cached property-change arguments for the collection indexer. private static readonly PropertyChangedEventArgs ItemArrayPropertyChangedEventArgs = new(ItemArray); + /// The synchronization context captured when this collection was created. private readonly SynchronizationContext? _syncContext; + /// The channel used to queue collection notifications. private Channel>? _eventChannel; + /// The lazily initialized gate protecting event processor startup. private object? _eventGate; + /// The signal that publishes queued collection notifications. private Signal>? _pipeline; + /// Cancellation source for the event processor. private CancellationTokenSource? _cts; + /// The currently registered collection-change handlers. private NotifyCollectionChangedEventHandler? _collectionChanged; + /// The current item count. private int _count; + /// Indicates whether the event processor has started. private int _eventProcessorStarted; + /// The number of active stream subscribers. private int _hasSubscribers; + /// The collection version incremented by each emitted change. private long _version; /// Initializes a new instance of the class. @@ -98,16 +114,18 @@ public int Count /// for efficient, low-allocation event delivery. /// public IObservable> Stream => Signal.Create>(observer => - { - EnsureEventProcessorStarted(); - Interlocked.Increment(ref _hasSubscribers); - var subscription = _pipeline!.Subscribe(observer); - return Scope.Create(() => - { - subscription.Dispose(); - Interlocked.Decrement(ref _hasSubscribers); - }); - }); + { + EnsureEventProcessorStarted(); + _ = Interlocked.Increment(ref _hasSubscribers); + var subscription = _pipeline!.Subscribe(observer); + return Scope.Create( + (Subscription: subscription, Source: this), + static state => + { + state.Subscription.Dispose(); + _ = Interlocked.Decrement(ref state.Source._hasSubscribers); + }); + }); /// Gets a value indicating whether the object has been disposed. public bool IsDisposed { get; private set; } @@ -163,9 +181,9 @@ public void Clear() // Clear indices outside of locks if (!Indices.IsEmpty) { - foreach (var idx in Indices.Values) + foreach (var pair in Indices) { - idx.Clear(); + pair.Value.Clear(); } } @@ -196,12 +214,19 @@ public TItem[] ToArray() => /// Attempts to enqueue a cache event for processing and increments the version counter. /// The cache action type. /// The item associated with the action. - /// An optional batch of items. [MethodImpl(MethodImplOptions.AggressiveInlining)] - protected void Emit(CacheAction action, TItem? item, PooledBatch? batch = null) + protected void Emit(CacheAction action, TItem? item) => + Emit(action, item, null); + + /// Attempts to enqueue a cache event for processing and increments the version counter. + /// The cache action type. + /// The item associated with the action. + /// The batch of items, or for a single-item event. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void Emit(CacheAction action, TItem? item, PooledBatch? batch) { // Increment version atomically for change tracking - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); OnPropertyChanged(CountPropertyChangedEventArgs); OnPropertyChanged(ItemArrayPropertyChangedEventArgs); @@ -283,7 +308,7 @@ protected void EmitBatchAddedFromList(IList items, int count) pool[i] = items[i]; } - Emit(CacheAction.BatchAdded, default, new PooledBatch(pool, count, ReturnToPool: false)); + Emit(CacheAction.BatchAdded, default, new(pool, count, ReturnToPool: false)); } /// Raises a batch removed event for the specified items. @@ -317,7 +342,7 @@ protected void EmitOwnedBatchRemoved(TItem[] items, int count) return; } - Emit(CacheAction.BatchRemoved, default, new PooledBatch(items, count, ReturnToPool: false)); + Emit(CacheAction.BatchRemoved, default, new(items, count, ReturnToPool: false)); } /// Emits a notification that a batch of items has been removed from the list. @@ -342,7 +367,7 @@ protected void EmitBatchRemovedFromList(IList items, int count) pool[i] = items[i]; } - Emit(CacheAction.BatchRemoved, default, new PooledBatch(pool, count, ReturnToPool: false)); + Emit(CacheAction.BatchRemoved, default, new(pool, count, ReturnToPool: false)); } /// Notifies all registered indices that a new item has been added. @@ -418,6 +443,15 @@ protected virtual void Dispose(bool disposing) protected bool HasChangeObservers() => Interlocked.CompareExchange(ref _hasSubscribers, 0, 0) != 0 || _collectionChanged is not null; + /// Gets the fixed shard count while associating the cached field with the closed item type. + /// The item type associated with the closed generic collection. + /// The number of collection shards. + private static int GetShardCount() + { + _ = typeof(T); + return FixedShardCount; + } + /// Creates data for the CreateBatch operation. /// The items value. /// The count value. @@ -426,7 +460,7 @@ private static PooledBatch CreateBatch(TItem[] items, int count) { var batchItems = new TItem[count]; Array.Copy(items, batchItems, count); - return new PooledBatch(batchItems, count, ReturnToPool: false); + return new(batchItems, count, ReturnToPool: false); } /// Asynchronously processes events from the event channel until cancellation is requested. @@ -498,7 +532,6 @@ private void InvokeLegacyINCC(CacheNotify evt) CacheAction.Added => NotifyCollectionChangedAction.Add, CacheAction.Removed => NotifyCollectionChangedAction.Remove, CacheAction.Cleared => NotifyCollectionChangedAction.Reset, - CacheAction.Updated => NotifyCollectionChangedAction.Reset, _ => NotifyCollectionChangedAction.Reset }; @@ -532,7 +565,7 @@ private void EnsureEventProcessorStarted() return; } - var gate = _eventGate; + var gate = Volatile.Read(ref _eventGate); if (gate is null) { var newGate = new object(); @@ -551,7 +584,7 @@ private void EnsureEventProcessorStarted() _pipeline = new(); _cts = new(); Volatile.Write(ref _eventProcessorStarted, 1); - Task.Factory.StartNew(ProcessEventsAsync, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + _ = Task.Factory.StartNew(ProcessEventsAsync, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } } } diff --git a/src/ReactiveList/Collections/QuaternaryDictionary.cs b/src/ReactiveList/Collections/QuaternaryDictionary.cs index 536489f..312399a 100644 --- a/src/ReactiveList/Collections/QuaternaryDictionary.cs +++ b/src/ReactiveList/Collections/QuaternaryDictionary.cs @@ -17,10 +17,16 @@ namespace CP.Primitives.Collections; public class QuaternaryDictionary : QuaternaryBase, TValue>, IQuaternaryDictionary where TKey : notnull { + /// The default view throttle interval in milliseconds. + private const int DefaultThrottleMs = 50; + + /// Initial capacity used for buffers that collect removed keys. private const int InitialRemovalBufferSize = 64; + /// Multiplier applied when a removal buffer grows. private const int CapacityGrowthFactor = 2; + /// Largest buffer allocated on the stack. private const int StackAllocationThreshold = 1024; /// Gets a collection containing all keys from the underlying quads. @@ -108,6 +114,10 @@ public void Add(TKey key, TValue value) throw new ArgumentException("Key exists"); } + /// Adds the specified key/value pair to the collection. + /// The key/value pair to add. + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + /// Attempts to add the specified key and value to the cache if the key does not already exist. /// The key to add. /// The value to associate with the key. @@ -231,6 +241,11 @@ public bool Remove(TKey key) return removed; } + /// Removes the specified key/value pair from the collection. + /// The key/value pair to remove. + /// true if removed; otherwise, false. + public bool Remove(KeyValuePair item) => Contains(item) && Remove(item.Key); + /// Attempts to retrieve the value associated with the specified key. /// The key whose value to retrieve. /// When this method returns, contains the value if found. @@ -309,10 +324,21 @@ public void AddValueIndex(string name, Func keySel /// The key value to search for within the specified index. /// An enumerable collection of values that match the specified key. public IEnumerable GetValuesBySecondaryIndex(string indexName, TIndexKey key) - where TIndexKey : notnull - { - return Indices.TryGetValue(indexName, out var idx) && idx is SecondaryIndex typedIdx ? typedIdx.Lookup(key) : []; - } + where TIndexKey : notnull => + Indices.TryGetValue(indexName, out var idx) && idx is SecondaryIndex typedIdx ? typedIdx.Lookup(key) : []; + + /// Creates a reactive view filtered by a secondary index key. + /// The type of the key used in the secondary index. + /// The name of the secondary index to filter by. + /// The key value to filter on. + /// The scheduler for dispatching updates. + /// A reactive view containing values matching the secondary index key. + public SecondaryIndexReactiveView CreateViewBySecondaryIndex( + string indexName, + TIndexKey key, + ISequencer scheduler) + where TIndexKey : notnull => + CreateViewBySecondaryIndex(indexName, key, scheduler, DefaultThrottleMs); /// Creates a reactive view filtered by a secondary index key. /// The type of the key used in the secondary index. @@ -325,10 +351,9 @@ public SecondaryIndexReactiveView CreateViewBySecondaryIndex + int throttleMs) + where TIndexKey : notnull => + !Indices.TryGetValue(indexName, out var idx) || idx is not SecondaryIndex ? throw new InvalidOperationException($"Secondary index '{indexName}' does not exist or has incompatible type.") : SecondaryIndexReactiveView.Create( this, @@ -336,7 +361,6 @@ public SecondaryIndexReactiveView CreateViewBySecondaryIndexDetermines whether the specified value matches the given key in the specified secondary index. /// The type of the key used in the secondary index. @@ -345,14 +369,8 @@ public SecondaryIndexReactiveView CreateViewBySecondaryIndexThe key value to match against. /// if the value's indexed property matches the specified key; otherwise, . public bool ValueMatchesSecondaryIndex(string indexName, TValue value, TIndexKey key) - where TIndexKey : notnull - { - return Indices.TryGetValue(indexName, out var idx) && idx.MatchesKey(value, key); - } - - /// Adds the specified key/value pair to the collection. - /// The key/value pair to add. - public void Add(KeyValuePair item) => Add(item.Key, item.Value); + where TIndexKey : notnull => + Indices.TryGetValue(indexName, out var idx) && idx.MatchesKey(value, key); /// Determines whether the dictionary contains the specified key and value pair. /// The key/value pair to locate. @@ -360,19 +378,11 @@ public bool ValueMatchesSecondaryIndex(string indexName, TValue value [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Contains(KeyValuePair item) => TryGetValue(item.Key, out var v) && EqualityComparer.Default.Equals(v, item.Value); - /// Removes the specified key/value pair from the collection. - /// The key/value pair to remove. - /// true if removed; otherwise, false. - public bool Remove(KeyValuePair item) => Contains(item) && Remove(item.Key); - /// Looks up the value associated with the specified key. /// The key to look up. /// A tuple containing a boolean indicating if the key was found and the value if present. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public (bool HasValue, TValue? Value) Lookup(TKey key) - { - return TryGetValue(key, out var value) ? (true, value) : (false, default); - } + public (bool HasValue, TValue? Value) Lookup(TKey key) => TryGetValue(key, out var value) ? (true, value) : (false, default); /// Removes all entries with keys in the specified collection from the dictionary. /// The collection of keys to remove. @@ -474,7 +484,8 @@ public void CopyTo(KeyValuePair[] array, int arrayIndex) { foreach (var kvp in Quads[i]) { - array[arrayIndex++] = kvp; + array[arrayIndex] = kvp; + arrayIndex++; } } finally @@ -548,7 +559,8 @@ private static int CollectMatchingKeys( GrowKeysBuffer(ref keysBuffer, keysCount); } - keysBuffer[keysCount++] = item.Key; + keysBuffer[keysCount] = item.Key; + keysCount++; } return keysCount; @@ -666,7 +678,7 @@ private void AddRangeCore(KeyValuePair[] items) { if (rentedShardIndexes is not null) { - ArrayPool.Shared.Return(rentedShardIndexes, clearArray: false); + ArrayPool.Shared.Return(rentedShardIndexes); } } @@ -719,7 +731,7 @@ private void AddRangeCore(IList> items) { if (rentedShardIndexes is not null) { - ArrayPool.Shared.Return(rentedShardIndexes, clearArray: false); + ArrayPool.Shared.Return(rentedShardIndexes); } } @@ -947,6 +959,7 @@ private int GetCountUnsafe() /// Internal wrapper for Edit operations that bypasses locking and notifications. private sealed class QuaternaryDictEditWrapper : IDictionary { + /// The dictionary whose shards are edited. private readonly QuaternaryDictionary _parent; /// Initializes a new instance of the class. @@ -1044,6 +1057,10 @@ public void Add(TKey key, TValue value) _parent.NotifyIndicesAdded(value); } + /// Adds the specified key/value pair to the collection. + /// The key/value pair to add to the collection. The key must not already exist in the collection. + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + /// Determines whether the dictionary contains an element with the specified key. /// The key to locate in the dictionary. Cannot be null. /// if the dictionary contains an element with the specified key; otherwise, Removes the first occurrence of the specified key/value pair from the collection. + /// The key/value pair to remove from the collection. The pair is removed only if both the key and value match + /// an entry in the collection. + /// true if the key/value pair was found and removed; otherwise, false. + public bool Remove(KeyValuePair item) => Contains(item) && Remove(item.Key); + /// Attempts to retrieve the value associated with the specified key. /// The key whose value to retrieve. /// When this method returns, contains the value associated with the specified key, if the key is found; @@ -1086,10 +1109,6 @@ public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) return found; } - /// Adds the specified key/value pair to the collection. - /// The key/value pair to add to the collection. The key must not already exist in the collection. - public void Add(KeyValuePair item) => Add(item.Key, item.Value); - /// Removes all items from the collection, resetting it to an empty state. /// This method clears all data from the collection, including any associated indices. /// After calling this method, the collection will contain no items and all indices will be empty. This @@ -1106,9 +1125,9 @@ public void Clear() return; } - foreach (var idx in _parent.Indices.Values) + foreach (var pair in _parent.Indices) { - idx.Clear(); + pair.Value.Clear(); } } @@ -1134,17 +1153,12 @@ public void CopyTo(KeyValuePair[] array, int arrayIndex) { foreach (var kvp in _parent.Quads[i]) { - array[arrayIndex++] = kvp; + array[arrayIndex] = kvp; + arrayIndex++; } } } - /// Removes the first occurrence of the specified key/value pair from the collection. - /// The key/value pair to remove from the collection. The pair is removed only if both the key and value match - /// an entry in the collection. - /// true if the key/value pair was found and removed; otherwise, false. - public bool Remove(KeyValuePair item) => Contains(item) && Remove(item.Key); - /// Returns an enumerator that iterates through the collection of key/value pairs in the dictionary. /// An enumerator for the collection of key/value pairs contained in the dictionary. public IEnumerator> GetEnumerator() diff --git a/src/ReactiveList/Collections/QuaternaryList.cs b/src/ReactiveList/Collections/QuaternaryList.cs index cc456bd..e51164d 100644 --- a/src/ReactiveList/Collections/QuaternaryList.cs +++ b/src/ReactiveList/Collections/QuaternaryList.cs @@ -22,14 +22,19 @@ namespace CP.Primitives.Collections; public class QuaternaryList : QuaternaryBase, IQuaternaryList where T : notnull { + /// Initial capacity used for buffers that collect removed items. private const int InitialRemovalBufferSize = 64; + /// Multiplier applied when a removal buffer grows. private const int CapacityGrowthFactor = 2; + /// Largest buffer allocated on the stack. private const int StackAllocationThreshold = 1024; + /// The zero-based index of the third shard. private const int ThirdShardIndex = 2; + /// The zero-based index of the fourth shard. private const int FourthShardIndex = 3; /// @@ -195,7 +200,8 @@ public int RemoveMany(Func predicate) removedBuffer = newBuffer; } - removedBuffer[removedCount++] = item; + removedBuffer[removedCount] = item; + removedCount++; totalRemoved++; } } @@ -310,10 +316,8 @@ public void AddIndex(string name, Func keySelector) /// The key value to search for within the specified index. /// An enumerable collection of entities that match the specified key. public IEnumerable GetItemsBySecondaryIndex(string indexName, TKey key) - where TKey : notnull - { - return Indices.TryGetValue(indexName, out var idx) && idx is SecondaryIndex typedIdx ? typedIdx.Lookup(key) : []; - } + where TKey : notnull => + Indices.TryGetValue(indexName, out var idx) && idx is SecondaryIndex typedIdx ? typedIdx.Lookup(key) : []; /// Determines whether the specified item matches the given key in the specified secondary index. /// The type of the key used in the secondary index. @@ -322,10 +326,8 @@ public IEnumerable GetItemsBySecondaryIndex(string indexName, TKey key) /// The key value to match against. /// if the item's indexed value matches the specified key; otherwise, . public bool ItemMatchesSecondaryIndex(string indexName, T item, TKey key) - where TKey : notnull - { - return Indices.TryGetValue(indexName, out var idx) && idx.MatchesKey(item, key); - } + where TKey : notnull => + Indices.TryGetValue(indexName, out var idx) && idx.MatchesKey(item, key); /// Determines whether the collection contains a specific value. /// The value to locate. @@ -540,7 +542,8 @@ private static void PopulateReplacementBuckets(T[] items, T[][] bucketArrays, Sp { var item = items[i]; var shardIndex = GetShardIndex(item); - bucketArrays[shardIndex][bucketIndexes[shardIndex]++] = item; + bucketArrays[shardIndex][bucketIndexes[shardIndex]] = item; + bucketIndexes[shardIndex]++; } } @@ -567,9 +570,9 @@ private void ClearItemsAndIndices() } SetCount(0); - foreach (var index in Indices.Values) + foreach (var pair in Indices) { - index.Clear(); + pair.Value.Clear(); } } @@ -712,7 +715,7 @@ private void AddRangeCore(T[] items) { if (rentedShardIndexes is not null) { - ArrayPool.Shared.Return(rentedShardIndexes, clearArray: false); + ArrayPool.Shared.Return(rentedShardIndexes); } } @@ -768,7 +771,7 @@ private void AddRangeCore(IList items) { if (rentedShardIndexes is not null) { - ArrayPool.Shared.Return(rentedShardIndexes, clearArray: false); + ArrayPool.Shared.Return(rentedShardIndexes); } } @@ -1015,8 +1018,10 @@ private int GetCountUnsafe() /// Internal wrapper for Edit operations that bypasses locking and notifications. private sealed class QuaternaryEditWrapper : ICollection { + /// The list whose shards are edited. private readonly QuaternaryList _parent; + /// Indicates whether secondary indexes must be maintained. private readonly bool _hasIndices; /// Initializes a new instance of the class. @@ -1045,37 +1050,6 @@ public int Count /// Gets a value indicating whether the collection is read-only. public bool IsReadOnly => false; - /// Gets the element at the specified index across all shards. - /// This indexer provides read-only access to elements as if the sharded collection were - /// a single contiguous list. Setting elements by index is not supported and will throw an exception. - /// The zero-based index of the element to get. Must be greater than or equal to 0 and less than the total - /// number of elements in all shards. - /// The element at the specified index in the combined sharded collection. - /// Thrown when is less than 0 or greater than or equal to the total number of elements - /// in all shards. - /// Thrown when attempting to set an element by index, as direct replacement is not supported in the sharded - /// list. - public T this[int index] - { - get - { - for (var i = 0; i < ShardCount; i++) - { - var quadCount = _parent.Quads[i].Count; - if (index < quadCount) - { - return _parent.Quads[i][index]; - } - - index -= quadCount; - } - - throw new ArgumentOutOfRangeException(nameof(index)); - } - - set => throw new NotSupportedException("Direct index replacement in sharded list is unstable."); - } - /// Adds the specified item to the collection. /// The item to add to the collection. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -1091,19 +1065,6 @@ public void Add(T item) _parent.NotifyIndicesAdded(item); } - /// Adds the elements of the specified collection to the end of the collection. - /// The order of the elements in the input collection is preserved. If any element in the - /// input collection is invalid for the collection, an exception may be thrown when adding that - /// element. - /// The collection whose elements should be added to the end of the collection. Cannot be null. - public void AddRange(IEnumerable items) - { - foreach (var item in items) - { - Add(item); - } - } - /// Removes the specified item from the collection. /// The item to remove from the collection. /// true if the item was successfully removed; otherwise, false. @@ -1139,9 +1100,9 @@ public void Clear() return; } - foreach (var idx in _parent.Indices.Values) + foreach (var pair in _parent.Indices) { - idx.Clear(); + pair.Value.Clear(); } } diff --git a/src/ReactiveList/Collections/Reactive2DList.cs b/src/ReactiveList/Collections/Reactive2DList.cs index 9f0a55c..a122bdc 100644 --- a/src/ReactiveList/Collections/Reactive2DList.cs +++ b/src/ReactiveList/Collections/Reactive2DList.cs @@ -136,8 +136,16 @@ public void ClearInner(int outerIndex) /// Returns a flattened sequence containing all items from each inner collection in the order they appear. /// An that enumerates all items from the inner collections. The sequence is empty if /// there are no items. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public IEnumerable Flatten() => Items.SelectMany(innerList => innerList.Items); + public IEnumerable Flatten() + { + foreach (var innerList in Items) + { + foreach (var item in innerList.Items) + { + yield return item; + } + } + } /// Retrieves the element at the specified inner index within the list located at the specified outer index. /// The zero-based index of the outer list from which to retrieve the inner list. Must be greater than or equal to 0 @@ -165,17 +173,6 @@ public T GetItem(int outerIndex, int innerIndex) return innerList[innerIndex]; } - /// Inserts the elements of a collection into the list at the specified index. - /// The zero-based index at which the new elements should be inserted. - /// The collection of elements to insert into the list. Cannot be null. - /// Thrown if is null. - public void Insert(int index, IEnumerable items) - { - ThrowHelper.ThrowIfNull(items); - - base.Insert(index, [.. items]); - } - /// Inserts an item into the collection at the specified index. /// The zero-based index at which the item should be inserted. /// The item to insert into the collection. Cannot be null. @@ -202,6 +199,17 @@ public void Insert(int index, IEnumerable items, int innerIndex) this[index].InsertRange(innerIndex, items); } + /// Inserts the elements of a collection into the list at the specified index as a new row. + /// The zero-based index at which the new row should be inserted. + /// The collection of elements to insert as a row. Cannot be null. + /// Thrown if is null. + public void InsertRange(int index, IEnumerable items) + { + ThrowHelper.ThrowIfNull(items); + + base.Insert(index, [.. items]); + } + /// Removes the element at the specified index from the inner collection at the given outer index. /// The zero-based index of the outer collection containing the inner collection from which to remove the element. /// Must be greater than or equal to 0 and less than the total number of outer collections. @@ -284,7 +292,8 @@ private static ReactiveList[] CreateRows(IEnumerable> items) var index = 0; foreach (var item in collection) { - rows[index++] = new(item); + rows[index] = new(item); + index++; } return rows; @@ -314,7 +323,8 @@ private static ReactiveList[] CreateSingleItemRows(IEnumerable items) var index = 0; foreach (var item in collection) { - rows[index++] = new(item); + rows[index] = new(item); + index++; } return rows; diff --git a/src/ReactiveList/Collections/ReactiveList.cs b/src/ReactiveList/Collections/ReactiveList.cs index ba5aed4..ae5c79a 100644 --- a/src/ReactiveList/Collections/ReactiveList.cs +++ b/src/ReactiveList/Collections/ReactiveList.cs @@ -27,67 +27,91 @@ namespace CP.Primitives.Collections; public class ReactiveList : IReactiveList where T : notnull { + /// The multiplier used when growing pooled buffers. private const int BufferGrowthFactor = 2; + /// The property name used for indexed item notifications. private const string ItemArray = "Item[]"; + /// The smallest pooled buffer rented for bulk removals. private const int MinimumRemovalBufferSize = 16; + /// The divisor used to estimate an initial bulk-removal buffer size. private const int RemovalBufferSizingDivisor = 4; + /// The cached property-change arguments for count updates. private static readonly PropertyChangedEventArgs CountPropertyChangedEventArgs = new(nameof(Count)); + /// The cached property-change arguments for indexed item updates. private static readonly PropertyChangedEventArgs ItemArrayPropertyChangedEventArgs = new(ItemArray); + /// The cached collection-reset event arguments. private static readonly NotifyCollectionChangedEventArgs ResetCollectionChangedEventArgs = new(NotifyCollectionChangedAction.Reset); + /// The serialized list that stores the collection contents. private readonly List _internalList = []; + /// The synchronization primitive recreated lazily after deserialization. [NonSerialized] - private Lock _lock = new(); + private Lock? _lock = new(); + /// The lazily created stream of added items. [NonSerialized] private IObservable>? _added; + /// The lazily created stream of changed items. [NonSerialized] private IObservable>? _changed; + /// The signal containing the latest collection snapshot. [NonSerialized] private BehaviorSignal>? _currentItems; + /// The lazily created stream of removed items. [NonSerialized] private IObservable>? _removed; + /// The public read-only observable view of all items. [NonSerialized] private ReadOnlyObservableCollection? _items; + /// The public read-only observable view of recently added items. [NonSerialized] private ReadOnlyObservableCollection? _itemsAdded; + /// The mutable collection backing . [NonSerialized] private RangeObservableCollection? _itemsAddedCollection; + /// The public read-only observable view of recently changed items. [NonSerialized] private ReadOnlyObservableCollection? _itemsChanged; + /// The mutable collection backing . [NonSerialized] private RangeObservableCollection? _itemsChangedCollection; + /// The public read-only observable view of recently removed items. [NonSerialized] private ReadOnlyObservableCollection? _itemsRemoved; + /// The mutable collection backing . [NonSerialized] private RangeObservableCollection? _itemsRemovedCollection; + /// The mutable observable collection mirroring the serialized list. [NonSerialized] private RangeObservableCollection? _observableItems; + /// The signal pipeline that publishes cache notifications. [NonSerialized] private Signal>? _streamPipeline; + /// Indicates whether this list has been disposed. [NonSerialized] private bool _disposed; + /// The monotonically increasing collection version. [NonSerialized] private long _version; @@ -128,10 +152,19 @@ public ReactiveList(T item) /// public event PropertyChangedEventHandler? PropertyChanged; + /// Defines the bulk-removal contract used by the observable collection helper. + private interface IRangeRemovable + { + /// Removes a contiguous range of items. + /// The index of the first item to remove. + /// The number of items to remove. + void RemoveRange(int index, int count); + } + /// Gets the added during the last change as an Observable. /// The added. public IObservable> Added => _added ??= Stream - .Keep(n => n.Action is CacheAction.Added or CacheAction.BatchAdded) + .Keep(static n => n.Action is CacheAction.Added or CacheAction.BatchAdded) .Map(GetItemsFromNotification); /// Gets the changed during the last change as an Observable. @@ -147,7 +180,7 @@ public ReactiveList(T item) /// Gets the removed items during the last change as an Observable. /// The removed. public IObservable> Removed => _removed ??= Stream - .Keep(n => n.Action is CacheAction.Removed or CacheAction.BatchRemoved or CacheAction.Cleared) + .Keep(static n => n.Action is CacheAction.Removed or CacheAction.BatchRemoved or CacheAction.Cleared) .Map(GetItemsFromNotification); /// @@ -199,6 +232,9 @@ public ReactiveList(T item) /// public object SyncRoot => this; + /// Gets the stable synchronization primitive, creating it once when deserialization left it unset. + private Lock SynchronizationLock => LazyInitializer.EnsureInitialized(ref _lock)!; + /// object? IList.this[int index] { @@ -217,7 +253,7 @@ public T this[int index] [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Add(T item) { - lock (_lock) + lock (SynchronizationLock) { _internalList.Add(item); _observableItems!.Add(item); @@ -225,12 +261,19 @@ public void Add(T item) } } + /// + public int Add(object? value) + { + Add((T)value!); + return Count - 1; + } + /// Creates a snapshot of current items as an array. /// An array containing all current items. [MethodImpl(MethodImplOptions.AggressiveInlining)] public T[] ToArray() { - lock (_lock) + lock (SynchronizationLock) { return [.. _internalList]; } @@ -246,7 +289,7 @@ public void AddRange(ReadOnlySpan items) } var itemArray = items.ToArray(); - lock (_lock) + lock (SynchronizationLock) { var requiredCapacity = _internalList.Count + itemArray.Length; if (_internalList.Capacity < requiredCapacity) @@ -260,12 +303,34 @@ public void AddRange(ReadOnlySpan items) } } + /// + public void AddRange(IEnumerable items) + { + var itemArray = (items as T[]) ?? [.. items]; + if (itemArray.Length == 0) + { + return; + } + + lock (SynchronizationLock) + { +#if NET6_0_OR_GREATER + // Use AddRange with capacity hint for List + _ = _internalList.EnsureCapacity(_internalList.Count + itemArray.Length); +#endif + _internalList.AddRange(itemArray); + _observableItems!.AddRange(itemArray); + + NotifyAddedRange(itemArray); + } + } + /// Copies items to the specified span. /// The destination span. /// Thrown when destination is too small. public void CopyTo(Span destination) { - lock (_lock) + lock (SynchronizationLock) { if (destination.Length < _internalList.Count) { @@ -283,20 +348,84 @@ public void CopyTo(Span destination) } } + /// + public void CopyTo(T[] array, int arrayIndex) + { + lock (SynchronizationLock) + { +#if NET6_0_OR_GREATER + CollectionsMarshal.AsSpan(_internalList).CopyTo(array.AsSpan(arrayIndex)); +#else + _internalList.CopyTo(array, arrayIndex); +#endif + } + } + + /// + public void CopyTo(Array array, int index) + { + ThrowHelper.ThrowIfNull(array); + + if (array.Rank != 1) + { + throw new ArgumentException("Only single dimensional arrays are supported for the requested action.", nameof(array)); + } + + if (array.GetLowerBound(0) != 0) + { + throw new ArgumentException("The lower bound of target array must be zero.", nameof(array)); + } + +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfNegative(index); +#else + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), "Index is less than zero."); + } +#endif + + if (array.Length - index < Count) + { + throw new ArgumentException("The number of elements in the source collection is greater than the available space from index to the end of the destination array.", nameof(array)); + } + + if (array is T[] tArray) + { + CopyTo(tArray, index); + } + else if (array is object[] objects) + { + try + { + lock (SynchronizationLock) + { + foreach (var item in _internalList) + { + objects[index] = item; + index++; + } + } + } + catch (ArrayTypeMismatchException) + { + throw new ArgumentException("Invalid array type."); + } + } + } + /// Gets a read-only span over the internal list for zero-copy access. /// /// WARNING: This method does not acquire a lock. The caller must ensure thread safety. /// The returned span is only valid while no modifications are made to the list. /// /// A read-only span over the internal items. - public ReadOnlySpan AsSpan() - { + public ReadOnlySpan AsSpan() => #if NET6_0_OR_GREATER - return CollectionsMarshal.AsSpan(_internalList); + CollectionsMarshal.AsSpan(_internalList); #else - return new ReadOnlySpan([.. _internalList]); + new([.. _internalList]); #endif - } /// Gets a memory region over the internal list for async operations. /// @@ -311,10 +440,13 @@ public ReadOnlySpan AsSpan() /// This method is more efficient than Clear() when you plan to add items back to the list, /// as it avoids the overhead of reallocating the internal array. The capacity is preserved. /// - /// Whether to emit change notifications. Defaults to true. - public void ClearWithoutDeallocation(bool notifyChange = true) + public void ClearWithoutDeallocation() => ClearWithoutDeallocation(notifyChange: true); + + /// Clears all items from the list without releasing the internal array capacity. + /// Whether to emit change notifications. + public void ClearWithoutDeallocation(bool notifyChange) { - lock (_lock) + lock (SynchronizationLock) { if (_internalList.Count == 0) { @@ -350,36 +482,7 @@ public void Dispose() } /// - public int Add(object? value) - { - Add((T)value!); - return Count - 1; - } - - /// - public void AddRange(IEnumerable items) - { - var itemArray = (items as T[]) ?? [.. items]; - if (itemArray.Length == 0) - { - return; - } - - lock (_lock) - { -#if NET6_0_OR_GREATER - // Use AddRange with capacity hint for List - _internalList.EnsureCapacity(_internalList.Count + itemArray.Length); -#endif - _internalList.AddRange(itemArray); - _observableItems!.AddRange(itemArray); - - NotifyAddedRange(itemArray); - } - } - - /// - void ICollection.Clear() => Clear(); + void ICollection.Clear() => ((IList)this).Clear(); /// void IList.Clear() => Clear(); @@ -387,7 +490,7 @@ public void AddRange(IEnumerable items) /// public void Clear() { - lock (_lock) + lock (SynchronizationLock) { if (_internalList.Count == 0) { @@ -406,78 +509,14 @@ public void Clear() [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Contains(T item) { - lock (_lock) + lock (SynchronizationLock) { return _internalList.Contains(item); } } /// - public bool Contains(object? value) - { - return IsCompatibleObject(value) && Contains((T)value!); - } - - /// - public void CopyTo(T[] array, int arrayIndex) - { - lock (_lock) - { -#if NET6_0_OR_GREATER - CollectionsMarshal.AsSpan(_internalList).CopyTo(array.AsSpan(arrayIndex)); -#else - _internalList.CopyTo(array, arrayIndex); -#endif - } - } - - /// - public void CopyTo(Array array, int index) - { - ThrowHelper.ThrowIfNull(array); - - if (array.Rank != 1) - { - throw new ArgumentException("Only single dimensional arrays are supported for the requested action.", nameof(array)); - } - - if (array.GetLowerBound(0) != 0) - { - throw new ArgumentException("The lower bound of target array must be zero.", nameof(array)); - } - - if (index < 0) - { - throw new ArgumentOutOfRangeException(nameof(index), "Index is less than zero."); - } - - if (array.Length - index < Count) - { - throw new ArgumentException("The number of elements in the source collection is greater than the available space from index to the end of the destination array.", nameof(array)); - } - - if (array is T[] tArray) - { - CopyTo(tArray, index); - } - else if (array is object[] objects) - { - try - { - lock (_lock) - { - foreach (var item in _internalList) - { - objects[index++] = item; - } - } - } - catch (ArrayTypeMismatchException) - { - throw new ArgumentException("Invalid array type."); - } - } - } + public bool Contains(object? value) => IsCompatibleObject(value) && Contains((T)value!); /// Executes a batch edit operation on the list. /// The action to perform on the internal list. @@ -485,7 +524,7 @@ public void Edit(Action> editAction) { ThrowHelper.ThrowIfNull(editAction); - lock (_lock) + lock (SynchronizationLock) { var snapshot = _internalList.ToArray(); var wrapper = new EditableListWrapper(_internalList); @@ -514,7 +553,7 @@ public void Edit(Action> editAction) /// public IEnumerator GetEnumerator() { - lock (_lock) + lock (SynchronizationLock) { return ((IEnumerable)_internalList.ToArray()).GetEnumerator(); } @@ -527,23 +566,20 @@ public IEnumerator GetEnumerator() [MethodImpl(MethodImplOptions.AggressiveInlining)] public int IndexOf(T item) { - lock (_lock) + lock (SynchronizationLock) { return _internalList.IndexOf(item); } } /// - public int IndexOf(object? value) - { - return IsCompatibleObject(value) ? IndexOf((T)value!) : -1; - } + public int IndexOf(object? value) => IsCompatibleObject(value) ? IndexOf((T)value!) : -1; /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Insert(int index, T item) { - lock (_lock) + lock (SynchronizationLock) { _internalList.Insert(index, item); _observableItems?.Insert(index, item); @@ -552,10 +588,7 @@ public void Insert(int index, T item) } /// - public void Insert(int index, object? value) - { - Insert(index, (T)value!); - } + public void Insert(int index, object? value) => Insert(index, (T)value!); /// Inserts the range. /// The index. @@ -568,7 +601,7 @@ public void InsertRange(int index, IEnumerable items) return; } - lock (_lock) + lock (SynchronizationLock) { _internalList.InsertRange(index, itemArray); _observableItems?.InsertRange(index, itemArray); @@ -598,7 +631,7 @@ public void Move(int oldIndex, int newIndex) return; } - lock (_lock) + lock (SynchronizationLock) { var item = _internalList[oldIndex]; _internalList.RemoveAt(oldIndex); @@ -612,7 +645,7 @@ public void Move(int oldIndex, int newIndex) [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Remove(T item) { - lock (_lock) + lock (SynchronizationLock) { var index = _internalList.IndexOf(item); if (index < 0) @@ -636,7 +669,7 @@ public void Remove(IEnumerable items) return; } - lock (_lock) + lock (SynchronizationLock) { var removed = new List(); foreach (var item in itemArray) @@ -665,7 +698,7 @@ public void Remove(object? value) return; } - Remove((T)value!); + _ = Remove((T)value!); } /// @@ -674,7 +707,7 @@ public int RemoveMany(Func predicate) { ThrowHelper.ThrowIfNull(predicate); - lock (_lock) + lock (SynchronizationLock) { if (_internalList.Count == 0) { @@ -706,7 +739,8 @@ public int RemoveMany(Func predicate) removedBuffer = newBuffer; } - removedBuffer[removedCount++] = item; + removedBuffer[removedCount] = item; + removedCount++; } } @@ -733,7 +767,7 @@ public int RemoveMany(Func predicate) } /// - void IList.RemoveAt(int index) => RemoveAt(index); + void IList.RemoveAt(int index) => ((IList)this).RemoveAt(index); /// void IList.RemoveAt(int index) => RemoveAt(index); @@ -741,7 +775,7 @@ public int RemoveMany(Func predicate) /// public void RemoveAt(int index) { - lock (_lock) + lock (SynchronizationLock) { if (index < 0 || index >= _internalList.Count) { @@ -763,7 +797,7 @@ public void RemoveRange(int index, int count) return; } - lock (_lock) + lock (SynchronizationLock) { if (index < 0 || index >= _internalList.Count) { @@ -799,18 +833,18 @@ public void ReplaceAll(IEnumerable items) { var itemArray = (items as T[]) ?? [.. items]; - lock (_lock) + lock (SynchronizationLock) { var oldItems = _internalList.ToArray(); _internalList.Clear(); #if NET6_0_OR_GREATER - _internalList.EnsureCapacity(itemArray.Length); + _ = _internalList.EnsureCapacity(itemArray.Length); #endif _internalList.AddRange(itemArray); _observableItems!.ReplaceAll(itemArray); - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); TrackReplacement(oldItems, itemArray); @@ -828,7 +862,7 @@ public void ReplaceAll(IEnumerable items) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Update(T item, T newValue) { - lock (_lock) + lock (SynchronizationLock) { var index = _internalList.IndexOf(item); if (index >= 0) @@ -893,7 +927,7 @@ private static PooledBatch CreateBatch(T[] items) { var batchItems = new T[items.Length]; Array.Copy(items, batchItems, items.Length); - return new PooledBatch(batchItems, items.Length, ReturnToPool: false); + return new(batchItems, items.Length, ReturnToPool: false); } /// Gets data for the GetMultisetDifference operation. @@ -911,7 +945,7 @@ private static T[] GetMultisetDifference(IReadOnlyList source, IReadOnlyList< for (var i = 0; i < subtract.Count; i++) { var item = subtract[i]; - counts.TryGetValue(item, out var count); + _ = counts.TryGetValue(item, out var count); counts[item] = count + 1; } @@ -985,7 +1019,7 @@ private void EmitReplacementBatchIfObserved(CacheAction action, T[] items, bool return; } - _streamPipeline!.OnNext(new CacheNotify(action, default, CreateBatch(items))); + _streamPipeline!.OnNext(new(action, default, CreateBatch(items))); } /// Sets data for the SetItem operation. @@ -993,7 +1027,7 @@ private void EmitReplacementBatchIfObserved(CacheAction action, T[] items, bool /// The new value. private void SetItem(int index, T value) { - lock (_lock) + lock (SynchronizationLock) { if (index < 0 || index >= _internalList.Count) { @@ -1021,7 +1055,7 @@ private void SetItem(int index, T value) /// called directly in normal application code. private void InitializeNonSerializedFields() { - _lock = new(); + _ = SynchronizationLock; _disposed = false; _observableItems = new(_internalList); _itemsAddedCollection = []; @@ -1043,56 +1077,45 @@ private void InitializeNonSerializedFields() /// Raises collectionreset notifications. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RaiseCollectionReset() - { - CollectionChanged?.Invoke(this, ResetCollectionChangedEventArgs); - } + private void RaiseCollectionReset() => CollectionChanged?.Invoke(this, ResetCollectionChangedEventArgs); /// Raises collectionadded notifications. /// The item value. /// The index value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RaiseCollectionAdded(T item, int index) - { + private void RaiseCollectionAdded(T item, int index) => CollectionChanged?.Invoke( this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); - } /// Raises collectionremoved notifications. /// The item value. /// The index value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RaiseCollectionRemoved(T item, int index) - { + private void RaiseCollectionRemoved(T item, int index) => CollectionChanged?.Invoke( this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); - } /// Raises collectionchanged notifications. /// The item value. /// The previous value. /// The index value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RaiseCollectionChanged(T item, T? previous, int index) - { + private void RaiseCollectionChanged(T item, T? previous, int index) => CollectionChanged?.Invoke( this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, item, previous, index)); - } /// Raises collectionmoved notifications. /// The item value. /// The currentIndex value. /// The previousIndex value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RaiseCollectionMoved(T item, int currentIndex, int previousIndex) - { + private void RaiseCollectionMoved(T item, int currentIndex, int previousIndex) => CollectionChanged?.Invoke( this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, item, currentIndex, previousIndex)); - } /// Performs the TrackAdded operation. /// The item value. @@ -1173,7 +1196,7 @@ private void TrackChanged(T item, CacheAction action, int currentIndex, int prev [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyAdded(T item, int index = -1, bool notifyINPC = true) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); var startIndex = index >= 0 ? index : _internalList.Count - 1; TrackAdded(item, startIndex); EmitStream(CacheAction.Added, item, currentIndex: startIndex); @@ -1194,7 +1217,7 @@ private void NotifyAdded(T item, int index = -1, bool notifyINPC = true) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyAddedRange(T[] items, int index = -1, bool notifyINPC = true) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); var startIndex = index >= 0 ? index : _internalList.Count - items.Length; TrackAddedRange(items); if (_streamPipeline?.HasObservers == true) @@ -1218,7 +1241,7 @@ private void NotifyAddedRange(T[] items, int index = -1, bool notifyINPC = true) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyRemoved(T item, int index, bool notifyINPC = true) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); TrackRemoved(item, index); EmitStream(CacheAction.Removed, item, currentIndex: index); @@ -1240,7 +1263,7 @@ private void NotifyRemoved(T item, int index, bool notifyINPC = true) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyRemovedRange(T[] items, bool notifyINPC = true) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); TrackRemovedRange(items); if (_streamPipeline?.HasObservers == true) { @@ -1265,7 +1288,7 @@ private void NotifyRemovedRange(T[] items, bool notifyINPC = true) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyCleared(T[] clearedItems, bool notifyINPC = true) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); TrackRemovedRange(clearedItems); if (_streamPipeline?.HasObservers == true) { @@ -1300,7 +1323,7 @@ private void NotifyCleared(T[] clearedItems, bool notifyINPC = true) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void NotifyChangedSingle(T item, ChangeReason reason = ChangeReason.Refresh, int currentIndex = -1, int previousIndex = -1, T? previous = default) { - Interlocked.Increment(ref _version); + _ = Interlocked.Increment(ref _version); var cacheAction = reason switch { ChangeReason.Update => CacheAction.Updated, @@ -1342,11 +1365,11 @@ private void EmitStream(CacheAction action, T? item, PooledBatch? batch = nul return; } - _streamPipeline.OnNext(new CacheNotify(action, item, batch, currentIndex, previousIndex, previous)); + _streamPipeline.OnNext(new(action, item, batch, currentIndex, previousIndex, previous)); } /// Provides the RangeObservableCollection implementation. - private sealed class RangeObservableCollection : ObservableCollection + private sealed class RangeObservableCollection : ObservableCollection, IRangeRemovable { /// Initializes a new instance of the class. public RangeObservableCollection() @@ -1399,25 +1422,6 @@ public void InsertRange(int index, T[] items) RaiseReset(); } - /// Removes data for the RemoveRange operation. - /// The index value. - /// The count value. - public void RemoveRange(int index, int count) - { - if (count == 0) - { - return; - } - - CheckReentrancy(); - for (var i = 0; i < count; i++) - { - Items.RemoveAt(index); - } - - RaiseReset(); - } - /// Performs the ReplaceAll operation. /// The items value. public void ReplaceAll(IReadOnlyList items) @@ -1443,6 +1447,25 @@ public void ReplaceWithSingle(T item) RaiseReset(); } + /// Removes data for the RemoveRange operation. + /// The index value. + /// The count value. + public void RemoveRange(int index, int count) + { + if (count == 0) + { + return; + } + + CheckReentrancy(); + for (var i = 0; i < count; i++) + { + Items.RemoveAt(index); + } + + RaiseReset(); + } + /// Raises reset notifications. private void RaiseReset() { diff --git a/src/ReactiveList/Core/CacheNotifyExtensions.cs b/src/ReactiveList/Core/CacheNotifyExtensions.cs index cf4dd95..d36653c 100644 --- a/src/ReactiveList/Core/CacheNotifyExtensions.cs +++ b/src/ReactiveList/Core/CacheNotifyExtensions.cs @@ -22,27 +22,25 @@ public static class CacheNotifyExtensions /// Converts a single to a . /// A representing the notification, or null for batch/clear operations without an item. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public Change? ToChange() - { - return notification is null + public Change? ToChange() => + notification is null ? null : notification.Action switch - { - CacheAction.Added when notification.Item is not null => - Change.CreateAdd(notification.Item, notification.CurrentIndex), - CacheAction.Removed when notification.Item is not null => - Change.CreateRemove(notification.Item, notification.CurrentIndex), - CacheAction.Updated when notification.Item is not null => - Change.CreateUpdate(notification.Item, notification.Previous!, notification.CurrentIndex), - CacheAction.Moved when notification.Item is not null => - Change.CreateMove(notification.Item, notification.CurrentIndex, notification.PreviousIndex), - CacheAction.Refreshed when notification.Item is not null => - Change.CreateRefresh(notification.Item, notification.CurrentIndex), - CacheAction.Cleared => - new Change(ChangeReason.Clear, default!), - _ => null - }; - } + { + CacheAction.Added when notification.Item is not null => + Change.CreateAdd(notification.Item, notification.CurrentIndex), + CacheAction.Removed when notification.Item is not null => + Change.CreateRemove(notification.Item, notification.CurrentIndex), + CacheAction.Updated when notification.Item is not null => + Change.CreateUpdate(notification.Item, notification.Previous!, notification.CurrentIndex), + CacheAction.Moved when notification.Item is not null => + Change.CreateMove(notification.Item, notification.CurrentIndex, notification.PreviousIndex), + CacheAction.Refreshed when notification.Item is not null => + Change.CreateRefresh(notification.Item, notification.CurrentIndex), + CacheAction.Cleared => + new Change(ChangeReason.Clear, default!), + _ => null + }; } /// Helpers for observables of cache notifications. @@ -68,7 +66,7 @@ public IObservable> WhereAdded() { ThrowHelper.ThrowIfNull(source); - return source.Keep(n => n.Action is CacheAction.Added or CacheAction.BatchAdded); + return source.Keep(static n => n.Action is CacheAction.Added or CacheAction.BatchAdded); } /// Filters the stream to only include remove notifications (single and batch). @@ -78,7 +76,7 @@ public IObservable> WhereRemoved() { ThrowHelper.ThrowIfNull(source); - return source.Keep(n => n.Action is CacheAction.Removed or CacheAction.BatchRemoved); + return source.Keep(static n => n.Action is CacheAction.Removed or CacheAction.BatchRemoved); } /// Projects single item notifications to their items. @@ -89,8 +87,8 @@ public IObservable SelectItems() ThrowHelper.ThrowIfNull(source); return source - .Keep(n => n.Item is not null) - .Map(n => n.Item!); + .Keep(static n => n.Item is not null) + .Map(static n => n.Item!); } /// Projects all items from notifications (both single and batch) to a flat sequence. @@ -100,7 +98,7 @@ public IObservable SelectAllItems() { ThrowHelper.ThrowIfNull(source); - return source.FlatMap(n => + return source.FlatMap(static n => { if (n.Batch is not null) { @@ -137,8 +135,8 @@ public IObservable SelectAllItems() return source .WhereAction(CacheAction.Moved) - .Keep(n => n.Item is not null) - .Map(n => (n.Item!, n.PreviousIndex, n.CurrentIndex)); + .Keep(static n => n.Item is not null) + .Map(static n => (n.Item!, n.PreviousIndex, n.CurrentIndex)); } /// Subscribes to clear notifications from the stream. @@ -156,7 +154,7 @@ public IObservable>> BufferNotifications(TimeSpan bufferTim return source .Buffer(bufferTime) - .Keep(b => b.Count > 0); + .Keep(static b => b.Count > 0); } /// Throttles the stream to reduce notification frequency. @@ -214,7 +212,7 @@ public IObservable> AutoDisposeBatches() { ThrowHelper.ThrowIfNull(source); - return source.Tap(n => n.Batch?.Dispose()); + return source.Tap(static n => n.Batch?.Dispose()); } /// Counts items by action type in a stream. @@ -224,7 +222,7 @@ public IObservable> AutoDisposeBatches() { ThrowHelper.ThrowIfNull(source); - return source.Map(n => + return source.Map(static n => { var count = n.Batch?.Count ?? (n.Item is not null ? 1 : 0); return (n.Action, count); @@ -243,7 +241,7 @@ public IObservable> ToChangeSets() { ThrowHelper.ThrowIfNull(source); - return source.Map(notification => + return source.Map(static notification => { if (notification.Batch is { Count: > 0 }) { @@ -251,8 +249,7 @@ public IObservable> ToChangeSets() var reason = notification.Action switch { CacheAction.BatchAdded => ChangeReason.Add, - CacheAction.BatchRemoved => ChangeReason.Remove, - CacheAction.Cleared => ChangeReason.Remove, // Clear is semantically a bulk remove + CacheAction.BatchRemoved or CacheAction.Cleared => ChangeReason.Remove, _ => ChangeReason.Refresh }; @@ -267,7 +264,7 @@ public IObservable> ToChangeSets() } else if (reason == ChangeReason.Remove) { - changes[i] = Change.CreateRemove(item, -1); + changes[i] = Change.CreateRemove(item); } else { @@ -285,7 +282,7 @@ public IObservable> ToChangeSets() var change = notification.ToChange(); return change.HasValue ? new ChangeSet(change.Value) : ChangeSet.Empty; - }).Where(cs => cs.Count > 0); + }).Where(static cs => cs.Count > 0); } } } diff --git a/src/ReactiveList/Core/Change.cs b/src/ReactiveList/Core/Change.cs index 3bb5da9..6b47a52 100644 --- a/src/ReactiveList/Core/Change.cs +++ b/src/ReactiveList/Core/Change.cs @@ -15,13 +15,40 @@ namespace CP.Primitives.Core; /// The type of item that changed. public readonly record struct Change { + /// Initializes a new instance of the class. + /// The reason for the change. + /// The current/new item value. + public Change(ChangeReason reason, T current) + : this(reason, current, default, -1, -1) + { + } + + /// Initializes a new instance of the class. + /// The reason for the change. + /// The current/new item value. + /// The previous item value (for updates). + public Change(ChangeReason reason, T current, T? previous) + : this(reason, current, previous, -1, -1) + { + } + + /// Initializes a new instance of the class. + /// The reason for the change. + /// The current/new item value. + /// The previous item value (for updates). + /// The current index of the item, or -1 if not applicable. + public Change(ChangeReason reason, T current, T? previous, int currentIndex) + : this(reason, current, previous, currentIndex, -1) + { + } + /// Initializes a new instance of the struct. /// The reason for the change. /// The current/new item value. /// The previous item value (for updates). /// The current index of the item, or -1 if not applicable. /// The previous index of the item (for moves), or -1 if not applicable. - public Change(ChangeReason reason, T current, T? previous = default, int currentIndex = -1, int previousIndex = -1) + public Change(ChangeReason reason, T current, T? previous, int currentIndex, int previousIndex) { Reason = reason; Current = current; @@ -45,26 +72,45 @@ public Change(ChangeReason reason, T current, T? previous = default, int current /// Gets the previous index of the item. Only populated for Move operations. public int PreviousIndex { get; } + /// Creates an Add change. + /// The item being added. + /// A new Change representing an add operation. + public static Change CreateAdd(T item) => + CreateAdd(item, -1); + /// Creates an Add change. /// The item being added. /// The index where the item was added. /// A new Change representing an add operation. - public static Change CreateAdd(T item, int index = -1) => - new(ChangeReason.Add, item, currentIndex: index); + public static Change CreateAdd(T item, int index) => + new(ChangeReason.Add, item, default, index, -1); + + /// Creates a Remove change. + /// The item being removed. + /// A new Change representing a remove operation. + public static Change CreateRemove(T item) => + CreateRemove(item, -1); /// Creates a Remove change. /// The item being removed. /// The index from which the item was removed. /// A new Change representing a remove operation. - public static Change CreateRemove(T item, int index = -1) => - new(ChangeReason.Remove, item, previousIndex: index); + public static Change CreateRemove(T item, int index) => + new(ChangeReason.Remove, item, default, -1, index); + + /// Creates an Update change. + /// The new item value. + /// The previous item value. + /// A new Change representing an update operation. + public static Change CreateUpdate(T current, T previous) => + CreateUpdate(current, previous, -1); /// Creates an Update change. /// The new item value. /// The previous item value. /// The index of the updated item. /// A new Change representing an update operation. - public static Change CreateUpdate(T current, T previous, int index = -1) => + public static Change CreateUpdate(T current, T previous, int index) => new(ChangeReason.Update, current, previous, index, index); /// Creates a Move change. @@ -73,12 +119,18 @@ public static Change CreateUpdate(T current, T previous, int index = -1) => /// The previous index of the item. /// A new Change representing a move operation. public static Change CreateMove(T item, int currentIndex, int previousIndex) => - new(ChangeReason.Move, item, currentIndex: currentIndex, previousIndex: previousIndex); + new(ChangeReason.Move, item, default, currentIndex, previousIndex); + + /// Creates a Refresh change. + /// The item being refreshed. + /// A new Change representing a refresh operation. + public static Change CreateRefresh(T item) => + CreateRefresh(item, -1); /// Creates a Refresh change. /// The item being refreshed. /// The index of the item. /// A new Change representing a refresh operation. - public static Change CreateRefresh(T item, int index = -1) => - new(ChangeReason.Refresh, item, currentIndex: index); + public static Change CreateRefresh(T item, int index) => + new(ChangeReason.Refresh, item, default, index, -1); } diff --git a/src/ReactiveList/Core/ChangeSet.cs b/src/ReactiveList/Core/ChangeSet.cs index f608a6a..4b27857 100644 --- a/src/ReactiveList/Core/ChangeSet.cs +++ b/src/ReactiveList/Core/ChangeSet.cs @@ -16,6 +16,7 @@ namespace CP.Primitives.Core; /// The type of items in the collection. public readonly record struct ChangeSet : IReadOnlyList>, IDisposable, IEquatable> { + /// The compact array containing the changes in this set. private readonly Change[]? _changes; /// Initializes a new instance of the struct with a single change. @@ -110,7 +111,7 @@ public Change this[int index] IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); /// - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => (IEnumerator>)GetEnumerator(); /// Retained for compatibility. Change sets no longer own pooled storage. public void Dispose() @@ -140,12 +141,18 @@ private int CountByReason(ChangeReason reason) } /// Enumerates the elements of a . - public struct Enumerator : IEnumerator> + public struct Enumerator : IEnumerator>, IEquatable { + /// The multiplier used to combine legacy hash-code components. + private const int EqualityHashMultiplier = 397; + + /// The array being enumerated. private readonly Change[] _changes; + /// The number of valid changes in . private readonly int _count; + /// The zero-based position of the current change. private int _index; /// Initializes a new instance of the struct. @@ -164,6 +171,18 @@ internal Enumerator(Change[] changes, int count) /// readonly object IEnumerator.Current => Current; + /// Determines whether two enumerators have the same source and enumeration state. + /// The first enumerator to compare. + /// The second enumerator to compare. + /// when the enumerators are equal; otherwise, . + public static bool operator ==(Enumerator left, Enumerator right) => left.Equals(right); + + /// Determines whether two enumerators have different sources or enumeration states. + /// The first enumerator to compare. + /// The second enumerator to compare. + /// when the enumerators are not equal; otherwise, . + public static bool operator !=(Enumerator left, Enumerator right) => !left.Equals(right); + /// Advances the enumerator to the next element. /// true if the enumerator was successfully advanced; false if it has passed the end. public bool MoveNext() @@ -181,9 +200,34 @@ public bool MoveNext() /// Sets the enumerator to its initial position. public void Reset() => _index = -1; + /// + public readonly bool Equals(Enumerator other) => + ReferenceEquals(_changes, other._changes) && _count == other._count && _index == other._index; + + /// + public override readonly bool Equals(object? obj) => obj is Enumerator other && Equals(other); + + /// + public override readonly int GetHashCode() => +#if NET6_0_OR_GREATER + HashCode.Combine(_changes, _count); +#else + GetLegacyEqualityHashCode(); +#endif + /// Releases resources used by the enumerator. public readonly void Dispose() { } + +#if !NET6_0_OR_GREATER + /// Computes an equality hash code on target frameworks without the built-in hash combiner. + /// A hash code for the current source and enumeration state. + private readonly int GetLegacyEqualityHashCode() + { + var hashCode = _changes?.GetHashCode() ?? 0; + return (hashCode * EqualityHashMultiplier) ^ _count; + } +#endif } } diff --git a/src/ReactiveList/Core/EditableListWrapperPool.cs b/src/ReactiveList/Core/EditableListWrapperPool.cs index 6238cdc..9f9d89d 100644 --- a/src/ReactiveList/Core/EditableListWrapperPool.cs +++ b/src/ReactiveList/Core/EditableListWrapperPool.cs @@ -14,26 +14,12 @@ namespace CP.Primitives.Core; /// public static class EditableListWrapperPool { - /// Gets or sets the maximum number of wrappers to keep in the pool for type . Default is 64. - /// The wrapped element type. - /// The maximum number of pooled wrappers. - public static int GetMaxPoolSize() - { - return State.Get().MaxPoolSize; - } - - /// Sets the maximum number of wrappers to keep in the pool for type . - /// The wrapped element type. - /// Maximum number of wrappers to retain. - public static void SetMaxPoolSize(int value) - { - State.Get().MaxPoolSize = Math.Max(1, value); - } - - /// Gets the current number of wrappers in the pool for type . + /// Rents a wrapper from the pool or creates a new one if the pool is empty. /// The wrapped element type. - /// The current number of pooled wrappers. - public static int GetCurrentPoolSize() => State.Get().PoolSize; + /// The underlying list to wrap. + /// A pooled wrapper instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PooledEditableListWrapper Rent(List list) => Rent(list, null); /// Rents a wrapper from the pool or creates a new one if the pool is empty. /// The wrapped element type. @@ -43,97 +29,20 @@ public static void SetMaxPoolSize(int value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static PooledEditableListWrapper Rent( List list, - ObservableCollection? observableCollection = null) + ObservableCollection? observableCollection) { - if (State.Get().TryRent(out var wrapper) && wrapper is not null) + if (EditableListWrapperPool.TryRent(out var wrapper) && wrapper is not null) { wrapper.Initialize(list, observableCollection); return wrapper; } - return new PooledEditableListWrapper(list, observableCollection); + return new(list, observableCollection); } /// Returns a wrapper to the pool for reuse. /// The wrapped element type. /// The wrapper to return. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void Return(PooledEditableListWrapper wrapper) - { - var state = State.Get(); - if (wrapper is null || state.PoolSize >= state.MaxPoolSize) - { - return; - } - - wrapper.Reset(); - state.Add(wrapper); - } - - /// Clears all wrappers from the pool for type . - /// The wrapped element type. - public static void Clear() - { - State.Get().Clear(); - } - - /// Gets or creates per-type pooled state. - private static class State - { - private static readonly ConcurrentDictionary Values = []; - - /// Gets or creates type-specific pool state. - /// The wrapped element type. - /// The backing pool state for . - public static State Get() - { - return (State)Values.GetOrAdd(typeof(T), static _ => new State()); - } - } - - /// Holds pooled wrappers and metadata for a specific wrapper type. - /// The wrapped element type. - private sealed class State - { - private readonly ConcurrentBag> _pool = []; - - private int _poolSize; - - /// Gets the current number of pooled wrappers for this type. - public int PoolSize => _poolSize; - - /// Gets or sets the maximum number of wrappers to keep in the pool for this type. Defaults to 64. - public int MaxPoolSize { get; set; } = 64; - - /// Attempts to rent a wrapper from the pool. - /// A wrapped instance from the pool if one is available; otherwise . - /// when a wrapper was rented from the pool; otherwise . - public bool TryRent(out PooledEditableListWrapper? wrapper) - { - if (!_pool.TryTake(out wrapper)) - { - return false; - } - - Interlocked.Decrement(ref _poolSize); - return true; - } - - /// Adds a wrapper to the pool if it is not . - /// The wrapper to add. - public void Add(PooledEditableListWrapper wrapper) - { - _pool.Add(wrapper); - Interlocked.Increment(ref _poolSize); - } - - /// Clears all pooled wrappers for this type. - public void Clear() - { - while (_pool.TryTake(out _)) - { - Interlocked.Decrement(ref _poolSize); - } - } - } + public static void Return(PooledEditableListWrapper wrapper) => EditableListWrapperPool.Return(wrapper); } diff --git a/src/ReactiveList/Core/EditableListWrapperPool{T}.cs b/src/ReactiveList/Core/EditableListWrapperPool{T}.cs new file mode 100644 index 0000000..53c53c0 --- /dev/null +++ b/src/ReactiveList/Core/EditableListWrapperPool{T}.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved. +// Chris Pulman and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if REACTIVELIST_REACTIVE +namespace CP.Reactive.Core; +#else +namespace CP.Primitives.Core; +#endif +/// Provides type-specific management for pooled instances. +/// The wrapped element type. +public static class EditableListWrapperPool +{ + /// The composed pool state for this element type. + private static readonly PoolState State = new(); + + /// Gets or sets the maximum number of wrappers retained for this element type. + public static int MaxPoolSize + { + get => EditableListWrapperPool.State.MaximumSize; + set => EditableListWrapperPool.State.MaximumSize = Math.Max(1, value); + } + + /// Gets the current number of wrappers retained for this element type. + public static int CurrentPoolSize => EditableListWrapperPool.State.Count; + + /// Clears all wrappers retained for this element type. + public static void Clear() => EditableListWrapperPool.State.Clear(); + + /// Attempts to rent a wrapper from the type-specific pool. + /// A wrapper from the pool when one is available; otherwise, . + /// when a wrapper was rented; otherwise, . + internal static bool TryRent(out PooledEditableListWrapper? wrapper) => State.TryRent(out wrapper); + + /// Returns a wrapper to the type-specific pool. + /// The wrapper to return. + internal static void Return(PooledEditableListWrapper wrapper) + { + if (wrapper is null || State.Count >= State.MaximumSize) + { + return; + } + + wrapper.Reset(); + State.Add(wrapper); + } + + /// Holds pooled wrappers and metadata for this wrapper type. + private sealed class PoolState + { + /// The available wrappers for this element type. + private readonly ConcurrentBag> _pool = []; + + /// The number of wrappers currently held in . + private int _count; + + /// Gets the current number of pooled wrappers for this type. + public int Count => Volatile.Read(ref _count); + + /// Gets or sets the maximum number of wrappers to keep in the pool for this type. Defaults to 64. + public int MaximumSize { get; set; } = 64; + + /// Attempts to rent a wrapper from the pool. + /// A wrapped instance from the pool if one is available; otherwise . + /// when a wrapper was rented from the pool; otherwise . + public bool TryRent(out PooledEditableListWrapper? wrapper) + { + if (!_pool.TryTake(out wrapper)) + { + return false; + } + + _ = Interlocked.Decrement(ref _count); + return true; + } + + /// Adds a wrapper to the pool if it is not . + /// The wrapper to add. + public void Add(PooledEditableListWrapper wrapper) + { + _pool.Add(wrapper); + _ = Interlocked.Increment(ref _count); + } + + /// Clears all pooled wrappers for this type. + public void Clear() + { + while (_pool.TryTake(out _)) + { + _ = Interlocked.Decrement(ref _count); + } + } + } +} diff --git a/src/ReactiveList/Core/GroupedObservable.cs b/src/ReactiveList/Core/GroupedObservable.cs index 4e22cc3..72fc7f9 100644 --- a/src/ReactiveList/Core/GroupedObservable.cs +++ b/src/ReactiveList/Core/GroupedObservable.cs @@ -13,6 +13,7 @@ namespace CP.Primitives.Core; /// The key value for this group. internal sealed class GroupedObservable(TKey key) : IGroupedObservable, IDisposable { + /// The signal that publishes values and terminal notifications to group subscribers. private readonly Signal _signal = new(); /// Gets the key identifying this group. @@ -23,17 +24,17 @@ internal sealed class GroupedObservable(TKey key) : IGroupedObse /// A disposable subscription. public IDisposable Subscribe(IObserver observer) => _signal.Subscribe(observer); + /// Disposes this group and releases resources. + public void Dispose() => _signal.Dispose(); + /// Pushes a new value into this group. /// The value to publish. - public void OnNext(TElement value) => _signal.OnNext(value); + internal void OnNext(TElement value) => _signal.OnNext(value); /// Notifies subscribers that the source produced an error. /// The error. - public void OnError(Exception error) => _signal.OnError(error); + internal void OnError(Exception error) => _signal.OnError(error); /// Notifies subscribers that the grouped stream has completed. - public void OnCompleted() => _signal.OnCompleted(); - - /// Disposes this group and releases resources. - public void Dispose() => _signal.Dispose(); + internal void OnCompleted() => _signal.OnCompleted(); } diff --git a/src/ReactiveList/Core/PooledBatch.cs b/src/ReactiveList/Core/PooledBatch.cs index e7d1675..0c80267 100644 --- a/src/ReactiveList/Core/PooledBatch.cs +++ b/src/ReactiveList/Core/PooledBatch.cs @@ -18,6 +18,7 @@ namespace CP.Primitives.Core; /// A value indicating whether should be returned to when disposed. public sealed record PooledBatch(T[] Items, int Count, bool ReturnToPool = true) : IDisposable { + /// Tracks whether the pooled array has already been returned. private int _isDisposed; /// Releases all resources used by the current instance. diff --git a/src/ReactiveList/Core/PooledEditableListWrapper.cs b/src/ReactiveList/Core/PooledEditableListWrapper.cs index 04dac8f..aba0e6b 100644 --- a/src/ReactiveList/Core/PooledEditableListWrapper.cs +++ b/src/ReactiveList/Core/PooledEditableListWrapper.cs @@ -16,10 +16,13 @@ namespace CP.Primitives.Core; /// The observable collection to keep in sync (optional). public sealed class PooledEditableListWrapper(List list, ObservableCollection? observableCollection = null) : IEditableList, IResettable, IDisposable { + /// The wrapped list, or after this wrapper is returned. private List? _list = list; + /// The optional observable collection kept in sync with . private ObservableCollection? _observableCollection = observableCollection; + /// Indicates whether this wrapper has been returned to its pool. private bool _isReturned; /// diff --git a/src/ReactiveList/Core/ReactiveGroup.cs b/src/ReactiveList/Core/ReactiveGroup.cs index 1565728..835363b 100644 --- a/src/ReactiveList/Core/ReactiveGroup.cs +++ b/src/ReactiveList/Core/ReactiveGroup.cs @@ -3,8 +3,12 @@ // See the LICENSE file in the project root for full license information. #if REACTIVELIST_REACTIVE +using CP.Reactive.Internal; + namespace CP.Reactive.Core; #else +using CP.Primitives.Internal; + namespace CP.Primitives.Core; #endif /// Represents a group of items with a key for use in grouped views. @@ -13,7 +17,20 @@ namespace CP.Primitives.Core; public sealed class ReactiveGroup : IGrouping, INotifyCollectionChanged, INotifyPropertyChanged where TKey : notnull { - private readonly ObservableCollection _items; + /// Synchronizes collection-changed subscriptions to the facade. + private readonly Lock _collectionChangedGate = new(); + + /// Synchronizes property-changed subscriptions to the facade. + private readonly Lock _propertyChangedGate = new(); + + /// Owns the observable collection and its source subscription. + private readonly State _state; + + /// Relays collection notifications through this facade when it has subscribers. + private NotificationRelay? _collectionChangedRelay; + + /// Relays property notifications through this facade when it has subscribers. + private NotificationRelay? _propertyChangedRelay; /// Initializes a new instance of the class. /// The group key. @@ -21,34 +38,134 @@ public sealed class ReactiveGroup : IGrouping, INotifyCollecti public ReactiveGroup(TKey key, ObservableCollection items) { Key = key; - _items = items; - Items = new(_items); - _items.CollectionChanged += (s, e) => - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); - CollectionChanged?.Invoke(this, e); - }; + _state = new(items); + Items = _state.ReadOnlyItems; + _state.Activate(); } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + if (value is null) + { + return; + } + + lock (_collectionChangedGate) + { + _collectionChangedRelay ??= new(this); + if (_collectionChangedRelay.Add(value.Invoke)) + { + _state.CollectionChanged += _collectionChangedRelay.OnEvent; + } + } + } + + remove + { + if (value is null) + { + return; + } + + lock (_collectionChangedGate) + { + if (_collectionChangedRelay?.Remove(value.Invoke) is true) + { + _state.CollectionChanged -= _collectionChangedRelay.OnEvent; + } + } + } + } /// - public event PropertyChangedEventHandler? PropertyChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + if (value is null) + { + return; + } + + lock (_propertyChangedGate) + { + _propertyChangedRelay ??= new(this); + if (_propertyChangedRelay.Add(value.Invoke)) + { + _state.PropertyChanged += _propertyChangedRelay.OnEvent; + } + } + } + + remove + { + if (value is null) + { + return; + } + + lock (_propertyChangedGate) + { + if (_propertyChangedRelay?.Remove(value.Invoke) is true) + { + _state.PropertyChanged -= _propertyChangedRelay.OnEvent; + } + } + } + } /// Gets the group key. public TKey Key { get; } /// Gets the number of items in the group. - public int Count => _items.Count; + public int Count => _state.Items.Count; /// Gets the items in the group for UI binding. public ReadOnlyObservableCollection Items { get; } /// - public IEnumerator GetEnumerator() => _items.GetEnumerator(); + public IEnumerator GetEnumerator() => _state.Items.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// Owns source notifications independently of the public facade. + private sealed class State + { + /// Initializes a new instance of the class. + /// The items in the group. + internal State(ObservableCollection items) + { + Items = items; + ReadOnlyItems = new(items); + } + + /// Raised when the source collection changes. + internal event EventHandler? CollectionChanged; + + /// Raised when a facade property changes. + internal event EventHandler? PropertyChanged; + + /// Gets the mutable source items. + internal ObservableCollection Items { get; } + + /// Gets the read-only source projection. + internal ReadOnlyObservableCollection ReadOnlyItems { get; } + + /// Subscribes this fully constructed state to its source. + internal void Activate() => Items.CollectionChanged += OnCollectionChanged; + + /// Forwards a source collection notification to the active facade relays. + /// The source collection. + /// The collection change details. + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) + { + PropertyChanged?.Invoke(sender, new(nameof(Count))); + PropertyChanged?.Invoke(sender, new("Item[]")); + CollectionChanged?.Invoke(sender, eventArgs); + } + } } diff --git a/src/ReactiveList/Core/SecondaryIndex.cs b/src/ReactiveList/Core/SecondaryIndex.cs index a19ddc6..828f14f 100644 --- a/src/ReactiveList/Core/SecondaryIndex.cs +++ b/src/ReactiveList/Core/SecondaryIndex.cs @@ -14,8 +14,10 @@ namespace CP.Primitives.Core; public class SecondaryIndex(Func selector) : ISecondaryIndex where TKey : notnull { + /// The fixed number of independently locked shards. private const int ShardCount = 4; + /// The hash buckets used to partition indexed values. private readonly ConcurrentDictionary>[] _shards = [ new ConcurrentDictionary>(), @@ -33,20 +35,20 @@ public void OnAdded(T item) var s = GetShardIndex(key); #if NETFRAMEWORK - var set = _shards[s].GetOrAdd(key, _ => []); + var set = _shards[s].GetOrAdd(key, static _ => []); lock (set) { - set.Add(item); + _ = set.Add(item); } #else - _shards[s].AddOrUpdate( + _ = _shards[s].AddOrUpdate( key, addValueFactory: static (_, newItem) => [newItem], - updateValueFactory: static (_, set, newItem) => + updateValueFactory: static (key, set, newItem) => { lock (set) { - set.Add(newItem); + _ = set.Add(newItem); } return set; @@ -70,10 +72,10 @@ public void OnRemoved(T item) lock (set) { - set.Remove(item); + _ = set.Remove(item); if (set.Count == 0) { - _shards[s].TryRemove(key, out _); + _ = _shards[s].TryRemove(key, out _); } } } diff --git a/src/ReactiveList/Internal/ArrayPoolClearHelper.cs b/src/ReactiveList/Internal/ArrayPoolClearHelper.cs index 7f554df..724580e 100644 --- a/src/ReactiveList/Internal/ArrayPoolClearHelper.cs +++ b/src/ReactiveList/Internal/ArrayPoolClearHelper.cs @@ -14,52 +14,25 @@ internal static class ArrayPoolClearHelper /// The array element type. /// when clearing is required; otherwise, . [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsReferenceOrContainsReferences() - { + internal static bool IsReferenceOrContainsReferences() => #if NETFRAMEWORK - return TypeCache.ContainsReferences; + TypeCache.ContainsReferences; #else - return RuntimeHelpers.IsReferenceOrContainsReferences(); + RuntimeHelpers.IsReferenceOrContainsReferences(); #endif - } #if NETFRAMEWORK - /// Determines whether a type contains managed references. - /// The type to inspect. - /// The value types already visited in the current traversal. - /// when the type contains managed references; otherwise, . - private static bool ContainsReferencesCore(Type type, HashSet visited) - { - if (!type.IsValueType) - { - return !type.IsPointer && !type.IsByRef; - } - - if (type.IsPrimitive || type.IsEnum || !visited.Add(type)) - { - return false; - } - - var fields = type.GetFields( - System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic); - for (var i = 0; i < fields.Length; i++) - { - if (ContainsReferencesCore(fields[i].FieldType, visited)) - { - return true; - } - } - - return false; - } - - /// Caches the reflection result once for each closed generic type. + /// Caches a conservative, type-safe clearing decision for each closed generic type. + /// + /// Custom value types are cleared on .NET Framework because that runtime does not expose + /// the generic runtime reference-inspection helper. This avoids inspecting private fields. + /// /// The type to inspect. - private static class TypeCache + internal static class TypeCache { - public static readonly bool ContainsReferences = ContainsReferencesCore(typeof(T), []); + /// Indicates whether arrays of should be cleared when returned. + internal static readonly bool ContainsReferences = + !typeof(T).IsValueType || Type.GetTypeCode(typeof(T)) == TypeCode.Object; } #endif } diff --git a/src/ReactiveList/Internal/BatchChangeTracker.cs b/src/ReactiveList/Internal/BatchChangeTracker.cs index b256476..6a18486 100644 --- a/src/ReactiveList/Internal/BatchChangeTracker.cs +++ b/src/ReactiveList/Internal/BatchChangeTracker.cs @@ -9,33 +9,73 @@ namespace CP.Primitives.Internal; #endif /// Provides batch change tracking for efficient event coalescing. /// The type of items being tracked. -internal struct BatchChangeTracker : IDisposable +internal struct BatchChangeTracker : IDisposable, IEquatable> { + /// The multiplier applied when a rented buffer must grow. private const int BufferGrowthFactor = 2; + /// The minimum size of the initially rented buffers. private const int InitialBufferSize = 16; + /// The rented buffer containing tracked additions. private T[]? _addedItems; + /// The rented buffer containing tracked removals. private T[]? _removedItems; + /// The number of valid entries in . private int _addedCount; + /// The number of valid entries in . private int _removedCount; /// Gets a value indicating whether there are any tracked changes. - public readonly bool HasChanges => _addedCount > 0 || _removedCount > 0; + internal readonly bool HasChanges => _addedCount > 0 || _removedCount > 0; /// Gets the added items as a span. - public readonly ReadOnlySpan AddedItems => _addedItems.AsSpan(0, _addedCount); + internal readonly ReadOnlySpan AddedItems => _addedItems.AsSpan(0, _addedCount); /// Gets the removed items as a span. - public readonly ReadOnlySpan RemovedItems => _removedItems.AsSpan(0, _removedCount); + internal readonly ReadOnlySpan RemovedItems => _removedItems.AsSpan(0, _removedCount); + + /// Determines whether this tracker references the same buffers and positions as another tracker. + /// The tracker to compare. + /// when both trackers have identical state; otherwise, . + public readonly bool Equals(BatchChangeTracker other) => + ReferenceEquals(_addedItems, other._addedItems) + && ReferenceEquals(_removedItems, other._removedItems) + && _addedCount == other._addedCount + && _removedCount == other._removedCount; + + /// + public override readonly bool Equals(object? obj) => obj is BatchChangeTracker other && Equals(other); + + /// + public override readonly int GetHashCode() => 0; + + /// Releases pooled arrays back to the pool. + public void Dispose() + { + if (_addedItems is not null) + { + ArrayPool.Shared.Return(_addedItems, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); + _addedItems = null; + } + + if (_removedItems is not null) + { + ArrayPool.Shared.Return(_removedItems, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); + _removedItems = null; + } + + _addedCount = 0; + _removedCount = 0; + } /// Tracks an added item. /// The item that was added. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void TrackAdded(T item) + internal void TrackAdded(T item) { _addedItems ??= ArrayPool.Shared.Rent(InitialBufferSize); @@ -44,13 +84,14 @@ public void TrackAdded(T item) GrowAdded(); } - _addedItems[_addedCount++] = item; + _addedItems[_addedCount] = item; + _addedCount++; } /// Tracks a removed item. /// The item that was removed. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void TrackRemoved(T item) + internal void TrackRemoved(T item) { _removedItems ??= ArrayPool.Shared.Rent(InitialBufferSize); @@ -59,26 +100,8 @@ public void TrackRemoved(T item) GrowRemoved(); } - _removedItems[_removedCount++] = item; - } - - /// Releases pooled arrays back to the pool. - public void Dispose() - { - if (_addedItems is not null) - { - ArrayPool.Shared.Return(_addedItems, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); - _addedItems = null; - } - - if (_removedItems is not null) - { - ArrayPool.Shared.Return(_removedItems, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); - _removedItems = null; - } - - _addedCount = 0; - _removedCount = 0; + _removedItems[_removedCount] = item; + _removedCount++; } /// Expands the backing store used for added items. diff --git a/src/ReactiveList/Internal/BitOperationsCompat.cs b/src/ReactiveList/Internal/BitOperationsCompat.cs index 3f6c2ed..533d82f 100644 --- a/src/ReactiveList/Internal/BitOperationsCompat.cs +++ b/src/ReactiveList/Internal/BitOperationsCompat.cs @@ -14,9 +14,9 @@ internal static class BitOperationsCompat /// The value. /// The integer base-2 logarithm. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int Log2(uint value) - { #if NETFRAMEWORK + internal static int Log2(uint value) + { var result = 0; while ((value >>= 1) != 0) { @@ -24,18 +24,18 @@ public static int Log2(uint value) } return result; + } #else - return System.Numerics.BitOperations.Log2(value); + internal static int Log2(uint value) => System.Numerics.BitOperations.Log2(value); #endif - } /// Rounds a value up to the next power of two. /// The value to round. /// The rounded power-of-two value. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static uint RoundUpToPowerOf2(uint value) - { #if NETFRAMEWORK + internal static uint RoundUpToPowerOf2(uint value) + { if (value <= 1) { return 1; @@ -48,8 +48,8 @@ public static uint RoundUpToPowerOf2(uint value) value |= value >> 8; value |= value >> 16; return value + 1; + } #else - return System.Numerics.BitOperations.RoundUpToPowerOf2(value); + internal static uint RoundUpToPowerOf2(uint value) => System.Numerics.BitOperations.RoundUpToPowerOf2(value); #endif - } } diff --git a/src/ReactiveList/Internal/CallerArgumentExpressionAttribute.cs b/src/ReactiveList/Internal/CallerArgumentExpressionAttribute.cs index 46c9e2f..f8ba584 100644 --- a/src/ReactiveList/Internal/CallerArgumentExpressionAttribute.cs +++ b/src/ReactiveList/Internal/CallerArgumentExpressionAttribute.cs @@ -10,6 +10,24 @@ namespace System.Runtime.CompilerServices; internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute { /// Gets the source parameter name. - public string ParameterName { get; } = parameterName; + internal string ParameterName { get; } = parameterName; +} +#elif REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; + +/// Marks the runtime-provided caller-argument-expression compatibility path. +file enum CallerArgumentExpressionAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, +} +#else +namespace CP.Primitives.Internal; + +/// Marks the runtime-provided caller-argument-expression compatibility path. +file enum CallerArgumentExpressionAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, } #endif diff --git a/src/ReactiveList/Internal/ChangeToken.cs b/src/ReactiveList/Internal/ChangeToken.cs index b578c3f..d846f4b 100644 --- a/src/ReactiveList/Internal/ChangeToken.cs +++ b/src/ReactiveList/Internal/ChangeToken.cs @@ -13,21 +13,21 @@ internal readonly record struct ChangeToken /// Initializes a new instance of the struct. /// The version number. /// The item count. - public ChangeToken(long version, int count) + internal ChangeToken(long version, int count) { Version = version; Count = count; } /// Gets the version number of the collection when this token was created. - public long Version { get; } + internal long Version { get; } /// Gets the count of items when this token was created. - public int Count { get; } + internal int Count { get; } /// Determines whether the collection has changed since this token was created. /// The current version of the collection. /// true if the collection has changed; otherwise, false. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly bool HasChanged(long currentVersion) => Version != currentVersion; + internal bool HasChanged(long currentVersion) => Version != currentVersion; } diff --git a/src/ReactiveList/Internal/EnumerableExtensions.cs b/src/ReactiveList/Internal/EnumerableExtensions.cs index 6029673..7c79254 100644 --- a/src/ReactiveList/Internal/EnumerableExtensions.cs +++ b/src/ReactiveList/Internal/EnumerableExtensions.cs @@ -16,7 +16,25 @@ internal static class EnumerableExtensions { /// Creates a hash set from a sequence. /// A hash set containing the source elements. - public HashSet ToHashSet() => new(source); + internal HashSet ToHashSet() => new(source); } } +#elif REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; + +/// Marks the runtime-provided enumerable-extension compatibility path. +file enum EnumerableExtensions +{ + /// Indicates that the target runtime provides the extension. + RuntimeProvided, +} +#else +namespace CP.Primitives.Internal; + +/// Marks the runtime-provided enumerable-extension compatibility path. +file enum EnumerableExtensions +{ + /// Indicates that the target runtime provides the extension. + RuntimeProvided, +} #endif diff --git a/src/ReactiveList/Internal/EventPattern.cs b/src/ReactiveList/Internal/EventPattern.cs index 3ff9d44..0bac583 100644 --- a/src/ReactiveList/Internal/EventPattern.cs +++ b/src/ReactiveList/Internal/EventPattern.cs @@ -15,15 +15,15 @@ internal sealed class EventPattern /// Initializes a new instance of the class. /// The event sender. /// The event arguments. - public EventPattern(object? sender, TEventArgs eventArgs) + internal EventPattern(object? sender, TEventArgs eventArgs) { Sender = sender; EventArgs = eventArgs; } /// Gets the source object that raised the event. - public object? Sender { get; } + internal object? Sender { get; } /// Gets the event arguments. - public TEventArgs EventArgs { get; } + internal TEventArgs EventArgs { get; } } diff --git a/src/ReactiveList/Internal/IsExternalInit.cs b/src/ReactiveList/Internal/IsExternalInit.cs index 52044ba..2a14d94 100644 --- a/src/ReactiveList/Internal/IsExternalInit.cs +++ b/src/ReactiveList/Internal/IsExternalInit.cs @@ -8,8 +8,26 @@ namespace System.Runtime.CompilerServices; internal sealed class IsExternalInit { /// Initializes a new instance of the class. - private IsExternalInit() + internal IsExternalInit() { } } +#elif REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; + +/// Marks the runtime-provided init-only compatibility path. +file enum IsExternalInit +{ + /// Indicates that the target runtime provides init-only support. + RuntimeProvided, +} +#else +namespace CP.Primitives.Internal; + +/// Marks the runtime-provided init-only compatibility path. +file enum IsExternalInit +{ + /// Indicates that the target runtime provides init-only support. + RuntimeProvided, +} #endif diff --git a/src/ReactiveList/Internal/MaybeNullWhenAttribute.cs b/src/ReactiveList/Internal/MaybeNullWhenAttribute.cs index e88384f..b499dad 100644 --- a/src/ReactiveList/Internal/MaybeNullWhenAttribute.cs +++ b/src/ReactiveList/Internal/MaybeNullWhenAttribute.cs @@ -10,6 +10,24 @@ namespace System.Diagnostics.CodeAnalysis; internal sealed class MaybeNullWhenAttribute(bool returnValue) : Attribute { /// Gets a value indicating whether the attribute condition applies on true returns. - public bool ReturnValue { get; } = returnValue; + internal bool ReturnValue { get; } = returnValue; +} +#elif REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; + +/// Marks the runtime-provided nullable-annotation compatibility path. +file enum MaybeNullWhenAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, +} +#else +namespace CP.Primitives.Internal; + +/// Marks the runtime-provided nullable-annotation compatibility path. +file enum MaybeNullWhenAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, } #endif diff --git a/src/ReactiveList/Internal/NotificationRelay.cs b/src/ReactiveList/Internal/NotificationRelay.cs new file mode 100644 index 0000000..beb5dcc --- /dev/null +++ b/src/ReactiveList/Internal/NotificationRelay.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2023-2026 Chris Pulman and Contributors. All rights reserved. +// Chris Pulman and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; +#else +namespace CP.Primitives.Internal; +#endif +/// Relays notifications while preserving the sender exposed by a facade. +/// The type of event data delivered by the relay. +internal sealed class NotificationRelay + where TEventArgs : EventArgs +{ + /// Synchronizes changes to the subscribed handlers. + private readonly Lock _gate = new(); + + /// The facade instance reported as the sender for each notification. + private readonly object _sender; + + /// The handlers that receive relayed notifications. + private Action? _handlers; + + /// Initializes a new instance of the class. + /// The facade instance to report as the notification sender. + internal NotificationRelay(object sender) => _sender = sender ?? throw new ArgumentNullException(nameof(sender)); + + /// Adds a handler to receive subsequent relayed notifications. + /// The handler to add, or to make no change. + /// when the handler is the relay's first subscription; otherwise, . + /// Duplicate handlers are retained, matching .NET event subscription semantics. + internal bool Add(Action? handler) + { + if (handler is null) + { + return false; + } + + lock (_gate) + { + var isFirstHandler = _handlers is null; + _handlers += handler; + return isFirstHandler; + } + } + + /// Removes the last matching handler from the relay. + /// The handler to remove, or to make no change. + /// + /// when a handler was removed and the relay has no remaining subscriptions; + /// otherwise, . + /// + /// When a handler was added more than once, only its most recent subscription is removed. + internal bool Remove(Action? handler) + { + if (handler is null) + { + return false; + } + + lock (_gate) + { + var previousHandlers = _handlers; + var remainingHandlers = (Action?)Delegate.Remove(previousHandlers, handler); + var wasRemoved = !ReferenceEquals(previousHandlers, remainingHandlers); + _handlers = remainingHandlers; + return wasRemoved && remainingHandlers is null; + } + } + + /// Relays an event received from the wrapped source. + /// The wrapped source that raised the event. + /// The event data to relay. + /// The source is intentionally ignored so subscribers observe the facade as the sender. + internal void OnEvent(object? source, TEventArgs eventArgs) + { + _ = source; + Dispatch(eventArgs); + } + + /// Delivers a notification to a snapshot of the current handlers. + /// The event data to relay. + internal void Dispatch(TEventArgs eventArgs) + { + Action? handlers; + + lock (_gate) + { + handlers = _handlers; + } + + handlers?.Invoke(_sender, eventArgs); + } +} diff --git a/src/ReactiveList/Internal/Observable.cs b/src/ReactiveList/Internal/Observable.cs index a0cb7bc..6f48892 100644 --- a/src/ReactiveList/Internal/Observable.cs +++ b/src/ReactiveList/Internal/Observable.cs @@ -14,7 +14,7 @@ internal static class Observable /// The observable element type. /// The factory to create the deferred observable. /// An observable sequence that defers invocation to subscription. - public static IObservable Defer(Func> factory) + internal static IObservable Defer(Func> factory) { ThrowHelper.ThrowIfNull(factory); @@ -41,7 +41,7 @@ public static IObservable Defer(Func> factory) /// Adds the event handler. /// Removes the event handler. /// An observable sequence of event pattern values. - public static IObservable> FromEventPattern( + internal static IObservable> FromEventPattern( Action addHandler, Action removeHandler) where TEventHandler : Delegate @@ -56,13 +56,13 @@ public static IObservable> FromEventPattern - observer.OnNext(new EventPattern(sender, (TEventArgs)(EventArgs)args)); + observer.OnNext(new(sender, (TEventArgs)(EventArgs)args)); handler = (TEventHandler)(object)typed; } else if (typeof(TEventHandler) == typeof(EventHandler)) { EventHandler typed = (sender, args) => - observer.OnNext(new EventPattern(sender, args)); + observer.OnNext(new(sender, args)); handler = (TEventHandler)(object)typed; } else @@ -71,7 +71,9 @@ public static IObservable> FromEventPattern removeHandler(handler)); + return Scope.Create( + (removeHandler, handler), + static state => state.removeHandler(state.handler)); }); } } diff --git a/src/ReactiveList/Internal/ObservableMixins.cs b/src/ReactiveList/Internal/ObservableMixins.cs index e33f5ba..df6e737 100644 --- a/src/ReactiveList/Internal/ObservableMixins.cs +++ b/src/ReactiveList/Internal/ObservableMixins.cs @@ -19,7 +19,7 @@ internal static class ObservableMixins /// The output value type. /// A function that returns an enumerable of output elements for each source value. /// An observable sequence of flattened results. - public IObservable FlatMap(Func> selector) + internal IObservable FlatMap(Func> selector) { ThrowHelper.ThrowIfNull(source); ThrowHelper.ThrowIfNull(selector); @@ -39,7 +39,7 @@ public IObservable FlatMap(Func> /// Converts an observable sequence to a materialized list. /// A list containing all values from the source sequence. - public IEnumerable ToEnumerable() + internal IEnumerable ToEnumerable() { ThrowHelper.ThrowIfNull(source); diff --git a/src/ReactiveList/Internal/PooledBuffer.cs b/src/ReactiveList/Internal/PooledBuffer.cs index c8ed42b..4450c40 100644 --- a/src/ReactiveList/Internal/PooledBuffer.cs +++ b/src/ReactiveList/Internal/PooledBuffer.cs @@ -13,24 +13,14 @@ namespace CP.Primitives.Internal; /// The number of valid elements in . internal sealed class PooledBuffer(T[] buffer, int length) : IDisposable { + /// The number of valid elements in the rented buffer. private readonly int _length = length; + /// The rented buffer, or after disposal. private T[]? _buffer = buffer; /// Gets a read-only span over the valid elements in the buffer. - public ReadOnlySpan Span => _buffer.AsSpan(0, _length); - - /// Creates a new pooled buffer containing the elements of the specified list. - /// The returned buffer uses a pooled array for storage. The caller is responsible for disposing - /// the buffer when it is no longer needed to return the underlying array to the pool. - /// The list whose elements are copied into the new pooled buffer. Cannot be null. - /// A new containing the elements of . - public static PooledBuffer FromList(List source) - { - var arr = ArrayPool.Shared.Rent(source.Count); - source.CopyTo(arr); - return new(arr, source.Count); - } + internal ReadOnlySpan Span => _buffer.AsSpan(0, _length); /// Releases all resources used by the current instance of the object. /// Call this method when you are finished using the object to return any pooled resources and @@ -45,4 +35,16 @@ public void Dispose() ArrayPool.Shared.Return(_buffer, clearArray: ArrayPoolClearHelper.IsReferenceOrContainsReferences()); _buffer = null; } + + /// Creates a new pooled buffer containing the elements of the specified list. + /// The returned buffer uses a pooled array for storage. The caller is responsible for disposing + /// the buffer when it is no longer needed to return the underlying array to the pool. + /// The list whose elements are copied into the new pooled buffer. Cannot be null. + /// A new containing the elements of . + internal static PooledBuffer FromList(List source) + { + var arr = ArrayPool.Shared.Rent(source.Count); + source.CopyTo(arr); + return new(arr, source.Count); + } } diff --git a/src/ReactiveList/Internal/ReactiveListScheduler.cs b/src/ReactiveList/Internal/ReactiveListScheduler.cs index 9586337..928ff47 100644 --- a/src/ReactiveList/Internal/ReactiveListScheduler.cs +++ b/src/ReactiveList/Internal/ReactiveListScheduler.cs @@ -12,19 +12,19 @@ namespace CP.Primitives.Internal; internal static class ReactiveListScheduler { /// Gets the current-thread scheduler. - public static ISequencer CurrentThread + internal static ISequencer CurrentThread => #if REACTIVELIST_REACTIVE - => System.Reactive.Concurrency.CurrentThreadScheduler.Instance; + System.Reactive.Concurrency.CurrentThreadScheduler.Instance; #else - => Sequencer.CurrentThread; + Sequencer.CurrentThread; #endif /// Gets the default scheduler. - public static ISequencer Default + internal static ISequencer Default => #if REACTIVELIST_REACTIVE - => System.Reactive.Concurrency.Scheduler.Default; + System.Reactive.Concurrency.Scheduler.Default; #else - => Sequencer.Default; + Sequencer.Default; #endif /// Schedules an action after a relative delay. @@ -32,7 +32,7 @@ public static ISequencer Default /// The relative delay. /// The action to run. /// The scheduled action disposable. - public static IDisposable Schedule(ISequencer scheduler, TimeSpan dueTime, Action action) + internal static IDisposable Schedule(ISequencer scheduler, TimeSpan dueTime, Action action) { ThrowHelper.ThrowIfNull(scheduler); ThrowHelper.ThrowIfNull(action); diff --git a/src/ReactiveList/Internal/ShardHash.cs b/src/ReactiveList/Internal/ShardHash.cs index 6f565e4..0d6ca83 100644 --- a/src/ReactiveList/Internal/ShardHash.cs +++ b/src/ReactiveList/Internal/ShardHash.cs @@ -10,6 +10,7 @@ namespace CP.Primitives.Internal; /// Provides optimized hash code calculation for sharding. internal static class ShardHash { + /// The number of bits in a 32-bit hash code. private const int HashBitCount = 32; /// Computes a shard index using optimized bit manipulation. @@ -18,7 +19,7 @@ internal static class ShardHash /// The number of shards (must be power of 2). /// The shard index. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int GetShardIndex(T key, int shardCount) + internal static int GetShardIndex(T key, int shardCount) { // Use unsigned shift to ensure positive result // Multiply by golden ratio to improve distribution @@ -31,7 +32,7 @@ public static int GetShardIndex(T key, int shardCount) /// The key to hash. /// The shard index (0-3). [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int GetShardIndex4(T key) + internal static int GetShardIndex4(T key) { // Optimized for 4 shards (2 bits needed) var hash = key?.GetHashCode() ?? 0; diff --git a/src/ReactiveList/Internal/SkipLocalsInitAttribute.cs b/src/ReactiveList/Internal/SkipLocalsInitAttribute.cs index 7a72329..794106f 100644 --- a/src/ReactiveList/Internal/SkipLocalsInitAttribute.cs +++ b/src/ReactiveList/Internal/SkipLocalsInitAttribute.cs @@ -7,4 +7,22 @@ namespace System.Runtime.CompilerServices; /// Indicates that local variables should not be zero-initialized. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Module)] internal sealed class SkipLocalsInitAttribute : Attribute; +#elif REACTIVELIST_REACTIVE +namespace CP.Reactive.Internal; + +/// Marks the runtime-provided local-initialization compatibility path. +file enum SkipLocalsInitAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, +} +#else +namespace CP.Primitives.Internal; + +/// Marks the runtime-provided local-initialization compatibility path. +file enum SkipLocalsInitAttribute +{ + /// Indicates that the target runtime provides the attribute. + RuntimeProvided, +} #endif diff --git a/src/ReactiveList/Internal/ThrowHelper.cs b/src/ReactiveList/Internal/ThrowHelper.cs index a1396ae..157930c 100644 --- a/src/ReactiveList/Internal/ThrowHelper.cs +++ b/src/ReactiveList/Internal/ThrowHelper.cs @@ -15,20 +15,24 @@ internal static class ThrowHelper /// The argument value. /// The parameter name, supplied by the compiler. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfNull( +#if NETFRAMEWORK + internal static void ThrowIfNull( T? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) where T : class { -#if NETFRAMEWORK if (argument is not null) { return; } throw new ArgumentNullException(paramName); + } #else + internal static void ThrowIfNull( + T? argument, + [CallerArgumentExpression(nameof(argument))] string? paramName = null) + where T : class => ArgumentNullException.ThrowIfNull(argument, paramName); #endif - } } diff --git a/src/ReactiveList/Internal/ValueBuffer.cs b/src/ReactiveList/Internal/ValueBuffer.cs index 11e2a1f..f4dd45c 100644 --- a/src/ReactiveList/Internal/ValueBuffer.cs +++ b/src/ReactiveList/Internal/ValueBuffer.cs @@ -14,17 +14,21 @@ namespace CP.Primitives.Internal; /// The type of elements in the buffer. internal ref struct ValueBuffer { + /// The multiplier applied when a rented buffer must grow. private const int BufferGrowthFactor = 2; + /// The caller-provided stack buffer used before renting an array. private readonly Span _stackBuffer; + /// The rented array used after the stack buffer is exhausted. private T[]? _rentedArray; + /// The number of elements currently stored in the buffer. private int _count; /// Initializes a new instance of the struct with a stack-allocated buffer. /// The stack-allocated buffer to use initially. - public ValueBuffer(in Span stackBuffer) + internal ValueBuffer(in Span stackBuffer) { _stackBuffer = stackBuffer; _rentedArray = null; @@ -32,17 +36,17 @@ public ValueBuffer(in Span stackBuffer) } /// Gets the number of elements in the buffer. - public readonly int Count => _count; + internal readonly int Count => _count; /// Gets a span over the valid elements in the buffer. - public readonly ReadOnlySpan Span => _rentedArray is not null + internal readonly ReadOnlySpan Span => _rentedArray is not null ? _rentedArray.AsSpan(0, _count) : _stackBuffer.Slice(0, _count); /// Adds an item to the buffer, growing if necessary. /// The item to add. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Add(T item) + internal void Add(T item) { var count = _count; @@ -69,7 +73,7 @@ public void Add(T item) } /// Returns the rented array to the pool if one was used. - public void Dispose() + internal void Dispose() { if (_rentedArray is null) { diff --git a/src/ReactiveList/QuaternaryExtensions.cs b/src/ReactiveList/QuaternaryExtensions.cs index 2bb9910..e8eb31d 100644 --- a/src/ReactiveList/QuaternaryExtensions.cs +++ b/src/ReactiveList/QuaternaryExtensions.cs @@ -17,6 +17,9 @@ namespace CP.Primitives; /// view creation. public static class QuaternaryExtensions { + /// The default view throttle interval in milliseconds. + private const int DefaultThrottleMs = 50; + /// Extensions for key-value cache notification streams. /// The key type carried by the receiver. /// The value type carried by the receiver. @@ -48,14 +51,13 @@ bool Matches(KeyValuePair item) => return notification.Action switch { - CacheAction.Added when Matches(notification.Item) => notification, - CacheAction.Removed when Matches(notification.Item) => notification, + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action == CacheAction.Cleared || Matches(notification.Item) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => ReactiveListExtensions.FilterBatchByPredicate(notification, Matches), - CacheAction.Cleared => notification, _ => null }; - }).Where(n => n is not null).Map(n => n!); + }).Where(static n => n is not null).Map(static n => n!); } /// @@ -81,18 +83,17 @@ public IObservable>> FilterBySecondaryInd return stream.Map(notification => { bool Matches(KeyValuePair item) => - item.Value is not null && indexKeys.Any(key => dict.ValueMatchesSecondaryIndex(indexName, item.Value, key)); + item.Value is not null && Array.Exists(indexKeys, key => dict.ValueMatchesSecondaryIndex(indexName, item.Value, key)); return notification.Action switch { - CacheAction.Added when Matches(notification.Item) => notification, - CacheAction.Removed when Matches(notification.Item) => notification, + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action == CacheAction.Cleared || Matches(notification.Item) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => ReactiveListExtensions.FilterBatchByPredicate(notification, Matches), - CacheAction.Cleared => notification, _ => null }; - }).Where(n => n is not null).Map(n => n!); + }).Where(static n => n is not null).Map(static n => n!); } } @@ -124,14 +125,14 @@ public IObservable> FilterBySecondaryIndex(QuaternaryList notification, - CacheAction.Removed when notification.Item is not null && Matches(notification.Item) => notification, + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action == CacheAction.Cleared + || (notification.Item is not null && Matches(notification.Item)) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => ReactiveListExtensions.FilterBatchByPredicate(notification, Matches), - CacheAction.Cleared => notification, _ => null }; - }).Where(n => n is not null).Map(n => n!); + }).Where(static n => n is not null).Map(static n => n!); } /// Filters the cache notification stream to only include items that match any of the specified secondary index keys. @@ -154,18 +155,18 @@ public IObservable> FilterBySecondaryIndex(QuaternaryList { - bool Matches(T item) => keys.Any(key => list.ItemMatchesSecondaryIndex(indexName, item, key)); + bool Matches(T item) => Array.Exists(keys, key => list.ItemMatchesSecondaryIndex(indexName, item, key)); return notification.Action switch { - CacheAction.Added when notification.Item is not null && Matches(notification.Item) => notification, - CacheAction.Removed when notification.Item is not null && Matches(notification.Item) => notification, + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action == CacheAction.Cleared + || (notification.Item is not null && Matches(notification.Item)) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => ReactiveListExtensions.FilterBatchByPredicate(notification, Matches), - CacheAction.Cleared => notification, _ => null }; - }).Where(n => n is not null).Map(n => n!); + }).Where(static n => n is not null).Map(static n => n!); } } @@ -183,24 +184,47 @@ public IObservable> FilterBySecondaryIndex(QuaternaryListThe name of the secondary value index to use for filtering. /// The key value to filter by. /// The scheduler used to manage update notifications for the view. + /// A view filtered by the specified secondary index key. + public ReactiveView> CreateViewBySecondaryIndex( + string indexName, + TIndexKey indexKey, + ISequencer scheduler) + where TIndexKey : notnull => + QuaternaryExtensions.CreateViewBySecondaryIndex(dict, indexName, indexKey, scheduler, DefaultThrottleMs); + + /// Creates a reactive view filtered by a secondary value index key. + /// The type of the secondary index key. + /// The name of the secondary value index to use for filtering. + /// The key value to filter by. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the dictionary matching the specified /// secondary index key. - public ReactiveView> CreateViewBySecondaryIndex(string indexName, TIndexKey indexKey, ISequencer scheduler, int throttleMs = 50) + public ReactiveView> CreateViewBySecondaryIndex(string indexName, TIndexKey indexKey, ISequencer scheduler, int throttleMs) where TIndexKey : notnull { ThrowHelper.ThrowIfNull(dict); ThrowHelper.ThrowIfNull(indexName); // Get initial snapshot from the secondary index - map values back to key-value pairs - var matchingValues = dict.GetValuesBySecondaryIndex(indexName, indexKey).ToHashSet(); - var snapshot = dict.Where(kvp => matchingValues.Contains(kvp.Value)); + var matchingValues = new HashSet(dict.GetValuesBySecondaryIndex(indexName, indexKey)); + + IEnumerable> GetSnapshot() + { + foreach (var item in dict) + { + if (matchingValues.Contains(item.Value)) + { + yield return item; + } + } + } // Create a filter that dynamically checks against the secondary index - return new ReactiveView>( + return new( dict.Stream, - snapshot, + GetSnapshot(), kvp => dict.ValueMatchesSecondaryIndex(indexName, kvp.Value, indexKey), TimeSpan.FromMilliseconds(throttleMs), scheduler); @@ -213,11 +237,24 @@ public ReactiveView> CreateViewBySecondaryIndexThe name of the secondary value index to use for filtering. /// The key values to filter by. Items whose values match any of these keys are included. /// The scheduler used to manage update notifications for the view. + /// A view filtered by any of the specified secondary index keys. + public ReactiveView> CreateViewBySecondaryIndex( + string indexName, + TIndexKey[] indexKeys, + ISequencer scheduler) + where TIndexKey : notnull => + QuaternaryExtensions.CreateViewBySecondaryIndex(dict, indexName, indexKeys, scheduler, DefaultThrottleMs); + + /// Creates a reactive view filtered by multiple secondary value index keys. + /// The type of the secondary index key. + /// The name of the secondary value index to use for filtering. + /// The key values to filter by. Items whose values match any of these keys are included. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the dictionary matching any of the /// specified secondary index keys. - public ReactiveView> CreateViewBySecondaryIndex(string indexName, TIndexKey[] indexKeys, ISequencer scheduler, int throttleMs = 50) + public ReactiveView> CreateViewBySecondaryIndex(string indexName, TIndexKey[] indexKeys, ISequencer scheduler, int throttleMs) where TIndexKey : notnull { ThrowHelper.ThrowIfNull(dict); @@ -225,15 +262,46 @@ public ReactiveView> CreateViewBySecondaryIndex dict.GetValuesBySecondaryIndex(indexName, k)).ToHashSet(); - var snapshot = dict.Where(kvp => matchingValues.Contains(kvp.Value)); + var matchingValues = new HashSet(); + foreach (var key in indexKeys) + { + foreach (var value in dict.GetValuesBySecondaryIndex(indexName, key)) + { + _ = matchingValues.Add(value); + } + } + + IEnumerable> GetSnapshot() + { + foreach (var item in dict) + { + if (matchingValues.Contains(item.Value)) + { + yield return item; + } + } + } // Create a filter that dynamically checks against any of the keys var keySet = new HashSet(indexKeys); - return new ReactiveView>( + + bool MatchesAny(TValue value) + { + foreach (var key in keySet) + { + if (dict.ValueMatchesSecondaryIndex(indexName, value, key)) + { + return true; + } + } + + return false; + } + + return new( dict.Stream, - snapshot, - kvp => keySet.Any(k => dict.ValueMatchesSecondaryIndex(indexName, kvp.Value, k)), + GetSnapshot(), + kvp => MatchesAny(kvp.Value), TimeSpan.FromMilliseconds(throttleMs), scheduler); } @@ -247,11 +315,28 @@ public ReactiveView> CreateViewBySecondaryIndexThe name of the secondary value index to use for filtering. /// An observable that emits arrays of key values. When new keys are emitted, the view rebuilds its contents. /// The scheduler used to manage update notifications for the view. + /// A dynamic view filtered by the emitted secondary index keys. + public DynamicSecondaryIndexDictionaryReactiveView CreateDynamicViewBySecondaryIndex( + string indexName, + IObservable keysObservable, + ISequencer scheduler) + where TIndexKey : notnull => + QuaternaryExtensions.CreateDynamicViewBySecondaryIndex(dict, indexName, keysObservable, scheduler, DefaultThrottleMs); + + /// Creates a reactive view with a dynamic secondary value index key filter. + /// The type of the secondary index key. + /// The name of the secondary value index to use for filtering. + /// An observable that emits arrays of key values. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the dictionary matching the /// specified secondary index keys and updates when the keys change. - public DynamicSecondaryIndexDictionaryReactiveView CreateDynamicViewBySecondaryIndex(string indexName, IObservable keysObservable, ISequencer scheduler, int throttleMs = 50) + public DynamicSecondaryIndexDictionaryReactiveView CreateDynamicViewBySecondaryIndex( + string indexName, + IObservable keysObservable, + ISequencer scheduler, + int throttleMs) where TIndexKey : notnull { ThrowHelper.ThrowIfNull(dict); @@ -275,11 +360,21 @@ public DynamicSecondaryIndexDictionaryReactiveView CreateDynamicVi /// The name of the secondary index to use for filtering. /// The key value to filter by. /// The scheduler used to manage update notifications for the view. + /// A view filtered by the specified secondary index key. + public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey key, ISequencer scheduler) + where TKey : notnull => + QuaternaryExtensions.CreateViewBySecondaryIndex(list, indexName, key, scheduler, DefaultThrottleMs); + + /// Creates a reactive view filtered by a secondary index key. + /// The type of the secondary index key. + /// The name of the secondary index to use for filtering. + /// The key value to filter by. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the source matching the specified /// secondary index key. - public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey key, ISequencer scheduler, int throttleMs = 50) + public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey key, ISequencer scheduler, int throttleMs) where TKey : notnull { ThrowHelper.ThrowIfNull(list); @@ -290,7 +385,12 @@ public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey k // Create a filter that dynamically checks against the secondary index // This ensures new items are properly filtered based on their key value - return new ReactiveView(list.Stream, snapshot, item => list.ItemMatchesSecondaryIndex(indexName, item, key), TimeSpan.FromMilliseconds(throttleMs), scheduler); + return new( + list.Stream, + snapshot, + item => list.ItemMatchesSecondaryIndex(indexName, item, key), + TimeSpan.FromMilliseconds(throttleMs), + scheduler); } /// Creates a reactive view filtered by multiple secondary index keys. @@ -300,11 +400,21 @@ public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey k /// The name of the secondary index to use for filtering. /// The key values to filter by. Items matching any of these keys are included. /// The scheduler used to manage update notifications for the view. + /// A view filtered by any of the specified secondary index keys. + public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey[] keys, ISequencer scheduler) + where TKey : notnull => + QuaternaryExtensions.CreateViewBySecondaryIndex(list, indexName, keys, scheduler, DefaultThrottleMs); + + /// Creates a reactive view filtered by multiple secondary index keys. + /// The type of the secondary index key. + /// The name of the secondary index to use for filtering. + /// The key values to filter by. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the source matching any of the /// specified secondary index keys. - public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey[] keys, ISequencer scheduler, int throttleMs = 50) + public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey[] keys, ISequencer scheduler, int throttleMs) where TKey : notnull { ThrowHelper.ThrowIfNull(list); @@ -312,14 +422,37 @@ public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey[] ThrowHelper.ThrowIfNull(keys); // Get initial snapshot from the secondary index for all keys - var snapshot = keys.SelectMany(key => list.GetItemsBySecondaryIndex(indexName, key)); + IEnumerable GetSnapshot() + { + foreach (var key in keys) + { + foreach (var item in list.GetItemsBySecondaryIndex(indexName, key)) + { + yield return item; + } + } + } // Create a filter that dynamically checks against any of the keys var keySet = new HashSet(keys); - return new ReactiveView( + + bool MatchesAny(T item) + { + foreach (var key in keySet) + { + if (list.ItemMatchesSecondaryIndex(indexName, item, key)) + { + return true; + } + } + + return false; + } + + return new( list.Stream, - snapshot, - item => keySet.Any(key => list.ItemMatchesSecondaryIndex(indexName, item, key)), + GetSnapshot(), + MatchesAny, TimeSpan.FromMilliseconds(throttleMs), scheduler); } @@ -331,18 +464,31 @@ public ReactiveView CreateViewBySecondaryIndex(string indexName, TKey[] /// The name of the secondary index to use for filtering. /// An observable that emits arrays of key values. When new keys are emitted, the view rebuilds its contents. /// The scheduler used to manage update notifications for the view. + /// A dynamic view filtered by the emitted secondary index keys. + public DynamicSecondaryIndexReactiveView CreateDynamicViewBySecondaryIndex( + string indexName, + IObservable keysObservable, + ISequencer scheduler) + where TKey : notnull => + QuaternaryExtensions.CreateDynamicViewBySecondaryIndex(list, indexName, keysObservable, scheduler, DefaultThrottleMs); + + /// Creates a reactive view with a dynamic secondary index key filter. + /// The type of the secondary index key. + /// The name of the secondary index to use for filtering. + /// An observable that emits arrays of key values. + /// The scheduler used to manage update notifications for the view. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the source matching the /// specified secondary index keys and updates when the keys change. - public DynamicSecondaryIndexReactiveView CreateDynamicViewBySecondaryIndex(string indexName, IObservable keysObservable, ISequencer scheduler, int throttleMs = 50) + public DynamicSecondaryIndexReactiveView CreateDynamicViewBySecondaryIndex(string indexName, IObservable keysObservable, ISequencer scheduler, int throttleMs) where TKey : notnull { ThrowHelper.ThrowIfNull(list); ThrowHelper.ThrowIfNull(indexName); ThrowHelper.ThrowIfNull(keysObservable); - return new DynamicSecondaryIndexReactiveView(list, indexName, keysObservable, scheduler, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, indexName, keysObservable, scheduler, TimeSpan.FromMilliseconds(throttleMs)); } } } diff --git a/src/ReactiveList/ReactiveListExtensions.cs b/src/ReactiveList/ReactiveListExtensions.cs index 3d02e7b..9146a0f 100644 --- a/src/ReactiveList/ReactiveListExtensions.cs +++ b/src/ReactiveList/ReactiveListExtensions.cs @@ -10,6 +10,9 @@ namespace CP.Primitives; /// Provides extension methods for reactive list operations including filtering, transforming, and observing changes. public static class ReactiveListExtensions { + /// The default view throttle interval in milliseconds. + private const int DefaultThrottleMs = 50; + /// Extensions for key-value cache notification streams. /// The key type carried by the receiver. /// The value type carried by the receiver. @@ -46,13 +49,12 @@ public IObservable>> FilterDynamic(IObser .Lead(static _ => true) // Default to include all items .Map(filter => stream.Map(notification => notification.Action switch { - CacheAction.Added when filter(notification.Item) => notification, - CacheAction.Removed => notification, // Always pass removed items + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action != CacheAction.Added || filter(notification.Item) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => FilterBatchByPredicate(notification, filter), - CacheAction.Cleared => notification, _ => null - }).Where(n => n is not null).Map(n => n!)) + }).Where(static n => n is not null).Map(static n => n!)) .SwitchTo(); } } @@ -92,13 +94,14 @@ public IObservable> FilterDynamic(IObservable> filt .Lead(static _ => true) // Default to include all items .Map(filter => stream.Map(notification => notification.Action switch { - CacheAction.Added when notification.Item is not null && filter(notification.Item) => notification, - CacheAction.Removed when notification.Item is not null => notification, // Always pass removed items + CacheAction.Added or CacheAction.Removed or CacheAction.Cleared + when notification.Action == CacheAction.Cleared + || (notification.Item is not null + && (notification.Action == CacheAction.Removed || filter(notification.Item))) => notification, CacheAction.BatchAdded or CacheAction.BatchRemoved or CacheAction.BatchOperation when notification.Batch is not null => FilterBatchByPredicate(notification, filter), - CacheAction.Cleared => notification, _ => null - }).Where(n => n is not null).Map(n => n!)) + }).Where(static n => n is not null).Map(static n => n!)) .SwitchTo(); } @@ -188,12 +191,13 @@ public IObservable> WhereChanges(Func, bool> predicate) var change = changeSet[i]; if (predicate(change)) { - filtered[idx++] = change; + filtered[idx] = change; + idx++; } } return new ChangeSet(filtered); - }).Where(cs => cs.Count > 0); + }).Where(static cs => cs.Count > 0); } /// Filters the change stream to only include changes of a specific reason. @@ -292,25 +296,25 @@ public IObservable SelectChanges(Func, TResult> sele /// An observable of added items. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable OnAdd() => - source.WhereReason(ChangeReason.Add).SelectChanges(c => c.Current); + source.WhereReason(ChangeReason.Add).SelectChanges(static c => c.Current); /// Subscribes to removes only from the change stream. /// An observable of removed items. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable OnRemove() => - source.WhereReason(ChangeReason.Remove).SelectChanges(c => c.Current); + source.WhereReason(ChangeReason.Remove).SelectChanges(static c => c.Current); /// Subscribes to updates only from the change stream. /// An observable of update tuples containing (Previous, Current) values. [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable<(T? Previous, T Current)> OnUpdate() => - source.WhereReason(ChangeReason.Update).SelectChanges(c => (c.Previous, c.Current)); + source.WhereReason(ChangeReason.Update).SelectChanges(static c => (c.Previous, c.Current)); /// Subscribes to moves only from the change stream. /// An observable of move tuples containing (Item, OldIndex, NewIndex). [MethodImpl(MethodImplOptions.AggressiveInlining)] public IObservable<(T Item, int OldIndex, int NewIndex)> OnMove() => - source.WhereReason(ChangeReason.Move).SelectChanges(c => (c.Current, c.PreviousIndex, c.CurrentIndex)); + source.WhereReason(ChangeReason.Move).SelectChanges(static c => (c.Current, c.PreviousIndex, c.CurrentIndex)); /// /// Projects each item in the source change set into groups based on a specified key, emitting an observable @@ -325,16 +329,8 @@ public IObservable OnRemove() => /// they change. public IObservable> GroupByChanges(Func keySelector) { - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (keySelector is null) - { - throw new ArgumentNullException(nameof(keySelector)); - } - + ThrowHelper.ThrowIfNull(source); + ThrowHelper.ThrowIfNull(keySelector); return Signal.Create>(observer => { var groups = new List>(); @@ -345,7 +341,17 @@ public IObservable> GroupByChanges(Func EqualityComparer.Default.Equals(g.Key, key)); + GroupedObservable? group = null; + for (var groupIndex = 0; groupIndex < groups.Count; groupIndex++) + { + var candidate = groups[groupIndex]; + if (EqualityComparer.Default.Equals(candidate.Key, key)) + { + group = candidate; + break; + } + } + if (group is null) { group = new(key); @@ -375,14 +381,9 @@ public IObservable> GroupByChanges(Func - { - subscription.Dispose(); - foreach (var group in groups) - { - group.Dispose(); - } - }); + return Scope.Create( + (Subscription: subscription, Groups: groups), + static state => DisposeGroups(state.Subscription, state.Groups)); }); } @@ -393,15 +394,8 @@ public IObservable> GroupByChanges(Func>> GroupingByChanges(Func keySelector) where TKey : notnull { - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (keySelector is null) - { - throw new ArgumentNullException(nameof(keySelector)); - } + ThrowHelper.ThrowIfNull(source); + ThrowHelper.ThrowIfNull(keySelector); return source.FlatMap(changeSet => { @@ -410,6 +404,15 @@ public IObservable>> GroupingByChanges(Func>> GroupingByChanges(Func new ChangeGrouping>(kvp.Key, kvp.Value)); + var result = new ChangeGrouping>[groups.Count]; + var index = 0; + foreach (var group in groups) + { + result[index] = new(group.Key, group.Value); + index++; + } + + return result; }); } @@ -432,7 +444,27 @@ public IObservable>> GroupingByChanges(Func public IObservable> SortBy(Func keySelector) => source.Map(changes => { - var sorted = changes.OrderBy(c => keySelector(c.Current)).ToArray(); + var keyedChanges = new (Change Change, TKey Key, int OriginalIndex)[changes.Count]; + for (var i = 0; i < changes.Count; i++) + { + var change = changes[i]; + keyedChanges[i] = (change, keySelector(change.Current), i); + } + + Array.Sort( + keyedChanges, + static (left, right) => + { + var comparison = Comparer.Default.Compare(left.Key, right.Key); + return comparison != 0 ? comparison : left.OriginalIndex.CompareTo(right.OriginalIndex); + }); + + var sorted = new Change[keyedChanges.Length]; + for (var i = 0; i < keyedChanges.Length; i++) + { + sorted[i] = keyedChanges[i].Change; + } + return new ChangeSet(sorted); }); } @@ -448,10 +480,7 @@ public IObservable> SortBy(Func keySelector) => sour /// An observable that includes refresh notifications when property changes occur. public IObservable> AutoRefresh(string? propertyName) { - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } + ThrowHelper.ThrowIfNull(source); var watchAllProperties = string.IsNullOrEmpty(propertyName); @@ -491,6 +520,26 @@ public IObservable> AutoRefresh(string? propertyName) extension(IReactiveList list) where T : notnull { + /// Creates a filtered view of the reactive list that updates automatically when the source changes. + /// A predicate to filter items. + /// A read-only observable collection that stays synchronized with the filtered source. + public FilteredReactiveView CreateView(Func filter) => + list.CreateView(filter, null, DefaultThrottleMs); + + /// Creates a filtered view using the specified scheduler and default throttling. + /// A predicate to filter items. + /// The scheduler for dispatching updates, or to use the default. + /// A read-only observable collection that stays synchronized with the filtered source. + public FilteredReactiveView CreateView(Func filter, ISequencer? scheduler) => + list.CreateView(filter, scheduler, DefaultThrottleMs); + + /// Creates a filtered view using the default scheduler and specified throttling. + /// A predicate to filter items. + /// The throttle interval in milliseconds. + /// A read-only observable collection that stays synchronized with the filtered source. + public FilteredReactiveView CreateView(Func filter, int throttleMs) => + list.CreateView(filter, null, throttleMs); + /// Creates a filtered view of the reactive list that updates automatically when the source changes. /// A predicate to filter items. /// Optional scheduler for dispatching updates. Defaults to CurrentThreadScheduler. @@ -498,8 +547,8 @@ public IObservable> AutoRefresh(string? propertyName) /// A read-only observable collection that stays synchronized with the filtered source. public FilteredReactiveView CreateView( Func filter, - ISequencer? scheduler = null, - int throttleMs = 50) + ISequencer? scheduler, + int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(list); @@ -516,17 +565,54 @@ public FilteredReactiveView CreateView( } #endif - return new FilteredReactiveView(list, filter, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, filter, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); } + /// Creates an unfiltered view of the reactive list that updates automatically when the source changes. + /// A read-only observable collection that stays synchronized with the source. + public FilteredReactiveView CreateView() => + list.CreateView((ISequencer?)null, DefaultThrottleMs); + + /// Creates an unfiltered view using the specified scheduler and default throttling. + /// The scheduler for dispatching updates, or to use the default. + /// A read-only observable collection that stays synchronized with the source. + public FilteredReactiveView CreateView(ISequencer? scheduler) => + list.CreateView(scheduler, DefaultThrottleMs); + + /// Creates an unfiltered view using the default scheduler and specified throttling. + /// The throttle interval in milliseconds. + /// A read-only observable collection that stays synchronized with the source. + public FilteredReactiveView CreateView(int throttleMs) => + list.CreateView((ISequencer?)null, throttleMs); + /// Creates an unfiltered view of the reactive list that updates automatically when the source changes. /// Optional scheduler for dispatching updates. Defaults to CurrentThreadScheduler. /// Throttle interval in milliseconds. Defaults to 50ms. /// A read-only observable collection that stays synchronized with the source. public FilteredReactiveView CreateView( - ISequencer? scheduler = null, - int throttleMs = 50) - => list.CreateView(_ => true, scheduler, throttleMs); + ISequencer? scheduler, + int throttleMs) => + list.CreateView(static _ => true, scheduler, throttleMs); + + /// Creates a dynamically filtered view that rebuilds when the filter predicate changes. + /// An observable that emits filter predicates. + /// A read-only observable collection that updates when the source or filter changes. + public DynamicFilteredReactiveView CreateView(IObservable> filterObservable) => + list.CreateView(filterObservable, null, DefaultThrottleMs); + + /// Creates a dynamically filtered view using the specified scheduler and default throttling. + /// An observable that emits filter predicates. + /// The scheduler for dispatching updates, or to use the default. + /// A read-only observable collection that updates when the source or filter changes. + public DynamicFilteredReactiveView CreateView(IObservable> filterObservable, ISequencer? scheduler) => + list.CreateView(filterObservable, scheduler, DefaultThrottleMs); + + /// Creates a dynamically filtered view using the default scheduler and specified throttling. + /// An observable that emits filter predicates. + /// The throttle interval in milliseconds. + /// A read-only observable collection that updates when the source or filter changes. + public DynamicFilteredReactiveView CreateView(IObservable> filterObservable, int throttleMs) => + list.CreateView(filterObservable, null, throttleMs); /// Creates a dynamically filtered view that rebuilds when the filter predicate changes. /// An observable that emits filter predicates. @@ -535,8 +621,8 @@ public FilteredReactiveView CreateView( /// A read-only observable collection that updates when the source or filter changes. public DynamicFilteredReactiveView CreateView( IObservable> filterObservable, - ISequencer? scheduler = null, - int throttleMs = 50) + ISequencer? scheduler, + int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(list); @@ -553,9 +639,29 @@ public DynamicFilteredReactiveView CreateView( } #endif - return new DynamicFilteredReactiveView(list, filterObservable, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, filterObservable, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); } + /// Creates a sorted view of the reactive list that updates automatically when the source changes. + /// The comparer to use for sorting. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(IComparer comparer) => + list.SortBy(comparer, null, DefaultThrottleMs); + + /// Creates a sorted view using the specified scheduler and default throttling. + /// The comparer to use for sorting. + /// The scheduler for dispatching updates, or to use the default. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(IComparer comparer, ISequencer? scheduler) => + list.SortBy(comparer, scheduler, DefaultThrottleMs); + + /// Creates a sorted view using the default scheduler and specified throttling. + /// The comparer to use for sorting. + /// The throttle interval in milliseconds. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(IComparer comparer, int throttleMs) => + list.SortBy(comparer, null, throttleMs); + /// Creates a sorted view of the reactive list that updates automatically when the source changes. /// The comparer to use for sorting. /// Optional scheduler for dispatching updates. Defaults to CurrentThreadScheduler. @@ -563,8 +669,8 @@ public DynamicFilteredReactiveView CreateView( /// A sorted view that stays synchronized with the source. public SortedReactiveView SortBy( IComparer comparer, - ISequencer? scheduler = null, - int throttleMs = 50) + ISequencer? scheduler, + int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(list); @@ -581,9 +687,67 @@ public SortedReactiveView SortBy( } #endif - return new SortedReactiveView(list, comparer, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, comparer, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); } + /// Creates a sorted view of the reactive list using a key selector. + /// The type of the sort key. + /// A function to extract the sort key. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector) => + list.SortBy(keySelector, false, null, DefaultThrottleMs); + + /// Creates a sorted view using a key selector and direction. + /// The type of the sort key. + /// A function to extract the sort key. + /// Whether to sort in descending order. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, bool descending) => + list.SortBy(keySelector, descending, null, DefaultThrottleMs); + + /// Creates an ascending sorted view using the specified scheduler and default throttling. + /// The type of the sort key. + /// A function to extract the sort key. + /// The scheduler for dispatching updates, or to use the default. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, ISequencer? scheduler) => + list.SortBy(keySelector, false, scheduler, DefaultThrottleMs); + + /// Creates an ascending sorted view using the default scheduler and specified throttling. + /// The type of the sort key. + /// A function to extract the sort key. + /// The throttle interval in milliseconds. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, int throttleMs) => + list.SortBy(keySelector, false, null, throttleMs); + + /// Creates a sorted view using the specified direction and scheduler with default throttling. + /// The type of the sort key. + /// A function to extract the sort key. + /// Whether to sort in descending order. + /// The scheduler for dispatching updates, or to use the default. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, bool descending, ISequencer? scheduler) => + list.SortBy(keySelector, descending, scheduler, DefaultThrottleMs); + + /// Creates a sorted view using the specified direction, default scheduler, and throttling. + /// The type of the sort key. + /// A function to extract the sort key. + /// Whether to sort in descending order. + /// The throttle interval in milliseconds. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, bool descending, int throttleMs) => + list.SortBy(keySelector, descending, null, throttleMs); + + /// Creates an ascending sorted view using the specified scheduler and throttling. + /// The type of the sort key. + /// A function to extract the sort key. + /// The scheduler for dispatching updates, or to use the default. + /// The throttle interval in milliseconds. + /// A sorted view that stays synchronized with the source. + public SortedReactiveView SortBy(Func keySelector, ISequencer? scheduler, int throttleMs) => + list.SortBy(keySelector, false, scheduler, throttleMs); + /// Creates a sorted view of the reactive list using a key selector. /// The type of the sort key. /// A function to extract the sort key. @@ -593,9 +757,9 @@ public SortedReactiveView SortBy( /// A sorted view that stays synchronized with the source. public SortedReactiveView SortBy( Func keySelector, - bool descending = false, - ISequencer? scheduler = null, - int throttleMs = 50) + bool descending, + ISequencer? scheduler, + int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(list); @@ -616,9 +780,35 @@ public SortedReactiveView SortBy( ? Comparer.Create((x, y) => Comparer.Default.Compare(keySelector(y), keySelector(x))) : Comparer.Create((x, y) => Comparer.Default.Compare(keySelector(x), keySelector(y))); - return new SortedReactiveView(list, comparer, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, comparer, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); } + /// Creates a grouped view of the reactive list. + /// The type of the grouping key. + /// A function to extract the key for grouping. + /// A grouped view that stays synchronized with the source. + public GroupedReactiveView GroupBy(Func keySelector) + where TKey : notnull => + list.GroupBy(keySelector, null, DefaultThrottleMs); + + /// Creates a grouped view using the specified scheduler and default throttling. + /// The type of the grouping key. + /// A function to extract the key for grouping. + /// The scheduler for dispatching updates, or to use the default. + /// A grouped view that stays synchronized with the source. + public GroupedReactiveView GroupBy(Func keySelector, ISequencer? scheduler) + where TKey : notnull => + list.GroupBy(keySelector, scheduler, DefaultThrottleMs); + + /// Creates a grouped view using the default scheduler and specified throttling. + /// The type of the grouping key. + /// A function to extract the key for grouping. + /// The throttle interval in milliseconds. + /// A grouped view that stays synchronized with the source. + public GroupedReactiveView GroupBy(Func keySelector, int throttleMs) + where TKey : notnull => + list.GroupBy(keySelector, null, throttleMs); + /// Creates a grouped view of the reactive list. /// The type of the grouping key. /// A function to extract the key for grouping. @@ -627,8 +817,8 @@ public SortedReactiveView SortBy( /// A grouped view that stays synchronized with the source. public GroupedReactiveView GroupBy( Func keySelector, - ISequencer? scheduler = null, - int throttleMs = 50) + ISequencer? scheduler, + int throttleMs) where TKey : notnull { #if NET8_0_OR_GREATER @@ -646,7 +836,7 @@ public GroupedReactiveView GroupBy( } #endif - return new GroupedReactiveView(list, keySelector, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); + return new(list, keySelector, scheduler ?? ReactiveListScheduler.CurrentThread, TimeSpan.FromMilliseconds(throttleMs)); } } @@ -669,11 +859,23 @@ public GroupedReactiveView GroupBy( /// current query and an item, and returns to include the item in the view; otherwise, . Cannot be null. /// The scheduler used to observe and process updates to the view. + /// A dynamic view that uses the default throttle interval. + public DynamicReactiveView CreateView( + IObservable queryObservable, + Func filter, + ISequencer scheduler) => + source.CreateView(queryObservable, filter, scheduler, DefaultThrottleMs); + + /// Creates a dynamic reactive view using a query observable. + /// The type of query values. + /// The observable sequence of query values. + /// The query-aware item predicate. + /// The scheduler used to process updates. /// The minimum time, in milliseconds, to wait before applying updates after a query change. Defaults to 50 /// milliseconds. /// A that reflects the filtered view of the source and updates automatically as /// the query observable emits new values. - public DynamicReactiveView CreateView(IObservable queryObservable, Func filter, ISequencer scheduler, int throttleMs = 50) + public DynamicReactiveView CreateView(IObservable queryObservable, Func filter, ISequencer scheduler, int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(source); @@ -699,7 +901,7 @@ public DynamicReactiveView CreateView(IObservable queryObserv // Convert query observable to a filter observable var filterObservable = queryObservable.Map>(query => item => filter(query, item)); - return new DynamicReactiveView(source, filterObservable, TimeSpan.FromMilliseconds(throttleMs), scheduler); + return new(source, filterObservable, TimeSpan.FromMilliseconds(throttleMs), scheduler); } /// Creates a reactive view with a dynamic filter that rebuilds when the filter observable emits a new predicate. @@ -707,11 +909,18 @@ public DynamicReactiveView CreateView(IObservable queryObserv /// This is useful for implementing dynamic search functionality where the search criteria can change over time. /// An observable that emits filter predicates. When a new predicate is emitted, the view rebuilds its contents. /// The scheduler used to manage update notifications for the view. + /// A dynamic view that uses the default throttle interval. + public DynamicReactiveView CreateView(IObservable> filterObservable, ISequencer scheduler) => + source.CreateView(filterObservable, scheduler, DefaultThrottleMs); + + /// Creates a reactive view with a dynamic filter. + /// An observable that emits filter predicates. + /// The scheduler used to manage update notifications. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the source and updates reactively /// as the source changes or the filter predicate changes. - public DynamicReactiveView CreateView(IObservable> filterObservable, ISequencer scheduler, int throttleMs = 50) + public DynamicReactiveView CreateView(IObservable> filterObservable, ISequencer scheduler, int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(source); @@ -728,7 +937,7 @@ public DynamicReactiveView CreateView(IObservable> filterObserv } #endif - return new DynamicReactiveView(source, filterObservable, TimeSpan.FromMilliseconds(throttleMs), scheduler); + return new(source, filterObservable, TimeSpan.FromMilliseconds(throttleMs), scheduler); } /// @@ -739,11 +948,16 @@ public DynamicReactiveView CreateView(IObservable> filterObserv /// notifications are throttled to avoid excessive updates when the source changes rapidly. Use this method to /// efficiently bind UI or other observers to dynamic data sources. /// The scheduler used to dispatch change notifications to the reactive view. Cannot be null. - /// The minimum interval, in milliseconds, between consecutive change notifications. Must be non-negative. The - /// default is 50 milliseconds. /// A that reflects the current state of the source and updates when the source changes, subject to the /// specified throttling. - public ReactiveView CreateView(ISequencer scheduler, int throttleMs = 50) + public ReactiveView CreateView(ISequencer scheduler) => + source.CreateView(scheduler, DefaultThrottleMs); + + /// Creates a reactive view over the source with explicit throttling. + /// The scheduler used to dispatch change notifications. + /// The minimum interval, in milliseconds, between change notifications. + /// A reactive view that reflects the source. + public ReactiveView CreateView(ISequencer scheduler, int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(source); @@ -756,7 +970,7 @@ public ReactiveView CreateView(ISequencer scheduler, int throttleMs = 50) // Thread-safe snapshot var snapshot = source.ToArray(); - return new ReactiveView(source.Stream, snapshot, _ => true, TimeSpan.FromMilliseconds(throttleMs), scheduler); + return new(source.Stream, snapshot, static _ => true, TimeSpan.FromMilliseconds(throttleMs), scheduler); } /// @@ -769,11 +983,18 @@ public ReactiveView CreateView(ISequencer scheduler, int throttleMs = 50) /// A function that determines whether an element should be included in the view. Only elements for which this /// function returns are included. /// The scheduler used to manage update notifications for the view. + /// A filtered reactive view that uses the default throttle interval. + public ReactiveView CreateView(Func filter, ISequencer scheduler) => + source.CreateView(filter, scheduler, DefaultThrottleMs); + + /// Creates a filtered reactive view over the source. + /// The predicate that selects included elements. + /// The scheduler used to manage update notifications. /// The minimum time interval, in milliseconds, to wait before propagating updates to the view. Defaults to 50 /// milliseconds. /// A that reflects the filtered contents of the source and updates reactively as /// the source changes. - public ReactiveView CreateView(Func filter, ISequencer scheduler, int throttleMs = 50) + public ReactiveView CreateView(Func filter, ISequencer scheduler, int throttleMs) { #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(source); @@ -786,7 +1007,7 @@ public ReactiveView CreateView(Func filter, ISequencer scheduler, in // Thread-safe snapshot var snapshot = source.ToArray(); - return new ReactiveView(source.Stream, snapshot, filter, TimeSpan.FromMilliseconds(throttleMs), scheduler); + return new(source.Stream, snapshot, filter, TimeSpan.FromMilliseconds(throttleMs), scheduler); } /// Connects to the change stream and converts to ChangeSet format for compatibility with DynamicData-style processing. @@ -797,10 +1018,7 @@ public ReactiveView CreateView(Func filter, ISequencer scheduler, in /// public IObservable> Connect() { - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } + ThrowHelper.ThrowIfNull(source); return Observable.Defer(() => { @@ -850,7 +1068,8 @@ public IObservable> AutoRefresh(Expression> prope } #endif - var member = (property.Body as MemberExpression)?.Member ?? throw new ArgumentException("Expression must be a property", nameof(property)); + _ = (property.Body as MemberExpression)?.Member + ?? throw new ArgumentException("Expression must be a property", nameof(property)); // Return the Stream directly - it already provides all change notifications // The caller can subscribe and handle refresh logic based on item property changes @@ -897,11 +1116,12 @@ public IObservable> AutoRefresh(Expression> prope var item = notification.Batch.Items[i]; if (filter(item)) { - filteredItems[index++] = item; + filteredItems[index] = item; + index++; } } - return new CacheNotify(CacheAction.BatchOperation, default, new PooledBatch(filteredItems, filteredItems.Length, ReturnToPool: false)); + return new(CacheAction.BatchOperation, default, new(filteredItems, filteredItems.Length, ReturnToPool: false)); } /// @@ -942,10 +1162,25 @@ public IObservable> AutoRefresh(Expression> prope var item = notification.Batch.Items[i]; if (matchingItems.Contains(item)) { - filteredItems[index++] = item; + filteredItems[index] = item; + index++; } } - return new CacheNotify(CacheAction.BatchOperation, default, new PooledBatch(filteredItems, filteredItems.Length, ReturnToPool: false)); + return new(CacheAction.BatchOperation, default, new(filteredItems, filteredItems.Length, ReturnToPool: false)); + } + + /// Disposes a grouped subscription and every group it created. + /// The grouping key type. + /// The grouped item type. + /// The source subscription. + /// The groups created by the subscription. + private static void DisposeGroups(IDisposable subscription, List> groups) + { + subscription.Dispose(); + foreach (var group in groups) + { + group.Dispose(); + } } } diff --git a/src/ReactiveList/Views/DynamicFilteredReactiveView.cs b/src/ReactiveList/Views/DynamicFilteredReactiveView.cs index cce2acf..c6dd3e5 100644 --- a/src/ReactiveList/Views/DynamicFilteredReactiveView.cs +++ b/src/ReactiveList/Views/DynamicFilteredReactiveView.cs @@ -12,18 +12,26 @@ namespace CP.Primitives.Views; /// updates when the source list changes or the filter predicate changes. /// /// The type of elements in the view. -public sealed class DynamicFilteredReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T>, IDisposable +public sealed class DynamicFilteredReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T> where T : notnull { - private readonly IReactiveList _source; + /// Serializes changes to the facade's event subscriptions. + private readonly Lock _eventLock = new(); - private readonly ObservableCollection _filteredItems; + /// Contains the mutable state and subscriptions owned by this view. + private readonly State _state; - private readonly MultipleDisposable _disposables = []; + /// Relays collection notifications with this facade as their sender. + private NotificationRelay? _collectionChangedRelay; - private readonly Lock _lock = new(); + /// Relays property notifications with this facade as their sender. + private NotificationRelay? _propertyChangedRelay; - private Func _currentFilter; + /// The collection handlers currently registered with this facade. + private NotifyCollectionChangedEventHandler? _collectionChangedHandlers; + + /// The property handlers currently registered with this facade. + private PropertyChangedEventHandler? _propertyChangedHandlers; /// Initializes a new instance of the class. /// The source reactive list to filter. @@ -36,7 +44,7 @@ public DynamicFilteredReactiveView( ISequencer scheduler, TimeSpan throttle) { - _source = source ?? throw new ArgumentNullException(nameof(source)); + _state = new(source); #if NET8_0_OR_GREATER ThrowHelper.ThrowIfNull(filterObservable); #else @@ -46,67 +54,44 @@ public DynamicFilteredReactiveView( } #endif - _currentFilter = _ => true; - _filteredItems = []; - Items = new(_filteredItems); - - // Initialize with current items (no filter initially) - RebuildView(); - - // Subscribe to filter changes - var filterSubscription = filterObservable - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnFilterChanged); - - _disposables.Add(filterSubscription); - - // Subscribe to source changes using Stream with ToChangeSets() - var sourceSubscription = _source.Stream - .ToChangeSets() - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged); + _state.Start(filterObservable, scheduler, throttle); + } - _disposables.Add(sourceSubscription); + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add => AddCollectionChanged(value); - // Forward collection changed events - _filteredItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); + remove => RemoveCollectionChanged(value); } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add => AddPropertyChanged(value); - /// - public event PropertyChangedEventHandler? PropertyChanged; + remove => RemovePropertyChanged(value); + } /// Gets the number of items in the filtered view. - public int Count => _filteredItems.Count; + public int Count => _state.Count; /// Gets the underlying read-only observable collection for UI binding. - public ReadOnlyObservableCollection Items { get; } + public ReadOnlyObservableCollection Items => _state.Items; /// Gets the item at the specified index. /// The zero-based index of the item to get. /// The item at the specified index. - public T this[int index] => _filteredItems[index]; + public T this[int index] => _state.GetItem(index); /// - public IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + public IEnumerator GetEnumerator() => _state.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// Forces a rebuild of the filtered view from the source using the current filter. - public void Refresh() - { - lock (_lock) - { - RebuildView(); - } - - OnPropertyChanged(nameof(Count)); - } + public void Refresh() => _state.Refresh(); /// Assigns the current collection of items to a property using the specified setter action. /// This method is typically used to bind the internal collection to an external property, such @@ -138,135 +123,363 @@ public DynamicFilteredReactiveView ToProperty(out ReadOnlyObservableCollectio } /// - public void Dispose() + public void Dispose() => _state.Stop(); + + /// Adds a collection handler and hooks the state when it is the first handler. + /// The handler to add. + private void AddCollectionChanged(NotifyCollectionChangedEventHandler? handler) { - _disposables.Dispose(); + if (handler is null) + { + return; + } + + lock (_eventLock) + { + if (_collectionChangedHandlers is not null) + { + _collectionChangedHandlers += handler; + return; + } + + _collectionChangedHandlers = handler; + var relay = new NotificationRelay(this); + _collectionChangedRelay = relay; + _ = relay.Add(DispatchCollectionChanged); + _state.HookCollectionChanged(relay); + } } - /// Handles filter change notifications. - /// The newFilter value. - private void OnFilterChanged(Func newFilter) + /// Removes a collection handler and unhooks the state after the last handler is removed. + /// The handler to remove. + private void RemoveCollectionChanged(NotifyCollectionChangedEventHandler? handler) { - lock (_lock) + if (handler is null) { - _currentFilter = newFilter ?? (_ => true); - RebuildView(); + return; } - OnPropertyChanged(nameof(Count)); + lock (_eventLock) + { + _collectionChangedHandlers -= handler; + if (_collectionChangedHandlers is not null || _collectionChangedRelay is not { } relay) + { + return; + } + + _state.UnhookCollectionChanged(relay); + _ = relay.Remove(DispatchCollectionChanged); + _collectionChangedRelay = null; + } } - /// Handles source change notifications. - /// The changes value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnSourceChanged(ChangeSet changes) + /// Adds a property handler and hooks the state when it is the first handler. + /// The handler to add. + private void AddPropertyChanged(PropertyChangedEventHandler? handler) { - lock (_lock) + if (handler is null) { - for (var i = 0; i < changes.Count; i++) + return; + } + + lock (_eventLock) + { + if (_propertyChangedHandlers is not null) { - var change = changes[i]; - ProcessChange(change); + _propertyChangedHandlers += handler; + return; } + + _propertyChangedHandlers = handler; + var relay = new NotificationRelay(this); + _propertyChangedRelay = relay; + _ = relay.Add(DispatchPropertyChanged); + _state.HookPropertyChanged(relay); + } + } + + /// Removes a property handler and unhooks the state after the last handler is removed. + /// The handler to remove. + private void RemovePropertyChanged(PropertyChangedEventHandler? handler) + { + if (handler is null) + { + return; } - OnPropertyChanged(nameof(Count)); + lock (_eventLock) + { + _propertyChangedHandlers -= handler; + if (_propertyChangedHandlers is not null || _propertyChangedRelay is not { } relay) + { + return; + } + + _state.UnhookPropertyChanged(relay); + _ = relay.Remove(DispatchPropertyChanged); + _propertyChangedRelay = null; + } } - /// Processes a source collection change. - /// The change value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ProcessChange(Change change) + /// Delivers a relayed collection notification to this facade's subscribers. + /// The facade reported as the notification sender. + /// The collection notification data. + private void DispatchCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) { - switch (change.Reason) + NotifyCollectionChangedEventHandler? handlers; + lock (_eventLock) + { + handlers = _collectionChangedHandlers; + } + + handlers?.Invoke(sender, eventArgs); + } + + /// Delivers a relayed property notification to this facade's subscribers. + /// The facade reported as the notification sender. + /// The property notification data. + private void DispatchPropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) + { + PropertyChangedEventHandler? handlers; + lock (_eventLock) + { + handlers = _propertyChangedHandlers; + } + + handlers?.Invoke(sender, eventArgs); + } + + /// Owns the mutable data and subscriptions behind the public facade. + private sealed class State + { + /// The source list whose changes feed this view. + private readonly IReactiveList _source; + + /// The mutable collection backing the public read-only view. + private readonly ObservableCollection _filteredItems; + + /// The subscriptions owned by this view. + private readonly MultipleDisposable _disposables = []; + + /// Serializes filter changes and collection updates. + private readonly Lock _lock = new(); + + /// The predicate currently applied to source items. + private Func _currentFilter = static _ => true; + + /// The property notification subscribers attached to this state. + private PropertyChangedEventHandler? _propertyChanged; + + /// Initializes a new instance of the class. + /// The source reactive list to filter. + public State(IReactiveList source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _filteredItems = []; + Items = new(_filteredItems); + } + + /// Gets the number of items currently in the filtered view. + public int Count => _filteredItems.Count; + + /// Gets the read-only collection exposed by the facade. + public ReadOnlyObservableCollection Items { get; } + + /// Gets the item at the specified index. + /// The zero-based index of the item to get. + /// The item at the specified index. + public T GetItem(int index) => _filteredItems[index]; + + /// Starts dynamic-filter and source subscriptions after this state is fully constructed. + /// An observable that emits filter predicates. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + public void Start( + IObservable> filterObservable, + ISequencer scheduler, + TimeSpan throttle) { - case ChangeReason.Add: + // Initialize with current items (no filter initially). + RebuildView(); + + // Subscribe to filter changes before source changes. + var filterSubscription = filterObservable + .Throttle(throttle) + .ObserveOn(scheduler) + .Subscribe(OnFilterChanged); + _disposables.Add(filterSubscription); + + var sourceSubscription = _source.Stream + .ToChangeSets() + .Throttle(throttle) + .ObserveOn(scheduler) + .Subscribe(OnSourceChanged); + _disposables.Add(sourceSubscription); + } + + /// Adds the collection relay used while the facade has subscribers. + /// The relay to attach. + public void HookCollectionChanged(NotificationRelay relay) => + _filteredItems.CollectionChanged += relay.OnEvent; + + /// Removes the collection relay after the facade loses its final subscriber. + /// The relay to detach. + public void UnhookCollectionChanged(NotificationRelay relay) => + _filteredItems.CollectionChanged -= relay.OnEvent; + + /// Adds the property relay used while the facade has subscribers. + /// The relay to attach. + public void HookPropertyChanged(NotificationRelay relay) => + _propertyChanged += relay.OnEvent; + + /// Removes the property relay after the facade loses its final subscriber. + /// The relay to detach. + public void UnhookPropertyChanged(NotificationRelay relay) => + _propertyChanged -= relay.OnEvent; + + /// Gets an enumerator over the filtered items. + /// An enumerator over the filtered items. + public IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + + /// Rebuilds the filtered view using its current predicate. + public void Refresh() + { + lock (_lock) + { + RebuildView(); + } + + OnPropertyChanged(nameof(Count)); + } + + /// Stops the dynamic-filter and source subscriptions. + public void Stop() => _disposables.Dispose(); + + /// Handles filter change notifications. + /// The new filter value. + private void OnFilterChanged(Func newFilter) + { + lock (_lock) + { + _currentFilter = newFilter ?? (static _ => true); + RebuildView(); + } + + OnPropertyChanged(nameof(Count)); + } + + /// Handles source change notifications. + /// The changes value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnSourceChanged(ChangeSet changes) + { + lock (_lock) + { + for (var i = 0; i < changes.Count; i++) { - if (_currentFilter(change.Current)) + var change = changes[i]; + ProcessChange(change); + } + } + + OnPropertyChanged(nameof(Count)); + } + + /// Processes a source collection change. + /// The change value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ProcessChange(Change change) + { + switch (change.Reason) + { + case ChangeReason.Add: { - _filteredItems.Add(change.Current); + if (_currentFilter(change.Current)) + { + _filteredItems.Add(change.Current); + } + + break; } - break; - } + case ChangeReason.Remove: + { + _ = _filteredItems.Remove(change.Current); + break; + } - case ChangeReason.Remove: - { - _filteredItems.Remove(change.Current); - break; - } + case ChangeReason.Update: + { + UpdateItem(change); + break; + } - case ChangeReason.Update: - { - UpdateItem(change); - break; - } + case ChangeReason.Clear: + { + _filteredItems.Clear(); + break; + } - case ChangeReason.Clear: - { - _filteredItems.Clear(); - break; - } + case ChangeReason.Move or ChangeReason.Refresh: + { + // For move and refresh, rebuild the view to maintain correct order. + RebuildView(); + break; + } - case ChangeReason.Move or ChangeReason.Refresh: - { - // For move and refresh, rebuild the view to maintain correct order - RebuildView(); - break; - } + default: + { + // Ignore invalid enum values to preserve the view's current state. + break; + } + } + } + + /// Updates an item while preserving its filtered position when possible. + /// The update change to apply. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateItem(Change change) + { + var previous = change.Previous; + var existingIndex = previous is null ? -1 : _filteredItems.IndexOf(previous); + var shouldInclude = _currentFilter(change.Current); - default: + if (existingIndex < 0) + { + if (!shouldInclude) { - // Ignore invalid enum values to preserve the view's current state. - break; + return; } - } - } - /// Updates an item while preserving its filtered position when possible. - /// The update change to apply. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateItem(Change change) - { - var previous = change.Previous; - var existingIndex = previous is null ? -1 : _filteredItems.IndexOf(previous); - var shouldInclude = _currentFilter(change.Current); + _filteredItems.Add(change.Current); + return; + } - if (existingIndex < 0) - { if (!shouldInclude) { + _filteredItems.RemoveAt(existingIndex); return; } - _filteredItems.Add(change.Current); - return; - } - - if (!shouldInclude) - { - _filteredItems.RemoveAt(existingIndex); - return; + _filteredItems[existingIndex] = change.Current; } - _filteredItems[existingIndex] = change.Current; - } - - /// Rebuilds the view from the current source state. - private void RebuildView() - { - _filteredItems.Clear(); - foreach (var item in _source) + /// Rebuilds the view from the current source state. + private void RebuildView() { - if (_currentFilter(item)) + _filteredItems.Clear(); + foreach (var item in _source) { - _filteredItems.Add(item); + if (_currentFilter(item)) + { + _filteredItems.Add(item); + } } } - } - /// Handles property change notifications. - /// The propertyName value. - private void OnPropertyChanged(string propertyName) => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + /// Raises a property change notification. + /// The name of the changed property. + private void OnPropertyChanged(string propertyName) => + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } diff --git a/src/ReactiveList/Views/DynamicReactiveView.cs b/src/ReactiveList/Views/DynamicReactiveView.cs index 2e009e1..d3366fb 100644 --- a/src/ReactiveList/Views/DynamicReactiveView.cs +++ b/src/ReactiveList/Views/DynamicReactiveView.cs @@ -22,20 +22,28 @@ namespace CP.Primitives.Views; public class DynamicReactiveView : INotifyPropertyChanged, IReactiveView, T> where T : notnull { + /// The mutable collection backing the public read-only view. private readonly ObservableCollection _target = []; + /// The source whose notifications update this view. private readonly IReactiveSource _source; + /// The subscriptions owned by this view. private readonly MultipleDisposable _disposables = []; + /// The sequencer used to marshal view updates. private readonly ISequencer _scheduler; + /// The interval over which source notifications are batched. private readonly TimeSpan _throttle; + /// The predicate currently applied to source items. private Func _currentFilter = static _ => true; + /// The active subscription to the source notification stream. private IDisposable? _streamSubscription; + /// Indicates whether this view has been disposed. private bool _disposedValue; /// @@ -52,7 +60,7 @@ public class DynamicReactiveView : INotifyPropertyChanged, IReactiveViewThe scheduler on which to observe and process batched notifications, typically used to marshal updates to the /// appropriate thread (such as the UI thread). /// Thrown if source, filterObservable, or scheduler is null. - #if NET8_0_OR_GREATER +#if NET8_0_OR_GREATER public DynamicReactiveView(IReactiveSource source, IObservable> filterObservable, in TimeSpan throttle, ISequencer scheduler) #else public DynamicReactiveView(IReactiveSource source, IObservable> filterObservable, TimeSpan throttle, ISequencer scheduler) @@ -95,7 +103,7 @@ public DynamicReactiveView(IReactiveSource source, IObservable> // Subscribe to subsequent filter changes with scheduler observation var filterChanges = hasInitialFilter ? filterObservable.Skip(1) : filterObservable; - filterChanges + _ = filterChanges .ObserveOn(scheduler) .Subscribe(newFilter => { @@ -195,7 +203,7 @@ private static bool TryGetLatest(IObservable> source, out Func { }); + static _ => { }); value = current; return hasValue; @@ -223,7 +231,7 @@ private void ApplyChange(CacheNotify n) { if (n.Item is not null) { - _target.Remove(n.Item); + _ = _target.Remove(n.Item); } break; @@ -300,7 +308,7 @@ private void RemoveBatch(PooledBatch? batch) for (var i = 0; i < batch.Count; i++) { - _target.Remove(batch.Items[i]); + _ = _target.Remove(batch.Items[i]); } } @@ -337,7 +345,7 @@ private void SubscribeToStream() // Subscribe to stream with current filter _streamSubscription = _source.Stream .Buffer(_throttle) - .Keep(b => b.Count > 0) + .Keep(static b => b.Count > 0) .ObserveOn(_scheduler) .Subscribe(batch => { diff --git a/src/ReactiveList/Views/DynamicSecondaryIndexDictionaryReactiveView.cs b/src/ReactiveList/Views/DynamicSecondaryIndexDictionaryReactiveView.cs index 4de07cd..b7953dc 100644 --- a/src/ReactiveList/Views/DynamicSecondaryIndexDictionaryReactiveView.cs +++ b/src/ReactiveList/Views/DynamicSecondaryIndexDictionaryReactiveView.cs @@ -14,79 +14,55 @@ namespace CP.Primitives.Views; /// /// The type of keys in the dictionary. /// The type of values in the dictionary. -public sealed class DynamicSecondaryIndexDictionaryReactiveView : IReadOnlyList>, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, KeyValuePair>, IDisposable +public sealed class DynamicSecondaryIndexDictionaryReactiveView : + IReadOnlyList>, + INotifyCollectionChanged, + INotifyPropertyChanged, + IReactiveView, KeyValuePair> where TKey : notnull { + /// The indexed dictionary whose changes feed this view. private readonly QuaternaryDictionary _source; + /// The name of the secondary index queried by this view. private readonly string _indexName; + /// Retrieves values for a boxed secondary-index key. private readonly Func, string, object, IEnumerable> _getValuesByIndex; + /// Determines whether a dictionary value matches a boxed secondary-index key. private readonly Func, string, TValue, object, bool> _valueMatchesIndex; + /// The mutable collection backing the public read-only view. private readonly ObservableCollection> _filteredItems; - private readonly MultipleDisposable _disposables = new(); + /// The subscriptions owned by this view. + private readonly MultipleDisposable _disposables = []; + /// Serializes key changes and collection updates. private readonly Lock _lock = new(); + /// The secondary-index keys currently included in the view. private HashSet _currentKeys = []; /// Initializes a new instance of the class. /// The source dictionary to filter. /// The name of the secondary index. - /// An observable of key arrays to filter by. - /// The scheduler for dispatching updates. - /// The throttle duration for updates. /// The delegate used to retrieve values for a boxed secondary index key. /// The delegate used to test whether a value matches a boxed secondary index key. private DynamicSecondaryIndexDictionaryReactiveView( QuaternaryDictionary source, string indexName, - IObservable keysObservable, - ISequencer scheduler, - TimeSpan throttle, Func, string, object, IEnumerable> getValuesByIndex, Func, string, TValue, object, bool> valueMatchesIndex) { _source = source ?? throw new ArgumentNullException(nameof(source)); _indexName = indexName ?? throw new ArgumentNullException(nameof(indexName)); - ThrowHelper.ThrowIfNull(keysObservable); _getValuesByIndex = getValuesByIndex ?? throw new ArgumentNullException(nameof(getValuesByIndex)); _valueMatchesIndex = valueMatchesIndex ?? throw new ArgumentNullException(nameof(valueMatchesIndex)); _filteredItems = []; Items = new(_filteredItems); - - var hasInitialKeys = TryGetLatest(keysObservable, out var initialKeys); - _currentKeys = initialKeys?.ToHashSet() ?? []; - RebuildView(); - - // Subscribe to key changes (skip the first since we already processed it) - var keyChanges = hasInitialKeys ? keysObservable.Skip(1) : keysObservable; - keyChanges - .Subscribe(keys => - { - lock (_lock) - { - _currentKeys = keys is null ? [] : new HashSet(keys); - RebuildView(); - } - - OnPropertyChanged(nameof(Count)); - }) - .DisposeWith(_disposables); - - // Subscribe to source changes - _source.Stream - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged) - .DisposeWith(_disposables); - - // Forward collection changed events - _filteredItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); } /// @@ -132,14 +108,13 @@ public static DynamicSecondaryIndexDictionaryReactiveView Create( + var view = new DynamicSecondaryIndexDictionaryReactiveView( source, indexName, - typedKeys, - scheduler, - throttle, static (dict, name, key) => dict.GetValuesBySecondaryIndex(name, (TIndexKey)key), static (dict, name, value, key) => dict.ValueMatchesSecondaryIndex(name, value, (TIndexKey)key)); + view.Start(typedKeys, scheduler, throttle); + return view; } /// @@ -180,10 +155,7 @@ public DynamicSecondaryIndexDictionaryReactiveView ToProperty(out } /// - public void Dispose() - { - _disposables.Dispose(); - } + public void Dispose() => _disposables.Dispose(); /// Attempts to get the latest value. /// The source value. @@ -204,12 +176,50 @@ private static bool TryGetLatest(IObservable source, out object[]? val current = next; hasValue = true; }, - _ => { }); + static _ => { }); value = current; return hasValue; } + /// Initializes the view contents and activates its subscriptions. + /// An observable of key arrays to filter by. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + private void Start(IObservable keysObservable, ISequencer scheduler, TimeSpan throttle) + { + ThrowHelper.ThrowIfNull(keysObservable); + + var hasInitialKeys = TryGetLatest(keysObservable, out var initialKeys); + _currentKeys = initialKeys?.ToHashSet() ?? []; + RebuildView(); + + // Subscribe to key changes (skip the first since we already processed it) + var keyChanges = hasInitialKeys ? keysObservable.Skip(1) : keysObservable; + _ = keyChanges + .Subscribe(keys => + { + lock (_lock) + { + _currentKeys = keys is null ? [] : new HashSet(keys); + RebuildView(); + } + + OnPropertyChanged(nameof(Count)); + }) + .DisposeWith(_disposables); + + // Subscribe to source changes + _ = _source.Stream + .Throttle(throttle) + .ObserveOn(scheduler) + .Subscribe(OnSourceChanged) + .DisposeWith(_disposables); + + // Forward collection changed events + _filteredItems.CollectionChanged += (_, e) => CollectionChanged?.Invoke(this, e); + } + /// Handles source change notifications. /// The notification value. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveList/Views/DynamicSecondaryIndexReactiveView.cs b/src/ReactiveList/Views/DynamicSecondaryIndexReactiveView.cs index d1baa15..846a174 100644 --- a/src/ReactiveList/Views/DynamicSecondaryIndexReactiveView.cs +++ b/src/ReactiveList/Views/DynamicSecondaryIndexReactiveView.cs @@ -13,21 +13,31 @@ namespace CP.Primitives.Views; /// /// The type of items in the list. /// The type of the secondary index key. -public sealed class DynamicSecondaryIndexReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T>, IDisposable +public sealed class DynamicSecondaryIndexReactiveView : + IReadOnlyList, + INotifyCollectionChanged, + INotifyPropertyChanged, + IReactiveView, T> where T : notnull where TKey : notnull { - private readonly QuaternaryList _source; + /// Serializes changes to the facade's event subscriptions. + private readonly Lock _eventLock = new(); - private readonly string _indexName; + /// Contains the mutable state and subscriptions owned by this view. + private readonly State _state; - private readonly ObservableCollection _filteredItems; + /// Relays collection notifications with this facade as their sender. + private NotificationRelay? _collectionChangedRelay; - private readonly MultipleDisposable _disposables = []; + /// Relays property notifications with this facade as their sender. + private NotificationRelay? _propertyChangedRelay; - private readonly Lock _lock = new(); + /// The collection handlers currently registered with this facade. + private NotifyCollectionChangedEventHandler? _collectionChangedHandlers; - private HashSet _currentKeys = []; + /// The property handlers currently registered with this facade. + private PropertyChangedEventHandler? _propertyChangedHandlers; /// Initializes a new instance of the class. /// The source list to filter. @@ -42,74 +52,46 @@ public DynamicSecondaryIndexReactiveView( ISequencer scheduler, TimeSpan throttle) { - _source = source ?? throw new ArgumentNullException(nameof(source)); - _indexName = indexName ?? throw new ArgumentNullException(nameof(indexName)); + _state = new(source, indexName); ThrowHelper.ThrowIfNull(keysObservable); + _state.Start(keysObservable, scheduler, throttle); + } - _filteredItems = []; - Items = new(_filteredItems); - - var hasInitialKeys = TryGetLatest(keysObservable, out var initialKeys); - _currentKeys = initialKeys?.ToHashSet() ?? []; - RebuildView(); - - // Subscribe to key changes (skip the first since we already processed it) - var keyChanges = hasInitialKeys ? keysObservable.Skip(1) : keysObservable; - keyChanges - .Subscribe(keys => - { - lock (_lock) - { - _currentKeys = keys?.ToHashSet() ?? []; - RebuildView(); - } - - OnPropertyChanged(nameof(Count)); - }) - .DisposeWith(_disposables); - - // Subscribe to source changes - _source.Stream - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged) - .DisposeWith(_disposables); + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add => AddCollectionChanged(value); - // Forward collection changed events - _filteredItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); + remove => RemoveCollectionChanged(value); } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add => AddPropertyChanged(value); - /// - public event PropertyChangedEventHandler? PropertyChanged; + remove => RemovePropertyChanged(value); + } /// Gets the number of items in the filtered view. - public int Count => _filteredItems.Count; + public int Count => _state.Count; /// Gets the underlying read-only observable collection for UI binding. - public ReadOnlyObservableCollection Items { get; } + public ReadOnlyObservableCollection Items => _state.Items; /// Gets the item at the specified index. /// The zero-based index of the item to get. /// The item at the specified index. - public T this[int index] => _filteredItems[index]; + public T this[int index] => _state.GetItem(index); /// - public IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + public IEnumerator GetEnumerator() => _state.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// Forces a rebuild of the filtered view from the source. - public void Refresh() - { - lock (_lock) - { - RebuildView(); - } - } + public void Refresh() => _state.Refresh(); /// Assigns the current collection of items to a property using the specified setter action. /// This method is typically used to bind the internal collection to an external property, such @@ -134,173 +116,404 @@ public DynamicSecondaryIndexReactiveView ToProperty(out ReadOnlyObserva } /// - public void Dispose() + public void Dispose() => _state.Stop(); + + /// Adds a collection handler and hooks the state when it is the first handler. + /// The handler to add. + private void AddCollectionChanged(NotifyCollectionChangedEventHandler? handler) { - _disposables.Dispose(); + if (handler is null) + { + return; + } + + lock (_eventLock) + { + if (_collectionChangedHandlers is not null) + { + _collectionChangedHandlers += handler; + return; + } + + _collectionChangedHandlers = handler; + var relay = new NotificationRelay(this); + _collectionChangedRelay = relay; + _ = relay.Add(DispatchCollectionChanged); + _state.HookCollectionChanged(relay); + } } - /// Attempts to get the latest value. - /// The source value. - /// The latest value. - /// when a value was read; otherwise, . - private static bool TryGetLatest(IObservable source, out TKey[]? value) + /// Removes a collection handler and unhooks the state after the last handler is removed. + /// The handler to remove. + private void RemoveCollectionChanged(NotifyCollectionChangedEventHandler? handler) { - var hasValue = false; - TKey[]? current = null; - using var subscription = source.Subscribe( - next => + if (handler is null) + { + return; + } + + lock (_eventLock) + { + _collectionChangedHandlers -= handler; + if (_collectionChangedHandlers is not null || _collectionChangedRelay is not { } relay) { - if (hasValue) - { - return; - } + return; + } - current = next; - hasValue = true; - }, - _ => { }); + _state.UnhookCollectionChanged(relay); + _ = relay.Remove(DispatchCollectionChanged); + _collectionChangedRelay = null; + } + } - value = current; - return hasValue; + /// Adds a property handler and hooks the state when it is the first handler. + /// The handler to add. + private void AddPropertyChanged(PropertyChangedEventHandler? handler) + { + if (handler is null) + { + return; + } + + lock (_eventLock) + { + if (_propertyChangedHandlers is not null) + { + _propertyChangedHandlers += handler; + return; + } + + _propertyChangedHandlers = handler; + var relay = new NotificationRelay(this); + _propertyChangedRelay = relay; + _ = relay.Add(DispatchPropertyChanged); + _state.HookPropertyChanged(relay); + } } - /// Handles source change notifications. - /// The notification value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnSourceChanged(CacheNotify notification) + /// Removes a property handler and unhooks the state after the last handler is removed. + /// The handler to remove. + private void RemovePropertyChanged(PropertyChangedEventHandler? handler) { - lock (_lock) + if (handler is null) { - switch (notification.Action) + return; + } + + lock (_eventLock) + { + _propertyChangedHandlers -= handler; + if (_propertyChangedHandlers is not null || _propertyChangedRelay is not { } relay) { - case CacheAction.Added: - { - if (notification.Item is not null && ItemMatchesCurrentKeys(notification.Item)) - { - _filteredItems.Add(notification.Item); - } + return; + } - break; - } + _state.UnhookPropertyChanged(relay); + _ = relay.Remove(DispatchPropertyChanged); + _propertyChangedRelay = null; + } + } - case CacheAction.Removed: - { - if (notification.Item is not null) - { - _filteredItems.Remove(notification.Item); - } + /// Delivers a relayed collection notification to this facade's subscribers. + /// The facade reported as the notification sender. + /// The collection notification data. + private void DispatchCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) + { + NotifyCollectionChangedEventHandler? handlers; + lock (_eventLock) + { + handlers = _collectionChangedHandlers; + } - break; - } + handlers?.Invoke(sender, eventArgs); + } - case CacheAction.Updated: - { - UpdateItem(notification); - break; - } + /// Delivers a relayed property notification to this facade's subscribers. + /// The facade reported as the notification sender. + /// The property notification data. + private void DispatchPropertyChanged(object? sender, PropertyChangedEventArgs eventArgs) + { + PropertyChangedEventHandler? handlers; + lock (_eventLock) + { + handlers = _propertyChangedHandlers; + } - case CacheAction.Cleared: - { - _filteredItems.Clear(); - break; - } + handlers?.Invoke(sender, eventArgs); + } - case CacheAction.Moved or - CacheAction.Refreshed or - CacheAction.BatchOperation or - CacheAction.BatchAdded or - CacheAction.BatchRemoved: - { - RebuildView(); - break; - } + /// Owns the mutable data and subscriptions behind the public facade. + private sealed class State + { + /// The indexed list whose changes feed this view. + private readonly QuaternaryList _source; - default: + /// The name of the secondary index queried by this view. + private readonly string _indexName; + + /// The mutable collection backing the public read-only view. + private readonly ObservableCollection _filteredItems; + + /// The subscriptions owned by this view. + private readonly MultipleDisposable _disposables = []; + + /// Serializes key changes and collection updates. + private readonly Lock _lock = new(); + + /// The secondary-index keys currently included in the view. + private HashSet _currentKeys = []; + + /// The property notification subscribers attached to this state. + private PropertyChangedEventHandler? _propertyChanged; + + /// Initializes a new instance of the class. + /// The source list to filter. + /// The name of the secondary index. + public State(QuaternaryList source, string indexName) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _indexName = indexName ?? throw new ArgumentNullException(nameof(indexName)); + _filteredItems = []; + Items = new(_filteredItems); + } + + /// Gets the number of items currently in the filtered view. + public int Count => _filteredItems.Count; + + /// Gets the read-only collection exposed by the facade. + public ReadOnlyObservableCollection Items { get; } + + /// Gets the item at the specified index. + /// The zero-based index of the item to get. + /// The item at the specified index. + public T GetItem(int index) => _filteredItems[index]; + + /// Starts key and source subscriptions after this state is fully constructed. + /// An observable of key arrays to filter by. + /// The scheduler for dispatching source updates. + /// The throttle duration for source updates. + public void Start( + IObservable keysObservable, + ISequencer scheduler, + TimeSpan throttle) + { + var hasInitialKeys = false; + TKey[]? initialKeys = null; + var initialSubscription = keysObservable.Subscribe( + next => + { + if (hasInitialKeys) { - // Ignore invalid enum values to preserve the view's current state. - break; + return; } - } + + initialKeys = next; + hasInitialKeys = true; + }, + static _ => { }); + initialSubscription.Dispose(); + + _currentKeys = initialKeys?.ToHashSet() ?? []; + RebuildView(); + + // Subscribe to key changes before source changes, skipping the value already read. + var keyChanges = hasInitialKeys ? keysObservable.Skip(1) : keysObservable; + _ = keyChanges + .Subscribe(OnKeysChanged) + .DisposeWith(_disposables); + + _ = _source.Stream + .Throttle(throttle) + .ObserveOn(scheduler) + .Subscribe(OnSourceChanged) + .DisposeWith(_disposables); } - OnPropertyChanged(nameof(Count)); - } + /// Adds the collection relay used while the facade has subscribers. + /// The relay to attach. + public void HookCollectionChanged(NotificationRelay relay) => + _filteredItems.CollectionChanged += relay.OnEvent; - /// Updates an item and its membership in the current secondary-index view. - /// The update notification to apply. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateItem(CacheNotify notification) - { - var current = notification.Item; - if (current is null) + /// Removes the collection relay after the facade loses its final subscriber. + /// The relay to detach. + public void UnhookCollectionChanged(NotificationRelay relay) => + _filteredItems.CollectionChanged -= relay.OnEvent; + + /// Adds the property relay used while the facade has subscribers. + /// The relay to attach. + public void HookPropertyChanged(NotificationRelay relay) => + _propertyChanged += relay.OnEvent; + + /// Removes the property relay after the facade loses its final subscriber. + /// The relay to detach. + public void UnhookPropertyChanged(NotificationRelay relay) => + _propertyChanged -= relay.OnEvent; + + /// Gets an enumerator over the filtered items. + /// An enumerator over the filtered items. + public IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + + /// Rebuilds the view from the current source state. + public void Refresh() { - return; + lock (_lock) + { + RebuildView(); + } } - var previous = notification.Previous; - var existingIndex = previous is null - ? _filteredItems.IndexOf(current) - : _filteredItems.IndexOf(previous); - var shouldBeInView = ItemMatchesCurrentKeys(current); + /// Stops the key and source subscriptions. + public void Stop() => _disposables.Dispose(); - if (existingIndex < 0) + /// Handles a dynamic key change. + /// The keys now included in the view. + private void OnKeysChanged(TKey[] keys) { - if (!shouldBeInView) + lock (_lock) { - return; + _currentKeys = keys?.ToHashSet() ?? []; + RebuildView(); } - _filteredItems.Add(current); - return; + OnPropertyChanged(nameof(Count)); } - if (!shouldBeInView) + /// Handles source change notifications. + /// The notification value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnSourceChanged(CacheNotify notification) { - _filteredItems.RemoveAt(existingIndex); - return; + lock (_lock) + { + switch (notification.Action) + { + case CacheAction.Added: + { + if (notification.Item is not null && ItemMatchesCurrentKeys(notification.Item)) + { + _filteredItems.Add(notification.Item); + } + + break; + } + + case CacheAction.Removed: + { + if (notification.Item is not null) + { + _ = _filteredItems.Remove(notification.Item); + } + + break; + } + + case CacheAction.Updated: + { + UpdateItem(notification); + break; + } + + case CacheAction.Cleared: + { + _filteredItems.Clear(); + break; + } + + case CacheAction.Moved or + CacheAction.Refreshed or + CacheAction.BatchOperation or + CacheAction.BatchAdded or + CacheAction.BatchRemoved: + { + RebuildView(); + break; + } + + default: + { + // Ignore invalid enum values to preserve the view's current state. + break; + } + } + } + + OnPropertyChanged(nameof(Count)); } - _filteredItems[existingIndex] = current; - } + /// Updates an item and its membership in the current secondary-index view. + /// The update notification to apply. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateItem(CacheNotify notification) + { + var current = notification.Item; + if (current is null) + { + return; + } - /// Rebuilds the view from the current source state. - private void RebuildView() - { - _filteredItems.Clear(); + var previous = notification.Previous; + var existingIndex = previous is null + ? _filteredItems.IndexOf(current) + : _filteredItems.IndexOf(previous); + var shouldBeInView = ItemMatchesCurrentKeys(current); - // Get items from secondary index for all current keys - // Explicitly specify TKey to ensure type inference is correct - foreach (var key in _currentKeys) + if (existingIndex < 0) + { + if (!shouldBeInView) + { + return; + } + + _filteredItems.Add(current); + return; + } + + if (!shouldBeInView) + { + _filteredItems.RemoveAt(existingIndex); + return; + } + + _filteredItems[existingIndex] = current; + } + + /// Rebuilds the view from the current source state. + private void RebuildView() { - foreach (var item in _source.GetItemsBySecondaryIndex(_indexName, key)) + _filteredItems.Clear(); + + foreach (var key in _currentKeys) { - // Avoid duplicates if same item matches multiple keys - if (!_filteredItems.Contains(item)) + foreach (var item in _source.GetItemsBySecondaryIndex(_indexName, key)) { - _filteredItems.Add(item); + // Avoid duplicates if the same item matches multiple keys. + if (!_filteredItems.Contains(item)) + { + _filteredItems.Add(item); + } } } } - } - /// Performs the ItemMatchesCurrentKeys operation. - /// The item value. - /// when the item matches any current key; otherwise, . - private bool ItemMatchesCurrentKeys(T item) - { - foreach (var key in _currentKeys) + /// Determines whether an item matches any current secondary-index key. + /// The item to test. + /// when the item matches a current key; otherwise, . + private bool ItemMatchesCurrentKeys(T item) { - if (_source.ItemMatchesSecondaryIndex(_indexName, item, key)) + foreach (var key in _currentKeys) { - return true; + if (_source.ItemMatchesSecondaryIndex(_indexName, item, key)) + { + return true; + } } + + return false; } - return false; + /// Raises a property change notification. + /// The name of the changed property. + private void OnPropertyChanged(string propertyName) => + _propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - - /// Handles property change notifications. - /// The propertyName value. - private void OnPropertyChanged(string propertyName) => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } diff --git a/src/ReactiveList/Views/FilteredReactiveView.cs b/src/ReactiveList/Views/FilteredReactiveView.cs index 1b5e9d0..6280c37 100644 --- a/src/ReactiveList/Views/FilteredReactiveView.cs +++ b/src/ReactiveList/Views/FilteredReactiveView.cs @@ -3,24 +3,33 @@ // See the LICENSE file in the project root for full license information. #if REACTIVELIST_REACTIVE +using CP.Reactive.Internal; + namespace CP.Reactive.Views; #else +using CP.Primitives.Internal; + namespace CP.Primitives.Views; #endif /// Provides a filtered, read-only view over a that automatically updates when the source list changes. /// The type of elements in the view. -public sealed class FilteredReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T>, IDisposable +public sealed class FilteredReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T> where T : notnull { - private readonly IReactiveList _source; + /// Synchronizes collection-changed subscriptions to this facade. + private readonly Lock _collectionChangedGate = new(); - private readonly Func _filter; + /// Synchronizes property-changed subscriptions to this facade. + private readonly Lock _propertyChangedGate = new(); - private readonly ObservableCollection _filteredItems; + /// The completed state object that owns filtering and source subscriptions. + private readonly State _state; - private readonly MultipleDisposable _disposables = []; + /// Relays collection notifications with this facade as the sender. + private NotificationRelay? _collectionChangedRelay; - private readonly Lock _lock = new(); + /// Relays property notifications with this facade as the sender. + private NotificationRelay? _propertyChangedRelay; /// Initializes a new instance of the class. /// The source reactive list to filter. @@ -33,59 +42,103 @@ public FilteredReactiveView( ISequencer scheduler, TimeSpan throttle) { - _source = source ?? throw new ArgumentNullException(nameof(source)); - _filter = filter ?? throw new ArgumentNullException(nameof(filter)); - - _filteredItems = []; - Items = new(_filteredItems); + _state = new(source, filter, scheduler, throttle); + _state.Start(); + } - // Initialize with current items - RebuildView(); + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + if (value is null) + { + return; + } - // Subscribe to changes using Stream with ToChangeSets() - var subscription = _source.Stream - .ToChangeSets() - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged); + lock (_collectionChangedGate) + { + _collectionChangedRelay ??= new(this); + if (_collectionChangedRelay.Add(value.Invoke)) + { + _state.CollectionChanged += _collectionChangedRelay.OnEvent; + } + } + } - _disposables.Add(subscription); + remove + { + if (value is null) + { + return; + } - // Forward collection changed events - _filteredItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); + lock (_collectionChangedGate) + { + if (_collectionChangedRelay?.Remove(value.Invoke) is true) + { + _state.CollectionChanged -= _collectionChangedRelay.OnEvent; + } + } + } } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + if (value is null) + { + return; + } - /// - public event PropertyChangedEventHandler? PropertyChanged; + lock (_propertyChangedGate) + { + _propertyChangedRelay ??= new(this); + if (_propertyChangedRelay.Add(value.Invoke)) + { + _state.PropertyChanged += _propertyChangedRelay.OnEvent; + } + } + } + + remove + { + if (value is null) + { + return; + } + + lock (_propertyChangedGate) + { + if (_propertyChangedRelay?.Remove(value.Invoke) is true) + { + _state.PropertyChanged -= _propertyChangedRelay.OnEvent; + } + } + } + } /// Gets the number of items in the filtered view. - public int Count => _filteredItems.Count; + public int Count => _state.Count; /// Gets the underlying read-only observable collection for UI binding. - public ReadOnlyObservableCollection Items { get; } + public ReadOnlyObservableCollection Items => _state.Items; /// Gets the item at the specified index. /// The zero-based index of the item to get. /// The item at the specified index. - public T this[int index] => _filteredItems[index]; + public T this[int index] => _state.GetItem(index); /// - public IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + public IEnumerator GetEnumerator() => _state.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// Forces a rebuild of the filtered view from the source. - public void Refresh() - { - lock (_lock) - { - RebuildView(); - } - } + public void Refresh() => _state.Refresh(); /// Assigns the current collection of items to a property using the specified setter action. /// This method is typically used to bind the internal collection to an external property, such @@ -117,122 +170,207 @@ public FilteredReactiveView ToProperty(out ReadOnlyObservableCollection co } /// - public void Dispose() - { - _disposables.Dispose(); - } + public void Dispose() => _state.Dispose(); - /// Handles source change notifications. - /// The changes value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnSourceChanged(ChangeSet changes) + /// Owns the mutable filtered view after construction has completed. + private sealed class State { - lock (_lock) + /// The reactive list that supplies items to this view. + private readonly IReactiveList _source; + + /// The predicate that determines whether an item is included. + private readonly Func _filter; + + /// The mutable collection that backs the read-only filtered items collection. + private readonly ObservableCollection _filteredItems = []; + + /// The scheduler used to dispatch source notifications. + private readonly ISequencer _scheduler; + + /// The throttle applied to source notifications. + private readonly TimeSpan _throttle; + + /// The subscriptions owned by this state. + private readonly MultipleDisposable _disposables = []; + + /// Synchronizes access to the filtered items collection. + private readonly Lock _lock = new(); + + /// Initializes a new instance of the class without publishing callbacks. + /// The source reactive list to filter. + /// The filter predicate. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + internal State( + IReactiveList source, + Func filter, + ISequencer scheduler, + TimeSpan throttle) { - for (var i = 0; i < changes.Count; i++) + _source = source ?? throw new ArgumentNullException(nameof(source)); + _filter = filter ?? throw new ArgumentNullException(nameof(filter)); + _scheduler = scheduler; + _throttle = throttle; + Items = new(_filteredItems); + RebuildView(); + } + + /// Raised when the filtered collection changes. + internal event EventHandler? CollectionChanged; + + /// Raised when a state property changes. + internal event EventHandler? PropertyChanged; + + /// Gets the number of filtered items. + internal int Count => _filteredItems.Count; + + /// Gets the read-only observable filtered items. + internal ReadOnlyObservableCollection Items { get; } + + /// Gets the item at the specified index. + /// The zero-based item index. + /// The filtered item. + internal T GetItem(int index) => _filteredItems[index]; + + /// Starts collection forwarding and source observation after state construction. + internal void Start() + { + _filteredItems.CollectionChanged += OnCollectionChanged; + var subscription = _source.Stream + .ToChangeSets() + .Throttle(_throttle) + .ObserveOn(_scheduler) + .Subscribe(OnSourceChanged); + + _disposables.Add(subscription); + } + + /// Returns an enumerator over the filtered items. + /// An enumerator over the filtered items. + internal IEnumerator GetEnumerator() => _filteredItems.GetEnumerator(); + + /// Rebuilds the view from the current source state. + internal void Refresh() + { + lock (_lock) { - var change = changes[i]; - ProcessChange(change); + RebuildView(); } } - OnPropertyChanged(nameof(Count)); - } + /// Disposes the source subscription. + internal void Dispose() => _disposables.Dispose(); - /// Processes a source collection change. - /// The change value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ProcessChange(Change change) - { - switch (change.Reason) + /// Handles source change notifications. + /// The changes value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnSourceChanged(ChangeSet changes) { - case ChangeReason.Add: + lock (_lock) + { + for (var i = 0; i < changes.Count; i++) { - if (_filter(change.Current)) + ProcessChange(changes[i]); + } + } + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); + } + + /// Processes a source collection change. + /// The change value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ProcessChange(Change change) + { + switch (change.Reason) + { + case ChangeReason.Add: { - _filteredItems.Add(change.Current); + if (_filter(change.Current)) + { + _filteredItems.Add(change.Current); + } + + break; } - break; - } + case ChangeReason.Remove: + { + _ = _filteredItems.Remove(change.Current); + break; + } - case ChangeReason.Remove: - { - _filteredItems.Remove(change.Current); - break; - } + case ChangeReason.Update: + { + UpdateItem(change); + break; + } - case ChangeReason.Update: - { - UpdateItem(change); - break; - } + case ChangeReason.Clear: + { + _filteredItems.Clear(); + break; + } - case ChangeReason.Clear: - { - _filteredItems.Clear(); - break; - } + case ChangeReason.Move or ChangeReason.Refresh: + { + RebuildView(); + break; + } - case ChangeReason.Move or ChangeReason.Refresh: - { - // For move and refresh, rebuild the view to maintain correct order - RebuildView(); - break; - } + default: + { + break; + } + } + } - default: + /// Updates an item while preserving its filtered position when possible. + /// The update change to apply. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateItem(Change change) + { + var previous = change.Previous; + var existingIndex = previous is null ? -1 : _filteredItems.IndexOf(previous); + var shouldInclude = _filter(change.Current); + + if (existingIndex < 0) + { + if (!shouldInclude) { - // Ignore invalid enum values to preserve the view's current state. - break; + return; } - } - } - /// Updates an item while preserving its filtered position when possible. - /// The update change to apply. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateItem(Change change) - { - var previous = change.Previous; - var existingIndex = previous is null ? -1 : _filteredItems.IndexOf(previous); - var shouldInclude = _filter(change.Current); + _filteredItems.Add(change.Current); + return; + } - if (existingIndex < 0) - { if (!shouldInclude) { + _filteredItems.RemoveAt(existingIndex); return; } - _filteredItems.Add(change.Current); - return; + _filteredItems[existingIndex] = change.Current; } - if (!shouldInclude) + /// Rebuilds the view from the current source state. + private void RebuildView() { - _filteredItems.RemoveAt(existingIndex); - return; - } - - _filteredItems[existingIndex] = change.Current; - } - - /// Rebuilds the view from the current source state. - private void RebuildView() - { - _filteredItems.Clear(); - foreach (var item in _source) - { - if (_filter(item)) + _filteredItems.Clear(); + foreach (var item in _source) { - _filteredItems.Add(item); + if (_filter(item)) + { + _filteredItems.Add(item); + } } } - } - /// Handles property change notifications. - /// The propertyName value. - private void OnPropertyChanged(string propertyName) => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + /// Forwards collection changes from the mutable collection. + /// The originating collection. + /// The collection change event data. + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) => + CollectionChanged?.Invoke(sender, eventArgs); + } } diff --git a/src/ReactiveList/Views/GroupedReactiveView.cs b/src/ReactiveList/Views/GroupedReactiveView.cs index 5d7032b..5a6a226 100644 --- a/src/ReactiveList/Views/GroupedReactiveView.cs +++ b/src/ReactiveList/Views/GroupedReactiveView.cs @@ -3,28 +3,39 @@ // See the LICENSE file in the project root for full license information. #if REACTIVELIST_REACTIVE +using CP.Reactive.Internal; + namespace CP.Reactive.Views; #else +using CP.Primitives.Internal; + namespace CP.Primitives.Views; #endif /// Provides a grouped view over a that automatically updates when the source list changes. /// The type of elements in the view. /// The type of the grouping key. -public sealed class GroupedReactiveView : IReadOnlyDictionary>, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, ReactiveGroup>, IDisposable +public sealed class GroupedReactiveView : + IReadOnlyDictionary>, + INotifyCollectionChanged, + INotifyPropertyChanged, + IReactiveView, ReactiveGroup> where T : notnull where TKey : notnull { - private readonly IReactiveList _source; - - private readonly Func _keySelector; + /// Synchronizes collection-changed subscriptions to this facade. + private readonly Lock _collectionChangedGate = new(); - private readonly Dictionary> _groups = []; + /// Synchronizes property-changed subscriptions to this facade. + private readonly Lock _propertyChangedGate = new(); - private readonly ObservableCollection> _groupCollection = []; + /// The completed state object that owns grouping and source subscriptions. + private readonly State _state; - private readonly MultipleDisposable _disposables = []; + /// Relays collection notifications with this facade as the sender. + private NotificationRelay? _collectionChangedRelay; - private readonly Lock _lock = new(); + /// Relays property notifications with this facade as the sender. + private NotificationRelay? _propertyChangedRelay; /// Initializes a new instance of the class. /// The source reactive list to group. @@ -37,90 +48,124 @@ public GroupedReactiveView( ISequencer scheduler, TimeSpan throttle) { - _source = source ?? throw new ArgumentNullException(nameof(source)); - _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); - - Groups = new(_groupCollection); + _state = new(source, keySelector, scheduler, throttle); + _state.Start(); + } - // Initialize with current items - RebuildView(); + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + if (value is null) + { + return; + } - // Subscribe to changes using Stream with ToChangeSets() - var subscription = _source.Stream - .ToChangeSets() - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged); + lock (_collectionChangedGate) + { + _collectionChangedRelay ??= new(this); + if (_collectionChangedRelay.Add(value.Invoke)) + { + _state.CollectionChanged += _collectionChangedRelay.OnEvent; + } + } + } - _disposables.Add(subscription); + remove + { + if (value is null) + { + return; + } - // Forward collection changed events - _groupCollection.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); + lock (_collectionChangedGate) + { + if (_collectionChangedRelay?.Remove(value.Invoke) is true) + { + _state.CollectionChanged -= _collectionChangedRelay.OnEvent; + } + } + } } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + if (value is null) + { + return; + } - /// - public event PropertyChangedEventHandler? PropertyChanged; + lock (_propertyChangedGate) + { + _propertyChangedRelay ??= new(this); + if (_propertyChangedRelay.Add(value.Invoke)) + { + _state.PropertyChanged += _propertyChangedRelay.OnEvent; + } + } + } + + remove + { + if (value is null) + { + return; + } + + lock (_propertyChangedGate) + { + if (_propertyChangedRelay?.Remove(value.Invoke) is true) + { + _state.PropertyChanged -= _propertyChangedRelay.OnEvent; + } + } + } + } /// Gets the number of groups. - public int Count => _groups.Count; + public int Count => _state.Count; /// Gets the collection of groups for UI binding. - public ReadOnlyObservableCollection> Groups { get; } + public ReadOnlyObservableCollection> Groups => _state.Groups; /// Gets the collection of groups for UI binding. This is an alias for . /// This property exists to satisfy the interface. public ReadOnlyObservableCollection> Items => Groups; /// Gets the keys of all groups. - public IEnumerable Keys => _groups.Keys; + public IEnumerable Keys => _state.Keys; /// Gets the values (item lists) of all groups. - public IEnumerable> Values => _groups.Values.Cast>(); + public IEnumerable> Values => _state.Values; /// Gets the items in the specified group. /// The group key. /// The items in the group. - public IReadOnlyList this[TKey key] => _groups[key]; + public IReadOnlyList this[TKey key] => _state.GetItems(key); /// Determines whether the view contains a group with the specified key. /// The key to locate. /// true if the view contains a group with the key; otherwise, false. - public bool ContainsKey(TKey key) => _groups.ContainsKey(key); + public bool ContainsKey(TKey key) => _state.ContainsKey(key); /// Gets the items in the specified group, if it exists. /// The group key. /// When this method returns, contains the items, if the key is found; otherwise, null. /// true if the view contains a group with the specified key; otherwise, false. - public bool TryGetValue(TKey key, out IReadOnlyList value) - { - if (_groups.TryGetValue(key, out var list)) - { - value = list; - return true; - } - - value = []; - return false; - } + public bool TryGetValue(TKey key, out IReadOnlyList value) => _state.TryGetValue(key, out value); /// - public IEnumerator>> GetEnumerator() => - _groups.Select(kvp => new KeyValuePair>(kvp.Key, kvp.Value)).GetEnumerator(); + public IEnumerator>> GetEnumerator() => _state.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// Forces a rebuild of the grouped view from the source. - public void Refresh() - { - lock (_lock) - { - RebuildView(); - } - } + public void Refresh() => _state.Refresh(); /// Assigns the current collection of groups to a property using the specified setter action. /// This method is typically used to bind the internal collection to an external property, such @@ -152,178 +197,313 @@ public GroupedReactiveView ToProperty(out ReadOnlyObservableCollection< } /// - public void Dispose() - { - _disposables.Dispose(); - } + public void Dispose() => _state.Dispose(); - /// Handles source change notifications. - /// The changes value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnSourceChanged(ChangeSet changes) + /// Owns the mutable grouped view after construction has completed. + private sealed class State { - lock (_lock) - { - for (var i = 0; i < changes.Count; i++) - { - var change = changes[i]; - ProcessChange(change); - } - } + /// The list whose changes feed this grouped view. + private readonly IReactiveList _source; - OnPropertyChanged(nameof(Count)); - } + /// Extracts the grouping key for each source item. + private readonly Func _keySelector; - /// Processes a source collection change. - /// The change value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ProcessChange(Change change) - { - switch (change.Reason) - { - case ChangeReason.Add: - { - AddToGroup(change.Current); - break; - } + /// The mutable item collection for each group key. + private readonly Dictionary> _groups = []; - case ChangeReason.Remove: - { - RemoveFromGroup(change.Current); - break; - } + /// The observable collection backing the public group view. + private readonly ObservableCollection> _groupCollection = []; - case ChangeReason.Update: - { - UpdateGroup(change); - break; - } + /// The scheduler used to dispatch source notifications. + private readonly ISequencer _scheduler; - case ChangeReason.Clear: - { - _groups.Clear(); - _groupCollection.Clear(); - break; - } + /// The throttle applied to source notifications. + private readonly TimeSpan _throttle; - case ChangeReason.Move or ChangeReason.Refresh: - { - // Rebuild for move/refresh - RebuildView(); - break; - } + /// The subscriptions owned by this state. + private readonly MultipleDisposable _disposables = []; - default: - { - // Ignore invalid enum values to preserve the view's current state. - break; - } - } - } + /// Serializes source notifications and group updates. + private readonly Lock _lock = new(); - /// Updates an item in its existing group or moves it to its new group. - /// The update change to apply. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateGroup(Change change) - { - var previous = change.Previous; - if (previous is null) + /// Initializes a new instance of the class without publishing callbacks. + /// The source reactive list to group. + /// A function to extract the grouping key. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + internal State( + IReactiveList source, + Func keySelector, + ISequencer scheduler, + TimeSpan throttle) { - AddToGroup(change.Current); - return; + _source = source ?? throw new ArgumentNullException(nameof(source)); + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + _scheduler = scheduler; + _throttle = throttle; + Groups = new(_groupCollection); + RebuildView(); } - var oldKey = _keySelector(previous); - var newKey = _keySelector(change.Current); - if (!EqualityComparer.Default.Equals(oldKey, newKey)) + /// Raised when the group collection changes. + internal event EventHandler? CollectionChanged; + + /// Raised when a state property changes. + internal event EventHandler? PropertyChanged; + + /// Gets the number of groups. + internal int Count => _groups.Count; + + /// Gets the read-only observable collection of groups. + internal ReadOnlyObservableCollection> Groups { get; } + + /// Gets all group keys. + internal IEnumerable Keys => _groups.Keys; + + /// Gets all group item collections. + internal IEnumerable> Values => EnumerateValues(); + + /// Gets the items in the specified group. + /// The group key. + /// The items in the group. + internal ObservableCollection GetItems(TKey key) => _groups[key]; + + /// Starts collection forwarding and source observation after state construction. + internal void Start() { - RemoveFromGroup(previous); - AddToGroup(change.Current); - return; + _groupCollection.CollectionChanged += OnCollectionChanged; + var subscription = _source.Stream + .ToChangeSets() + .Throttle(_throttle) + .ObserveOn(_scheduler) + .Subscribe(OnSourceChanged); + + _disposables.Add(subscription); } - if (!_groups.TryGetValue(oldKey, out var group)) + /// Determines whether a group exists. + /// The group key. + /// when the group exists; otherwise, . + internal bool ContainsKey(TKey key) => _groups.ContainsKey(key); + + /// Gets a group when it exists. + /// The group key. + /// The located item collection or an empty collection. + /// when the group exists; otherwise, . + internal bool TryGetValue(TKey key, out IReadOnlyList value) { - return; + if (_groups.TryGetValue(key, out var list)) + { + value = list; + return true; + } + + value = []; + return false; } - var index = group.IndexOf(previous); - if (index < 0) + /// Returns an enumerator over the groups. + /// An enumerator over the groups. + internal IEnumerator>> GetEnumerator() => EnumerateGroups().GetEnumerator(); + + /// Rebuilds the view from the current source state. + internal void Refresh() { - return; + lock (_lock) + { + RebuildView(); + } } - group[index] = change.Current; - } + /// Disposes the source subscription. + internal void Dispose() => _disposables.Dispose(); - /// Adds data for the AddToGroup operation. - /// The item value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AddToGroup(T item) - { - var key = _keySelector(item); - if (!_groups.TryGetValue(key, out var group)) + /// Handles source change notifications. + /// The changes value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnSourceChanged(ChangeSet changes) { - group = []; - _groups[key] = group; - _groupCollection.Add(new ReactiveGroup(key, group)); - } + lock (_lock) + { + for (var i = 0; i < changes.Count; i++) + { + ProcessChange(changes[i]); + } + } - group.Add(item); - } + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); + } - /// Removes data for the RemoveFromGroup operation. - /// The item value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RemoveFromGroup(T item) - { - var key = _keySelector(item); - if (!_groups.TryGetValue(key, out var group)) + /// Processes a source collection change. + /// The change value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ProcessChange(Change change) { - return; + switch (change.Reason) + { + case ChangeReason.Add: + { + AddToGroup(change.Current); + break; + } + + case ChangeReason.Remove: + { + RemoveFromGroup(change.Current); + break; + } + + case ChangeReason.Update: + { + UpdateGroup(change); + break; + } + + case ChangeReason.Clear: + { + _groups.Clear(); + _groupCollection.Clear(); + break; + } + + case ChangeReason.Move or ChangeReason.Refresh: + { + RebuildView(); + break; + } + + default: + { + break; + } + } } - group.Remove(item); - if (group.Count != 0) + /// Updates an item in its existing group or moves it to its new group. + /// The update change to apply. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateGroup(Change change) { - return; + var previous = change.Previous; + if (previous is null) + { + AddToGroup(change.Current); + return; + } + + var oldKey = _keySelector(previous); + var newKey = _keySelector(change.Current); + if (!EqualityComparer.Default.Equals(oldKey, newKey)) + { + RemoveFromGroup(previous); + AddToGroup(change.Current); + return; + } + + if (!_groups.TryGetValue(oldKey, out var group)) + { + return; + } + + var index = group.IndexOf(previous); + if (index < 0) + { + return; + } + + group[index] = change.Current; } - _groups.Remove(key); - ReactiveGroup? reactiveGroup = null; - var comparer = EqualityComparer.Default; - for (var i = 0; i < _groupCollection.Count; i++) + /// Adds data for the AddToGroup operation. + /// The item value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddToGroup(T item) { - var candidate = _groupCollection[i]; - if (comparer.Equals(candidate.Key, key)) + var key = _keySelector(item); + if (!_groups.TryGetValue(key, out var group)) { - reactiveGroup = candidate; - break; + group = []; + _groups[key] = group; + _groupCollection.Add(new(key, group)); } + + group.Add(item); } - if (reactiveGroup is null) + /// Removes data for the RemoveFromGroup operation. + /// The item value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RemoveFromGroup(T item) { - return; + var key = _keySelector(item); + if (!_groups.TryGetValue(key, out var group)) + { + return; + } + + _ = group.Remove(item); + if (group.Count != 0) + { + return; + } + + _ = _groups.Remove(key); + ReactiveGroup? reactiveGroup = null; + var comparer = EqualityComparer.Default; + for (var i = 0; i < _groupCollection.Count; i++) + { + var candidate = _groupCollection[i]; + if (comparer.Equals(candidate.Key, key)) + { + reactiveGroup = candidate; + break; + } + } + + if (reactiveGroup is null) + { + return; + } + + _ = _groupCollection.Remove(reactiveGroup); } - _groupCollection.Remove(reactiveGroup); - } + /// Rebuilds the view from the current source state. + private void RebuildView() + { + _groups.Clear(); + _groupCollection.Clear(); - /// Rebuilds the view from the current source state. - private void RebuildView() - { - _groups.Clear(); - _groupCollection.Clear(); + foreach (var item in _source) + { + AddToGroup(item); + } + } - foreach (var item in _source) + /// Enumerates the group item collections without LINQ allocation. + /// The group item collections. + private IEnumerable> EnumerateValues() { - AddToGroup(item); + foreach (var value in _groups.Values) + { + yield return value; + } } - } - /// Handles property change notifications. - /// The propertyName value. - private void OnPropertyChanged(string propertyName) => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + /// Enumerates the groups using the public read-only value type. + /// The key and read-only item collection for each group. + private IEnumerable>> EnumerateGroups() + { + foreach (var group in _groups) + { + yield return new(group.Key, group.Value); + } + } + + /// Forwards collection changes from the mutable collection. + /// The originating collection. + /// The collection change event data. + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) => + CollectionChanged?.Invoke(sender, eventArgs); + } } diff --git a/src/ReactiveList/Views/ReactiveView.cs b/src/ReactiveList/Views/ReactiveView.cs index 708d1c3..8e9352c 100644 --- a/src/ReactiveList/Views/ReactiveView.cs +++ b/src/ReactiveList/Views/ReactiveView.cs @@ -21,10 +21,16 @@ namespace CP.Primitives.Views; public class ReactiveView : INotifyPropertyChanged, IReactiveView, T> where T : notnull { - private readonly ObservableCollection _target = []; + /// Synchronizes changes to the facade's notification relay. + private readonly Lock _eventGate = new(); - private readonly IDisposable? _sub; + /// The composed state that owns the collection and source subscription. + private readonly State _state; + /// Relays state notifications while preserving this facade as the event sender. + private NotificationRelay? _propertyChangedRelay; + + /// Indicates whether this view has been disposed. private bool _disposedValue; /// @@ -49,56 +55,54 @@ public ReactiveView(IObservable> stream, IEnumerable snapshot, public ReactiveView(IObservable> stream, IEnumerable snapshot, Func filter, TimeSpan throttle, ISequencer sheduler) #endif { - Items = new(_target); + ThrowHelper.ThrowIfNull(stream); + ThrowHelper.ThrowIfNull(filter); - if (stream is null) - { - throw new ArgumentNullException(nameof(stream)); - } + _state = new(snapshot, filter); + Items = _state.Items; + _state.Activate(stream, throttle, sheduler); + } - if (filter is null) + /// Occurs when a property value changes. + /// This event is typically raised by classes that implement the interface to notify clients, such as data-binding frameworks, that a property + /// value has changed. + public event PropertyChangedEventHandler? PropertyChanged + { + add { - throw new ArgumentNullException(nameof(filter)); - } + if (value is null) + { + return; + } - if (snapshot is not null) - { - // 1. Load Initial State (Snapshot) - foreach (var item in snapshot) + lock (_eventGate) { - if (filter(item)) + _propertyChangedRelay ??= new(this); + if (_propertyChangedRelay.Add(value.Invoke)) { - _target.Add(item); + _state.PropertyChanged += _propertyChangedRelay.OnEvent; } } } - // 2. Subscribe to Stream with Throttling - _sub = stream - .Buffer(throttle) // Batch changes by time - .Keep(b => b.Count > 0) - .ObserveOn(sheduler) // Jump to UI Thread - .Subscribe(batch => + remove + { + if (value is null) { - foreach (var notify in batch) - { - ApplyChange(notify, filter); + return; + } - // Critical: Return array to pool - notify.Batch?.Dispose(); + lock (_eventGate) + { + if (_propertyChangedRelay?.Remove(value.Invoke) == true) + { + _state.PropertyChanged -= _propertyChangedRelay.OnEvent; } - - // Signal UI to refresh - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Items))); - }); + } + } } - /// Occurs when a property value changes. - /// This event is typically raised by classes that implement the interface to notify clients, such as data-binding frameworks, that a property - /// value has changed. - public event PropertyChangedEventHandler? PropertyChanged; - /// Gets a read-only, observable collection of items of type T. /// The collection reflects changes to the underlying data source and notifies observers of any /// modifications. Items cannot be added to or removed from this collection directly. @@ -112,10 +116,7 @@ public ReactiveView(IObservable> stream, IEnumerable snapshot, /// Thrown if is null. public ReactiveView ToProperty(Action> propertySetter) { - if (propertySetter is null) - { - throw new ArgumentNullException(nameof(propertySetter)); - } + ThrowHelper.ThrowIfNull(propertySetter); propertySetter(Items); return this; @@ -157,199 +158,263 @@ protected virtual void Dispose(bool disposing) if (disposing) { - _sub?.Dispose(); + _state.Dispose(); } _disposedValue = true; } - /// Applies the specified cache notification to the target collection, optionally filtering items to be added. - /// Depending on the action specified in the notification, this method may add, remove, or clear - /// items in the target collection. When adding items, only those for which the filter returns are included. - /// The cache notification describing the action to apply and the item or batch of items affected. Cannot be null. - /// A predicate used to determine whether an item should be added to the target collection. Cannot be null. - private void ApplyChange(CacheNotify n, Func filter) + /// Owns the mutable data and active subscription used by the facade. + private sealed class State { - switch (n.Action) - { - case CacheAction.Added: - { - AddItem(n.Item, filter); - break; - } + /// The predicate applied to snapshot items and source notifications. + private readonly Func _filter; - case CacheAction.Removed: - { - if (n.Item is not null) - { - _target.Remove(n.Item); - } + /// The mutable collection backing the public read-only view. + private readonly ObservableCollection _target = []; - break; - } + /// The subscription that feeds source notifications into this state. + private IDisposable? _subscription; - case CacheAction.Updated: - { - UpdateItem(n, filter); - break; - } + /// Initializes a new instance of the class from a snapshot. + /// The initial items to filter into the view. + /// The predicate that controls view membership. + internal State(IEnumerable snapshot, Func filter) + { + _filter = filter; + Items = new(_target); - case CacheAction.Moved: - { - // Source-relative positions cannot be mapped reliably when preceding items are filtered out. - break; - } + if (snapshot is null) + { + return; + } - case CacheAction.Refreshed: + foreach (var item in snapshot) + { + if (filter(item)) { - RefreshItem(n.Item, filter); - break; + _target.Add(item); } + } + } - case CacheAction.Cleared: - { - _target.Clear(); - break; - } + /// Occurs after a source batch changes the view items. + internal event EventHandler? PropertyChanged; - case CacheAction.BatchOperation or CacheAction.BatchAdded: - { - AddBatch(n.Batch, filter); - break; - } + /// Gets the read-only collection exposed by the facade. + internal ReadOnlyObservableCollection Items { get; } - case CacheAction.BatchRemoved: - { - RemoveBatch(n.Batch); - break; - } + /// Subscribes this fully initialized state to the source stream. + /// The source notification stream. + /// The interval over which notifications are buffered. + /// The scheduler on which buffered notifications are observed. +#if NET8_0_OR_GREATER + internal void Activate(IObservable> stream, in TimeSpan throttle, ISequencer scheduler) => +#else + internal void Activate(IObservable> stream, TimeSpan throttle, ISequencer scheduler) => +#endif + _subscription = stream + .Buffer(throttle) + .Keep(static batch => batch.Count > 0) + .ObserveOn(scheduler) + .Subscribe(HandleBatch); + + /// Disposes the active source subscription. + internal void Dispose() => _subscription?.Dispose(); + + /// Processes a buffered source batch and returns any pooled payloads. + /// The buffered notifications to process. + private void HandleBatch(IList> batch) + { + foreach (var notification in batch) + { + ApplyChange(notification); + notification.Batch?.Dispose(); + } - default: - { - // Ignore invalid enum values to preserve the view's current state. - break; - } + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Items))); } - } - /// Adds an item when it satisfies the view filter. - /// The item to consider. - /// The view filter. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AddItem(T? item, Func filter) - { - if (item is null || !filter(item)) + /// Applies one cache notification to the target collection. + /// The notification describing the change to apply. + private void ApplyChange(CacheNotify notification) { - return; - } + switch (notification.Action) + { + case CacheAction.Added: + { + AddItem(notification.Item); + break; + } - _target.Add(item); - } + case CacheAction.Removed: + { + if (notification.Item is not null) + { + _ = _target.Remove(notification.Item); + } - /// Updates an item and its membership in the filtered view. - /// The update notification. - /// The view filter. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateItem(CacheNotify notification, Func filter) - { - var previous = notification.Previous; - var current = notification.Item; - var existingIndex = -1; - if (previous is not null) - { - existingIndex = _target.IndexOf(previous); - } - else if (current is not null) - { - existingIndex = _target.IndexOf(current); + break; + } + + case CacheAction.Updated: + { + UpdateItem(notification); + break; + } + + case CacheAction.Moved: + { + // Source-relative positions cannot be mapped reliably when preceding items are filtered out. + break; + } + + case CacheAction.Refreshed: + { + RefreshItem(notification.Item); + break; + } + + case CacheAction.Cleared: + { + _target.Clear(); + break; + } + + case CacheAction.BatchOperation or CacheAction.BatchAdded: + { + AddBatch(notification.Batch); + break; + } + + case CacheAction.BatchRemoved: + { + RemoveBatch(notification.Batch); + break; + } + + default: + { + // Ignore invalid enum values to preserve the view's current state. + break; + } + } } - if (current is null || !filter(current)) + /// Adds an item when it satisfies the view filter. + /// The item to consider. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddItem(T? item) { - if (existingIndex >= 0) + if (item is null || !_filter(item)) { - _target.RemoveAt(existingIndex); + return; } - return; + _target.Add(item); } - if (existingIndex < 0) + /// Updates an item and its membership in the filtered view. + /// The update notification. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateItem(CacheNotify notification) { - // Without a previous value there is no reliable way to identify and remove the item being replaced. - // Preserve the current view rather than appending a potentially duplicate or stale replacement. + var previous = notification.Previous; + var current = notification.Item; + var existingIndex = -1; if (previous is not null) { - _target.Add(current); + existingIndex = _target.IndexOf(previous); + } + else if (current is not null) + { + existingIndex = _target.IndexOf(current); } - return; - } + if (current is null || !_filter(current)) + { + if (existingIndex >= 0) + { + _target.RemoveAt(existingIndex); + } - _target[existingIndex] = current; - } + return; + } - /// Re-evaluates one item against the view filter. - /// The refreshed item. - /// The view filter. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RefreshItem(T? item, Func filter) - { - if (item is null) - { - return; + if (existingIndex < 0) + { + // Without a previous value there is no reliable way to identify and remove the item being replaced. + // Preserve the current view rather than appending a potentially duplicate or stale replacement. + if (previous is not null) + { + _target.Add(current); + } + + return; + } + + _target[existingIndex] = current; } - var existingIndex = _target.IndexOf(item); - var shouldInclude = filter(item); - if (existingIndex < 0) + /// Re-evaluates one item against the view filter. + /// The refreshed item. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RefreshItem(T? item) { - if (!shouldInclude) + if (item is null) { return; } - _target.Add(item); - return; - } + var existingIndex = _target.IndexOf(item); + var shouldInclude = _filter(item); + if (existingIndex < 0) + { + if (!shouldInclude) + { + return; + } - if (shouldInclude) - { - return; - } + _target.Add(item); + return; + } - _target.RemoveAt(existingIndex); - } + if (shouldInclude) + { + return; + } - /// Adds every matching item in a batch. - /// The batch to add. - /// The view filter. - private void AddBatch(PooledBatch? batch, Func filter) - { - if (batch is null) - { - return; + _target.RemoveAt(existingIndex); } - for (var i = 0; i < batch.Count; i++) + /// Adds every matching item in a batch. + /// The batch to add. + private void AddBatch(PooledBatch? batch) { - AddItem(batch.Items[i], filter); - } - } + if (batch is null) + { + return; + } - /// Removes every item in a batch. - /// The batch to remove. - private void RemoveBatch(PooledBatch? batch) - { - if (batch is null) - { - return; + for (var i = 0; i < batch.Count; i++) + { + AddItem(batch.Items[i]); + } } - for (var i = 0; i < batch.Count; i++) + /// Removes every item in a batch. + /// The batch to remove. + private void RemoveBatch(PooledBatch? batch) { - _target.Remove(batch.Items[i]); + if (batch is null) + { + return; + } + + for (var i = 0; i < batch.Count; i++) + { + _ = _target.Remove(batch.Items[i]); + } } } } diff --git a/src/ReactiveList/Views/SecondaryIndexReactiveView.cs b/src/ReactiveList/Views/SecondaryIndexReactiveView.cs index 31b1851..04bf1e1 100644 --- a/src/ReactiveList/Views/SecondaryIndexReactiveView.cs +++ b/src/ReactiveList/Views/SecondaryIndexReactiveView.cs @@ -13,39 +13,44 @@ namespace CP.Primitives.Views; /// /// The type of primary keys in the dictionary. /// The type of values in the dictionary. -public sealed class SecondaryIndexReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, TValue>, IDisposable +public sealed class SecondaryIndexReactiveView : IReadOnlyList, INotifyCollectionChanged, + INotifyPropertyChanged, IReactiveView, TValue> where TKey : notnull { + /// The dictionary that supplies items to this view. private readonly QuaternaryDictionary _source; + /// The name of the secondary index used to filter items. private readonly string _indexName; + /// The secondary index key used to filter items. private readonly object _indexKey; + /// Retrieves the values matching a secondary index key. private readonly Func, string, object, IEnumerable> _getValuesByIndex; + /// Determines whether a value matches a secondary index key. private readonly Func, string, TValue, object, bool> _valueMatchesIndex; + /// The mutable collection that backs the read-only filtered items collection. private readonly ObservableCollection _filteredItems; + /// The subscriptions owned by this view. private readonly MultipleDisposable _disposables = []; + /// Synchronizes access to the filtered items collection. private readonly Lock _lock = new(); /// Initializes a new instance of the class. /// The source dictionary to filter. /// The name of the secondary index. /// The secondary index key (boxed) to filter on. - /// The scheduler for dispatching updates. - /// The throttle duration for updates. /// The delegate used to retrieve values for a boxed secondary index key. /// The delegate used to test whether a value matches a boxed secondary index key. private SecondaryIndexReactiveView( QuaternaryDictionary source, string indexName, object indexKey, - ISequencer scheduler, - TimeSpan throttle, Func, string, object, IEnumerable> getValuesByIndex, Func, string, TValue, object, bool> valueMatchesIndex) { @@ -57,20 +62,6 @@ private SecondaryIndexReactiveView( _filteredItems = []; Items = new(_filteredItems); - - // Initialize with current matching items - RebuildView(); - - // Subscribe to changes - var subscription = _source.Stream - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged); - - _disposables.Add(subscription); - - // Forward collection changed events - _filteredItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); } /// @@ -106,14 +97,14 @@ public static SecondaryIndexReactiveView Create( TimeSpan throttle) where TIndexKey : notnull { - return new SecondaryIndexReactiveView( + var view = new SecondaryIndexReactiveView( source, indexName, indexKey, - scheduler, - throttle, static (dict, name, key) => dict.GetValuesBySecondaryIndex(name, (TIndexKey)key), static (dict, name, value, key) => dict.ValueMatchesSecondaryIndex(name, value, (TIndexKey)key)); + view.Start(scheduler, throttle); + return view; } /// @@ -154,9 +145,26 @@ public SecondaryIndexReactiveView ToProperty(out ReadOnlyObservabl } /// - public void Dispose() + public void Dispose() => _disposables.Dispose(); + + /// Initializes the view contents and activates its subscriptions. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + private void Start(ISequencer scheduler, TimeSpan throttle) { - _disposables.Dispose(); + // Initialize with current matching items + RebuildView(); + + // Subscribe to changes + var subscription = _source.Stream + .Throttle(throttle) + .ObserveOn(scheduler) + .Subscribe(OnSourceChanged); + + _disposables.Add(subscription); + + // Forward collection changed events + _filteredItems.CollectionChanged += (_, e) => CollectionChanged?.Invoke(this, e); } /// Handles source change notifications. @@ -182,7 +190,7 @@ private void OnSourceChanged(CacheNotify> notificatio { if (notification.Item.Value is not null) { - _filteredItems.Remove(notification.Item.Value); + _ = _filteredItems.Remove(notification.Item.Value); } break; diff --git a/src/ReactiveList/Views/SortedReactiveView.cs b/src/ReactiveList/Views/SortedReactiveView.cs index 5ea4050..852f8fc 100644 --- a/src/ReactiveList/Views/SortedReactiveView.cs +++ b/src/ReactiveList/Views/SortedReactiveView.cs @@ -3,24 +3,33 @@ // See the LICENSE file in the project root for full license information. #if REACTIVELIST_REACTIVE +using CP.Reactive.Internal; + namespace CP.Reactive.Views; #else +using CP.Primitives.Internal; + namespace CP.Primitives.Views; #endif /// Provides a sorted, read-only view over a that automatically updates when the source list changes. /// The type of elements in the view. -public sealed class SortedReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T>, IDisposable +public sealed class SortedReactiveView : IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged, IReactiveView, T> where T : notnull { - private readonly IReactiveList _source; + /// Synchronizes collection-changed subscriptions to this facade. + private readonly Lock _collectionChangedGate = new(); - private readonly IComparer _comparer; + /// Synchronizes property-changed subscriptions to this facade. + private readonly Lock _propertyChangedGate = new(); - private readonly ObservableCollection _sortedItems; + /// The completed state object that owns sorting and source subscriptions. + private readonly State _state; - private readonly MultipleDisposable _disposables = []; + /// Relays collection notifications with this facade as the sender. + private NotificationRelay? _collectionChangedRelay; - private readonly Lock _lock = new(); + /// Relays property notifications with this facade as the sender. + private NotificationRelay? _propertyChangedRelay; /// Initializes a new instance of the class. /// The source reactive list to sort. @@ -33,59 +42,103 @@ public SortedReactiveView( ISequencer scheduler, TimeSpan throttle) { - _source = source ?? throw new ArgumentNullException(nameof(source)); - _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); - - _sortedItems = []; - Items = new(_sortedItems); + _state = new(source, comparer, scheduler, throttle); + _state.Start(); + } - // Initialize with current items sorted - RebuildView(); + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add + { + if (value is null) + { + return; + } - // Subscribe to changes using Stream with ToChangeSets() - var subscription = _source.Stream - .ToChangeSets() - .Throttle(throttle) - .ObserveOn(scheduler) - .Subscribe(OnSourceChanged); + lock (_collectionChangedGate) + { + _collectionChangedRelay ??= new(this); + if (_collectionChangedRelay.Add(value.Invoke)) + { + _state.CollectionChanged += _collectionChangedRelay.OnEvent; + } + } + } - _disposables.Add(subscription); + remove + { + if (value is null) + { + return; + } - // Forward collection changed events - _sortedItems.CollectionChanged += (s, e) => CollectionChanged?.Invoke(this, e); + lock (_collectionChangedGate) + { + if (_collectionChangedRelay?.Remove(value.Invoke) is true) + { + _state.CollectionChanged -= _collectionChangedRelay.OnEvent; + } + } + } } /// - public event NotifyCollectionChangedEventHandler? CollectionChanged; + public event PropertyChangedEventHandler? PropertyChanged + { + add + { + if (value is null) + { + return; + } - /// - public event PropertyChangedEventHandler? PropertyChanged; + lock (_propertyChangedGate) + { + _propertyChangedRelay ??= new(this); + if (_propertyChangedRelay.Add(value.Invoke)) + { + _state.PropertyChanged += _propertyChangedRelay.OnEvent; + } + } + } + + remove + { + if (value is null) + { + return; + } + + lock (_propertyChangedGate) + { + if (_propertyChangedRelay?.Remove(value.Invoke) is true) + { + _state.PropertyChanged -= _propertyChangedRelay.OnEvent; + } + } + } + } /// Gets the number of items in the sorted view. - public int Count => _sortedItems.Count; + public int Count => _state.Count; /// Gets the underlying read-only observable collection for UI binding. - public ReadOnlyObservableCollection Items { get; } + public ReadOnlyObservableCollection Items => _state.Items; /// Gets the item at the specified index. /// The zero-based index of the item to get. /// The item at the specified index. - public T this[int index] => _sortedItems[index]; + public T this[int index] => _state.GetItem(index); /// - public IEnumerator GetEnumerator() => _sortedItems.GetEnumerator(); + public IEnumerator GetEnumerator() => _state.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// Forces a rebuild of the sorted view from the source. - public void Refresh() - { - lock (_lock) - { - RebuildView(); - } - } + public void Refresh() => _state.Refresh(); /// Assigns the current collection of items to a property using the specified setter action. /// This method is typically used to bind the internal collection to an external property, such @@ -117,144 +170,231 @@ public SortedReactiveView ToProperty(out ReadOnlyObservableCollection coll } /// - public void Dispose() => _disposables.Dispose(); + public void Dispose() => _state.Dispose(); - /// Handles source change notifications. - /// The changes value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnSourceChanged(ChangeSet changes) + /// Owns the mutable sorted view after construction has completed. + private sealed class State { - lock (_lock) - { - var needsRebuild = false; + /// The reactive list that supplies items to this view. + private readonly IReactiveList _source; - for (var i = 0; i < changes.Count; i++) - { - var change = changes[i]; - switch (change.Reason) - { - case ChangeReason.Add: - { - InsertSorted(change.Current); - break; - } - - case ChangeReason.Remove: - { - _sortedItems.Remove(change.Current); - break; - } - - case ChangeReason.Update: - { - // Remove old, insert new in sorted position - if (change.Previous is not null) - { - _sortedItems.Remove(change.Previous); - } + /// The comparer used to order items in this view. + private readonly IComparer _comparer; - InsertSorted(change.Current); - break; - } - - case ChangeReason.Clear: - { - _sortedItems.Clear(); - break; - } - - case ChangeReason.Move or ChangeReason.Refresh: - { - // Rebuild for move/refresh as sort order may have changed - needsRebuild = true; - break; - } - - default: - { - // Ignore invalid enum values to preserve the view's current state. - break; - } - } - } + /// The mutable collection that backs the read-only sorted items collection. + private readonly ObservableCollection _sortedItems = []; - if (needsRebuild) - { - RebuildView(); - } - } + /// The scheduler used to dispatch source notifications. + private readonly ISequencer _scheduler; - OnPropertyChanged(nameof(Count)); - } + /// The throttle applied to source notifications. + private readonly TimeSpan _throttle; - /// Inserts data for the InsertSorted operation. - /// The item value. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void InsertSorted(T item) - { - // Binary search for correct insertion position - var index = BinarySearch(item); - if (index < 0) + /// The subscriptions owned by this state. + private readonly MultipleDisposable _disposables = []; + + /// Synchronizes access to the sorted items collection. + private readonly Lock _lock = new(); + + /// Initializes a new instance of the class without publishing callbacks. + /// The source reactive list to sort. + /// The comparer for sorting items. + /// The scheduler for dispatching updates. + /// The throttle duration for updates. + internal State( + IReactiveList source, + IComparer comparer, + ISequencer scheduler, + TimeSpan throttle) { - index = ~index; + _source = source ?? throw new ArgumentNullException(nameof(source)); + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + _scheduler = scheduler; + _throttle = throttle; + Items = new(_sortedItems); + RebuildView(); } - _sortedItems.Insert(index, item); - } + /// Raised when the sorted collection changes. + internal event EventHandler? CollectionChanged; - /// Searches for the sorted insertion index. - /// The item value. - /// The matching item index or the bitwise complement of the insertion index. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int BinarySearch(T item) - { - var lo = 0; - var hi = _sortedItems.Count - 1; + /// Raised when a state property changes. + internal event EventHandler? PropertyChanged; + + /// Gets the number of sorted items. + internal int Count => _sortedItems.Count; + + /// Gets the read-only observable sorted items. + internal ReadOnlyObservableCollection Items { get; } - while (lo <= hi) + /// Gets the item at the specified index. + /// The zero-based item index. + /// The sorted item. + internal T GetItem(int index) => _sortedItems[index]; + + /// Starts collection forwarding and source observation after state construction. + internal void Start() { - var mid = lo + ((hi - lo) >> 1); - var comparison = _comparer.Compare(_sortedItems[mid], item); + _sortedItems.CollectionChanged += OnCollectionChanged; + var subscription = _source.Stream + .ToChangeSets() + .Throttle(_throttle) + .ObserveOn(_scheduler) + .Subscribe(OnSourceChanged); + + _disposables.Add(subscription); + } - if (comparison == 0) + /// Returns an enumerator over the sorted items. + /// An enumerator over the sorted items. + internal IEnumerator GetEnumerator() => _sortedItems.GetEnumerator(); + + /// Rebuilds the view from the current source state. + internal void Refresh() + { + lock (_lock) { - return mid; + RebuildView(); } + } - if (comparison < 0) + /// Disposes the source subscription. + internal void Dispose() => _disposables.Dispose(); + + /// Handles source change notifications. + /// The changes value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void OnSourceChanged(ChangeSet changes) + { + lock (_lock) { - lo = mid + 1; + var needsRebuild = false; + + for (var i = 0; i < changes.Count; i++) + { + var change = changes[i]; + switch (change.Reason) + { + case ChangeReason.Add: + { + InsertSorted(change.Current); + break; + } + + case ChangeReason.Remove: + { + _ = _sortedItems.Remove(change.Current); + break; + } + + case ChangeReason.Update: + { + if (change.Previous is not null) + { + _ = _sortedItems.Remove(change.Previous); + } + + InsertSorted(change.Current); + break; + } + + case ChangeReason.Clear: + { + _sortedItems.Clear(); + break; + } + + case ChangeReason.Move or ChangeReason.Refresh: + { + needsRebuild = true; + break; + } + + default: + { + break; + } + } + } + + if (needsRebuild) + { + RebuildView(); + } } - else + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count))); + } + + /// Inserts data for the InsertSorted operation. + /// The item value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void InsertSorted(T item) + { + var index = BinarySearch(item); + if (index < 0) { - hi = mid - 1; + index = ~index; } - } - return ~lo; - } + _sortedItems.Insert(index, item); + } - /// Rebuilds the view from the current source state. - private void RebuildView() - { - _sortedItems.Clear(); - var sourceItems = _source.Items; - var count = sourceItems.Count; - var sorted = new List(count); - for (var i = 0; i < count; i++) + /// Searches for the sorted insertion index. + /// The item value. + /// The matching item index or the bitwise complement of the insertion index. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int BinarySearch(T item) { - sorted.Add(sourceItems[i]); + var lo = 0; + var hi = _sortedItems.Count - 1; + + while (lo <= hi) + { + var mid = lo + ((hi - lo) >> 1); + var comparison = _comparer.Compare(_sortedItems[mid], item); + + if (comparison == 0) + { + return mid; + } + + if (comparison < 0) + { + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return ~lo; } - sorted.Sort(_comparer); - for (var i = 0; i < sorted.Count; i++) + /// Rebuilds the view from the current source state. + private void RebuildView() { - _sortedItems.Add(sorted[i]); + _sortedItems.Clear(); + var sourceItems = _source.Items; + var count = sourceItems.Count; + var sorted = new List(count); + for (var i = 0; i < count; i++) + { + sorted.Add(sourceItems[i]); + } + + sorted.Sort(_comparer); + for (var i = 0; i < sorted.Count; i++) + { + _sortedItems.Add(sorted[i]); + } } - } - /// Handles property change notifications. - /// The propertyName value. - private void OnPropertyChanged(string propertyName) => - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + /// Forwards collection changes from the mutable collection. + /// The originating collection. + /// The collection change event data. + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs) => + CollectionChanged?.Invoke(sender, eventArgs); + } } diff --git a/src/ReactiveListTestApp.Tests/LiveDataEngineTests.cs b/src/ReactiveListTestApp.Tests/LiveDataEngineTests.cs index 38a48e3..a7eda8b 100644 --- a/src/ReactiveListTestApp.Tests/LiveDataEngineTests.cs +++ b/src/ReactiveListTestApp.Tests/LiveDataEngineTests.cs @@ -11,28 +11,40 @@ namespace ReactiveListTestApp.Tests; /// Verifies deterministic high-rate generation and pooled hot-path behavior. public sealed class LiveDataEngineTests { + /// The number of instruments generated by the engine. private const int InstrumentCount = 40; + /// The number of raw events generated for a full test frame. private const int RawEventCount = 1_000; + /// The expected number of diagnostic samples in a frame. private const int SampleCount = 12; + /// The minimum supported target event rate. private const int MinimumRate = 1_000; + /// The maximum supported target event rate. private const int MaximumRate = 100_000; + /// The number of frames generated before validating price stability. private const int PriceStabilityFrameCount = 20; + /// The number of events generated after resetting the engine. private const int PostResetEventCount = 250; + /// The target event rate used by the continuous producer test. private const int ContinuousTargetRate = 8_000; + /// The expected number of events in a continuously produced frame. private const int ExpectedContinuousFrameEvents = 1_000; - private const double MinimumExpectedPrice = 50d; + /// The exclusive lower price bound expected after repeated generation. + private const double MinimumExpectedPrice = 50D; - private const double MaximumExpectedPrice = 250d; + /// The exclusive upper price bound expected after repeated generation. + private const double MaximumExpectedPrice = 250D; + /// The maximum wait for the continuous producer to publish a frame. private static readonly TimeSpan ProducerTimeout = TimeSpan.FromSeconds(2); /// Confirms one frame processes the requested raw count and updates both pooled structures. @@ -65,9 +77,9 @@ public async Task GeneratedPricesRemainWithinRealisticBounds() frame = engine.GenerateFrame(RawEventCount); } - await Assert.That(frame.Snapshots.All(snapshot => snapshot.Price is > MinimumExpectedPrice and < MaximumExpectedPrice)).IsTrue(); - await Assert.That(frame.Snapshots.Any(snapshot => snapshot.IsAlert)).IsTrue(); - await Assert.That(frame.Snapshots.Any(snapshot => !snapshot.IsAlert)).IsTrue(); + await Assert.That(Array.TrueForAll(frame.Snapshots, static snapshot => snapshot.Price is > MinimumExpectedPrice and < MaximumExpectedPrice)).IsTrue(); + await Assert.That(Array.Exists(frame.Snapshots, static snapshot => snapshot.IsAlert)).IsTrue(); + await Assert.That(Array.Exists(frame.Snapshots, static snapshot => !snapshot.IsAlert)).IsTrue(); } /// Confirms cumulative counters and pooled state restart cleanly. @@ -92,10 +104,7 @@ public async Task ResetRestartsCountersAndState() [Test] public async Task TargetRateIsClampedToSupportedRange() { - using var engine = new LiveDataEngine - { - TargetEventsPerSecond = 1 - }; + using var engine = new LiveDataEngine { TargetEventsPerSecond = 1 }; await Assert.That(engine.TargetEventsPerSecond).IsEqualTo(MinimumRate); @@ -109,10 +118,7 @@ public async Task TargetRateIsClampedToSupportedRange() [Test] public async Task ContinuousProducerPublishesAndCanPause() { - using var engine = new LiveDataEngine - { - TargetEventsPerSecond = ContinuousTargetRate - }; + using var engine = new LiveDataEngine { TargetEventsPerSecond = ContinuousTargetRate }; var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); EventHandler handler = (_, frame) => completion.TrySetResult(frame); engine.FrameProduced += handler; diff --git a/src/ReactiveListTestApp/Infrastructure/DelegateCommand.cs b/src/ReactiveListTestApp/Infrastructure/DelegateCommand.cs index f51ddbb..aa654d7 100644 --- a/src/ReactiveListTestApp/Infrastructure/DelegateCommand.cs +++ b/src/ReactiveListTestApp/Infrastructure/DelegateCommand.cs @@ -21,5 +21,5 @@ internal sealed class DelegateCommand(Action execute, Func? canExecute = n public void Execute(object? parameter) => execute(); /// Notifies WPF that command availability may have changed. - public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + internal void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } diff --git a/src/ReactiveListTestApp/MainWindow.xaml.cs b/src/ReactiveListTestApp/MainWindow.xaml.cs index d2421bc..561b92d 100644 --- a/src/ReactiveListTestApp/MainWindow.xaml.cs +++ b/src/ReactiveListTestApp/MainWindow.xaml.cs @@ -12,10 +12,13 @@ namespace ReactiveListTestApp; /// Hosts the live ReactiveList feature showcase. public sealed partial class MainWindow : Window, IDisposable { + /// The shared whole-number display format. private const string NumberFormat = "{0:N0}"; + /// The view model owned by this window. private readonly MainWindowViewModel _viewModel; + /// Tracks whether the window has released its owned resources. private bool _disposed; /// Initializes a new instance of the class. @@ -37,7 +40,6 @@ public void Dispose() _disposed = true; _viewModel.Dispose(); - GC.SuppressFinalize(this); } /// @@ -59,46 +61,46 @@ private static Binding CreateBinding(string path, string? format = null, Binding /// Attaches all application-level view-model bindings in C#. private void ConfigureBindings() { - StartPauseButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.StartPauseCommand))); - StartPauseButton.SetBinding(ContentControl.ContentProperty, CreateBinding(nameof(MainWindowViewModel.StartPauseText))); - BurstButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.BurstCommand))); - StepButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.StepCommand))); - ResetButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.ResetCommand))); - RateSlider.SetBinding(RangeBase.ValueProperty, CreateBinding(nameof(MainWindowViewModel.TargetRate), mode: BindingMode.TwoWay)); - TargetRateText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.TargetRate), "{0:N0} events/s")); - StatusTextBlock.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.StatusText))); - LastOperationText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.LastOperation))); + _ = StartPauseButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.StartPauseCommand))); + _ = StartPauseButton.SetBinding(ContentControl.ContentProperty, CreateBinding(nameof(MainWindowViewModel.StartPauseText))); + _ = BurstButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.BurstCommand))); + _ = StepButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.StepCommand))); + _ = ResetButton.SetBinding(Button.CommandProperty, CreateBinding(nameof(MainWindowViewModel.ResetCommand))); + _ = RateSlider.SetBinding(RangeBase.ValueProperty, CreateBinding(nameof(MainWindowViewModel.TargetRate), mode: BindingMode.TwoWay)); + _ = TargetRateText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.TargetRate), "{0:N0} events/s")); + _ = StatusTextBlock.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.StatusText))); + _ = LastOperationText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.LastOperation))); - RateMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.EventsPerSecond), NumberFormat)); - FrameMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.UiFramesPerSecond), "{0:N1}")); - TotalMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.TotalEvents), NumberFormat)); - GenerationMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.GenerationMilliseconds), "{0:N2}")); - AllocationMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.AllocatedKilobytesPerFrame), "{0:N1}")); - SourceMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.SourceCount), NumberFormat)); - VisibleMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.ViewBacklogCount), NumberFormat)); - AlertMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.AlertCount), NumberFormat)); + _ = RateMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.EventsPerSecond), NumberFormat)); + _ = FrameMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.UiFramesPerSecond), "{0:N1}")); + _ = TotalMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.TotalEvents), NumberFormat)); + _ = GenerationMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.GenerationMilliseconds), "{0:N2}")); + _ = AllocationMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.AllocatedKilobytesPerFrame), "{0:N1}")); + _ = SourceMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.SourceCount), NumberFormat)); + _ = VisibleMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.ViewBacklogCount), NumberFormat)); + _ = AlertMetric.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.AlertCount), NumberFormat)); - LiveTapeGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.LiveTape))); - AlertsGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Alerts))); - SearchGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SearchResults))); - LatencyGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SlowestUpdates))); - SectorGroupsList.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SectorGroups))); - IndexedListGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.IndexedVenueSnapshots))); - IndexedDictionaryGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.IndexedVenueDictionary))); - MatrixGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.MatrixRows))); - StreamGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.StreamEvents))); - FeaturesGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Features))); + _ = LiveTapeGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.LiveTape))); + _ = AlertsGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Alerts))); + _ = SearchGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SearchResults))); + _ = LatencyGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SlowestUpdates))); + _ = SectorGroupsList.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.SectorGroups))); + _ = IndexedListGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.IndexedVenueSnapshots))); + _ = IndexedDictionaryGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.IndexedVenueDictionary))); + _ = MatrixGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.MatrixRows))); + _ = StreamGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.StreamEvents))); + _ = FeaturesGrid.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Features))); - SearchBox.SetBinding(TextBox.TextProperty, CreateBinding(nameof(MainWindowViewModel.SearchText), mode: BindingMode.TwoWay, trigger: UpdateSourceTrigger.PropertyChanged)); - AlertOnlyCheckBox.SetBinding(ToggleButton.IsCheckedProperty, CreateBinding(nameof(MainWindowViewModel.OnlyAlerts), mode: BindingMode.TwoWay)); - VenueComboBox.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Venues))); - VenueComboBox.SetBinding(Selector.SelectedItemProperty, CreateBinding(nameof(MainWindowViewModel.SelectedVenue), mode: BindingMode.TwoWay)); + _ = SearchBox.SetBinding(TextBox.TextProperty, CreateBinding(nameof(MainWindowViewModel.SearchText), mode: BindingMode.TwoWay, trigger: UpdateSourceTrigger.PropertyChanged)); + _ = AlertOnlyCheckBox.SetBinding(ToggleButton.IsCheckedProperty, CreateBinding(nameof(MainWindowViewModel.OnlyAlerts), mode: BindingMode.TwoWay)); + _ = VenueComboBox.SetBinding(ItemsControl.ItemsSourceProperty, CreateBinding(nameof(MainWindowViewModel.Venues))); + _ = VenueComboBox.SetBinding(Selector.SelectedItemProperty, CreateBinding(nameof(MainWindowViewModel.SelectedVenue), mode: BindingMode.TwoWay)); - ReactiveVersionText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.ReactiveListVersion), "ReactiveList version: {0:N0}")); - QuaternaryListCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.QuaternaryListCount), "QuaternaryList retained: {0:N0}")); - QuaternaryDictionaryCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.QuaternaryDictionaryCount), "QuaternaryDictionary keys: {0:N0}")); - MatrixCellCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.MatrixCellCount), "{0:N0} live cells")); - HotTickCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.HotTickCount), "{0:N0} ticks in current scratch batch")); - HotDictionaryCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.HotDictionaryCount), "{0:N0} latest instrument snapshots")); + _ = ReactiveVersionText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.ReactiveListVersion), "ReactiveList version: {0:N0}")); + _ = QuaternaryListCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.QuaternaryListCount), "QuaternaryList retained: {0:N0}")); + _ = QuaternaryDictionaryCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.QuaternaryDictionaryCount), "QuaternaryDictionary keys: {0:N0}")); + _ = MatrixCellCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.MatrixCellCount), "{0:N0} live cells")); + _ = HotTickCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.HotTickCount), "{0:N0} ticks in current scratch batch")); + _ = HotDictionaryCountText.SetBinding(TextBlock.TextProperty, CreateBinding(nameof(MainWindowViewModel.HotDictionaryCount), "{0:N0} latest instrument snapshots")); } } diff --git a/src/ReactiveListTestApp/MainWindowViewModel.cs b/src/ReactiveListTestApp/MainWindowViewModel.cs index 72022ab..2524599 100644 --- a/src/ReactiveListTestApp/MainWindowViewModel.cs +++ b/src/ReactiveListTestApp/MainWindowViewModel.cs @@ -24,101 +24,159 @@ namespace ReactiveListTestApp; /// Coordinates the live producer, reactive collections, views and sampled WPF metrics. -internal sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable +public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable { + /// The delay applied to standard reactive views. private const int ListViewThrottleMilliseconds = 0; + /// The delay applied to secondary-index views. private const int IndexViewThrottleMilliseconds = 0; + /// The maximum number of snapshots retained in the live tape. private const int TapeRetention = 800; + /// The maximum number of snapshots retained by the indexed list. private const int IndexedRetention = 1_200; + /// The maximum number of cache notifications retained in the stream inspector. private const int StreamRetention = 160; + /// The name shared by the venue secondary indexes. private const string VenueIndexName = "ByVenue"; + /// The feature catalog category used by reactive view entries. private const string DataViewCategory = "Data view"; + /// The minimum producer rate selectable in the UI. private const int MinimumRate = 1_000; + /// The maximum producer rate selectable in the UI. private const int MaximumRate = 100_000; + /// The increment used when rounding a selected producer rate. private const int RateStep = 1_000; + /// The number of projection frames produced each second. private const int ProjectionFramesPerSecond = 8; + /// The minimum raw event count generated by a single-step request. private const int MinimumStepEvents = 100; + /// The number of venue rows in the price matrix. private const int MatrixRowCount = 4; + /// The number of instrument columns in each price matrix row. private const int MatrixColumnCount = 10; + /// The interval between scalar metric updates. private const int MetricsSampleMilliseconds = 500; + /// The initial capacity of each formatted matrix row. private const int MatrixTextCapacity = 80; - private const double BytesPerKilobyte = 1_024d; + /// The number of bytes in one kibibyte used by the allocation display. + private const double BytesPerKilobyte = 1_024D; - private readonly LiveDataEngine _engine = new(); + /// The producer that supplies raw market frames. + private readonly LiveDataEngine _engine; + /// The bounded reactive market tape. private readonly ReactiveList _tape = []; + /// The current prices arranged by venue and instrument. private readonly Reactive2DList _priceMatrix = CreatePriceMatrix(); + /// The retained snapshots that support named secondary indexes. private readonly QuaternaryList _indexedSnapshots = []; + /// The latest snapshot for each instrument. private readonly QuaternaryDictionary _latestByInstrument = []; + /// The dispatcher-owned matrix row projections. private readonly ReactiveList _matrixRows = []; + /// The bounded collection event inspector. private readonly ReactiveList _streamEvents = []; + /// The current predicate used by the dynamic tape view. private readonly BehaviorSignal> _dynamicFilter = new(static _ => true); + /// The active venue keys used by the dynamic index views. private readonly BehaviorSignal _venueKeys = new(["LSE"]); + /// The stream subscriptions owned by the view model. private readonly List _disposables = []; + /// The dispatcher that owns all UI-facing projections. private readonly Dispatcher _dispatcher; + /// The clock used to timestamp stream entries. + private readonly TimeProvider _timeProvider; + + /// The stopwatch used to sample event and UI frame rates. private readonly Stopwatch _rateWindow = Stopwatch.StartNew(); + /// The stable event handler registered with the producer. private readonly EventHandler _frameHandler; + /// The unfiltered tape view. private readonly FilteredReactiveView _allView; + /// The alert-only tape view. private readonly FilteredReactiveView _alertsView; + /// The user-controlled dynamic tape view. private readonly DynamicFilteredReactiveView _searchView; + /// The latency-sorted tape view. private readonly SortedReactiveView _latencyView; + /// The sector-grouped tape view. private readonly GroupedReactiveView _sectorView; + /// The venue-filtered indexed list view. private readonly DynamicSecondaryIndexReactiveView _indexedVenueView; + /// The venue-filtered indexed dictionary view. private readonly DynamicSecondaryIndexDictionaryReactiveView _dictionaryVenueView; + /// The filtered collection event view. private readonly FilteredReactiveView _streamView; + /// The raw event count accumulated in the current sampling window. private long _windowEvents; + /// The raw event count observed during the previous sample. private long _lastWindowEvents; + /// The number of UI frames accumulated in the current sample. private long _uiFrames; + /// The next sequence number assigned to a stream entry. private long _streamSequence; + /// Indicates whether continuous production is active. private bool _isRunning = true; + /// The selected raw event rate. private int _targetRate = 10_000; + /// Tracks whether owned resources have been released. private bool _disposed; /// Initializes a new instance of the class. public MainWindowViewModel() + : this(TimeProvider.System) + { + } + + /// Initializes a new instance of the class. + /// The clock used by producer and stream timestamps. + internal MainWindowViewModel(TimeProvider timeProvider) { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + _engine = new(timeProvider); _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; _frameHandler = (_, frame) => ApplyFrame(frame); var sequencer = new DispatcherSequencer(_dispatcher, DispatcherPriority.Background); @@ -126,46 +184,41 @@ public MainWindowViewModel() _indexedSnapshots.AddIndex(VenueIndexName, static snapshot => snapshot.Venue); _latestByInstrument.AddValueIndex(VenueIndexName, static snapshot => snapshot.Venue); _matrixRows.AddRange([ - new("LSE", string.Empty, 0d, 0d, 0d), - new("XNAS", string.Empty, 0d, 0d, 0d), - new("XNYS", string.Empty, 0d, 0d, 0d), - new("XEUR", string.Empty, 0d, 0d, 0d) + new("LSE", string.Empty, 0D, 0D, 0D), + new("XNAS", string.Empty, 0D, 0D, 0D), + new("XNYS", string.Empty, 0D, 0D, 0D), + new("XEUR", string.Empty, 0D, 0D, 0D) ]); _allView = _tape.CreateView(sequencer, ListViewThrottleMilliseconds); _alertsView = _tape.CreateView(static snapshot => snapshot.IsAlert, sequencer, ListViewThrottleMilliseconds); _searchView = _tape.CreateView(_dynamicFilter, sequencer, ListViewThrottleMilliseconds); - _latencyView = _tape.SortBy(static snapshot => snapshot.LatencyMilliseconds, descending: true, sequencer, ListViewThrottleMilliseconds); + _latencyView = _tape.SortBy( + static snapshot => snapshot.LatencyMilliseconds, + descending: true, + sequencer, + ListViewThrottleMilliseconds); _sectorView = _tape.GroupBy(static snapshot => snapshot.Sector, sequencer, ListViewThrottleMilliseconds); - _indexedVenueView = _indexedSnapshots.CreateDynamicViewBySecondaryIndex(VenueIndexName, _venueKeys, sequencer, IndexViewThrottleMilliseconds); - _dictionaryVenueView = _latestByInstrument.CreateDynamicViewBySecondaryIndex(VenueIndexName, _venueKeys, sequencer, IndexViewThrottleMilliseconds); + _indexedVenueView = _indexedSnapshots.CreateDynamicViewBySecondaryIndex( + VenueIndexName, + _venueKeys, + sequencer, + IndexViewThrottleMilliseconds); + _dictionaryVenueView = _latestByInstrument.CreateDynamicViewBySecondaryIndex( + VenueIndexName, + _venueKeys, + sequencer, + IndexViewThrottleMilliseconds); _streamView = _streamEvents.CreateView(sequencer, ListViewThrottleMilliseconds); - _disposables.AddRange([ - _tape.Stream.Subscribe(new ActionObserver>(notification => RecordStream("ReactiveList", notification.Action, notification.Batch?.Count ?? 1))), - _tape.Connect().Subscribe(new ActionObserver>(changes => RecordStream("Connect() ChangeSet", CacheAction.BatchOperation, changes.Count))), - _indexedSnapshots.Stream.Subscribe(new ActionObserver>(notification => RecordStream("QuaternaryList", notification.Action, notification.Batch?.Count ?? 1))), - _latestByInstrument.Stream.Subscribe(new ActionObserver>>(notification => RecordStream("QuaternaryDictionary", notification.Action, notification.Batch?.Count ?? 1))) - ]); + _disposables.AddRange(CreateStreamSubscriptions()); StartPauseCommand = new DelegateCommand(ToggleRunning); ResetCommand = new DelegateCommand(Reset); BurstCommand = new DelegateCommand(Burst); StepCommand = new DelegateCommand(Step, () => !_isRunning); - Features = - [ - new("ReactiveList", "Reactive collection", "Thread-safe list with pooled batch mutations, versioned stream and activity collections.", "Bounded market tape using AddRange and RemoveRange."), - new("Reactive2DList", "Reactive collection", "Observable rows plus inner Add, Insert, Set, Remove, Flatten and TotalCount operations.", "Four live venue lanes with ten instruments per row."), - new("QuaternaryList", "Concurrent collection", "Four sharded writers with snapshots, batches, RemoveMany and named secondary indexes.", "Retained indexed ticks filtered by a dynamic venue signal."), - new("QuaternaryDictionary", "Concurrent collection", "Sharded lookup map with AddOrUpdate, range edits and value secondary indexes.", "Latest quote per instrument with a dynamic venue index view."), - new("QuadList", "Pooled hot path", "ArrayPool-backed non-reactive list optimized for spans and low-allocation batch work.", "Per-frame raw tick scratch buffer on the producer thread."), - new("QuadDictionary", "Pooled hot path", "Pooled hash table with direct ref lookup and no notification overhead.", "Latest snapshot aggregation before reactive publication."), - new("Filtered / Dynamic views", DataViewCategory, "Read-only observable projections updated from source streams and changing predicates.", "All, alert-only and live text/alert query grids."), - new("Sorted / Grouped views", DataViewCategory, "Incrementally refreshed order and ReactiveGroup projections.", "Latency leaderboard and sector group summaries."), - new("Secondary-index views", DataViewCategory, "Static or signal-driven views that query named quaternary indexes.", "Venue selector drives list and dictionary views together."), - new("Connect / ChangeSet pipeline", "Stream", "Cache notifications bridge to ChangeSet transforms, filters, grouping and action callbacks.", "Bounded stream inspector shows real collection actions.") - ]; + Features = CreateFeatureCatalog(); _engine.FrameProduced += _frameHandler; _engine.TargetEventsPerSecond = _targetRate; @@ -197,13 +250,13 @@ public MainWindowViewModel() public ReadOnlyObservableCollection> IndexedVenueDictionary => _dictionaryVenueView.Items; /// Gets display rows projected from the two-dimensional collection. - public ReadOnlyObservableCollection MatrixRows => _matrixRows.Items; + public System.Collections.IEnumerable MatrixRows => _matrixRows.Items; /// Gets the bounded cache notification log. - public ReadOnlyObservableCollection StreamEvents => _streamView.Items; + public System.Collections.IEnumerable StreamEvents => _streamView.Items; /// Gets the feature catalog shown beside the stream inspector. - public IReadOnlyList Features { get; } + public System.Collections.IEnumerable Features { get; } /// Gets the available secondary-index keys. public IReadOnlyList Venues { get; } = ["LSE", "XNAS", "XNYS", "XEUR"]; @@ -419,7 +472,7 @@ public void Dispose() _disposed = true; _engine.FrameProduced -= _frameHandler; - _engine.Dispose(); + ((IDisposable)_engine).Dispose(); for (var i = _disposables.Count - 1; i >= 0; i--) { _disposables[i].Dispose(); @@ -443,10 +496,104 @@ public void Dispose() _streamEvents.Dispose(); } + /// Creates the static feature catalog shown by the application. + /// The feature cards. + private static FeatureCard[] CreateFeatureCatalog() => + [ + new( + "ReactiveList", + "Reactive collection", + "Thread-safe list with pooled batch mutations, versioned stream and activity collections.", + "Bounded market tape using AddRange and RemoveRange."), + new( + "Reactive2DList", + "Reactive collection", + "Observable rows plus inner Add, Insert, Set, Remove, Flatten and TotalCount operations.", + "Four live venue lanes with ten instruments per row."), + new( + "QuaternaryList", + "Concurrent collection", + "Four sharded writers with snapshots, batches, RemoveMany and named secondary indexes.", + "Retained indexed ticks filtered by a dynamic venue signal."), + new( + "QuaternaryDictionary", + "Concurrent collection", + "Sharded lookup map with AddOrUpdate, range edits and value secondary indexes.", + "Latest quote per instrument with a dynamic venue index view."), + new( + "QuadList", + "Pooled hot path", + "ArrayPool-backed non-reactive list optimized for spans and low-allocation batch work.", + "Per-frame raw tick scratch buffer on the producer thread."), + new( + "QuadDictionary", + "Pooled hot path", + "Pooled hash table with direct ref lookup and no notification overhead.", + "Latest snapshot aggregation before reactive publication."), + new( + "Filtered / Dynamic views", + DataViewCategory, + "Read-only observable projections updated from source streams and changing predicates.", + "All, alert-only and live text/alert query grids."), + new( + "Sorted / Grouped views", + DataViewCategory, + "Incrementally refreshed order and ReactiveGroup projections.", + "Latency leaderboard and sector group summaries."), + new( + "Secondary-index views", + DataViewCategory, + "Static or signal-driven views that query named quaternary indexes.", + "Venue selector drives list and dictionary views together."), + new( + "Connect / ChangeSet pipeline", + "Stream", + "Cache notifications bridge to ChangeSet transforms, filters, grouping and action callbacks.", + "Bounded stream inspector shows real collection actions.") + ]; + /// Creates four pre-sized inner rows for allocation-stable updates. /// The initialized price matrix. - private static Reactive2DList CreatePriceMatrix() => - new(Enumerable.Range(0, MatrixRowCount).Select(_ => Enumerable.Repeat(0d, MatrixColumnCount))); + private static Reactive2DList CreatePriceMatrix() + { + var rows = new double[MatrixRowCount][]; + for (var row = 0; row < rows.Length; row++) + { + rows[row] = new double[MatrixColumnCount]; + } + + return new(rows); + } + + /// Creates the subscriptions that feed the bounded stream inspector. + /// The subscriptions owned by the view model. + private IDisposable[] CreateStreamSubscriptions() => + [ + _tape.Stream.Subscribe( + new ActionObserver>( + notification => RecordStream( + "ReactiveList", + notification.Action, + notification.Batch?.Count ?? 1))), + _tape.Connect().Subscribe( + new ActionObserver>( + changes => RecordStream( + "Connect() ChangeSet", + CacheAction.BatchOperation, + changes.Count))), + _indexedSnapshots.Stream.Subscribe( + new ActionObserver>( + notification => RecordStream( + "QuaternaryList", + notification.Action, + notification.Batch?.Count ?? 1))), + _latestByInstrument.Stream.Subscribe( + new ActionObserver>>( + notification => RecordStream( + "QuaternaryDictionary", + notification.Action, + notification.Batch?.Count ?? 1))) + ]; /// Pauses or resumes the continuous worker. private void ToggleRunning() @@ -455,7 +602,7 @@ private void ToggleRunning() IsRunning = !_engine.IsPaused; StatusText = IsRunning ? "LIVE · worker ingesting full-rate batches" : "PAUSED · views remain queryable"; LastOperation = IsRunning ? "Continuous producer resumed" : "Continuous producer paused"; - Interlocked.Exchange(ref _uiFrames, 0); + _ = Interlocked.Exchange(ref _uiFrames, 0); _lastWindowEvents = Interlocked.Read(ref _windowEvents); _rateWindow.Restart(); if (!IsRunning) @@ -480,14 +627,14 @@ private void Reset() { for (var column = 0; column < MatrixColumnCount; column++) { - _priceMatrix.SetItem(row, column, 0d); + _priceMatrix.SetItem(row, column, 0D); } - _matrixRows[row] = new(Venues[row], string.Empty, 0d, 0d, 0d); + _matrixRows[row] = new(Venues[row], string.Empty, 0D, 0D, 0D); } - Interlocked.Exchange(ref _windowEvents, 0); - Interlocked.Exchange(ref _uiFrames, 0); + _ = Interlocked.Exchange(ref _windowEvents, 0); + _ = Interlocked.Exchange(ref _uiFrames, 0); _lastWindowEvents = 0; _rateWindow.Restart(); EventsPerSecond = 0; @@ -534,7 +681,7 @@ private void ApplyFrame(MarketFrame frame) { var retainedFrameCount = IndexedRetention / frame.Snapshots.Length; var cutoff = frame.Sequence - ((long)frame.EventCount * retainedFrameCount); - _indexedSnapshots.RemoveMany(snapshot => snapshot.Sequence < cutoff); + _ = _indexedSnapshots.RemoveMany(snapshot => snapshot.Sequence < cutoff); } foreach (ref readonly var snapshot in frame.Snapshots.AsSpan()) @@ -542,8 +689,8 @@ private void ApplyFrame(MarketFrame frame) _latestByInstrument.AddOrUpdate(snapshot.InstrumentId, snapshot); } - Interlocked.Add(ref _windowEvents, frame.EventCount); - _dispatcher.BeginInvoke( + _ = Interlocked.Add(ref _windowEvents, frame.EventCount); + _ = _dispatcher.BeginInvoke( DispatcherPriority.Background, () => ApplyUiFrame(frame)); } @@ -562,7 +709,16 @@ private void ApplyUiFrame(MarketFrame frame) SourceCount = _tape.Count; VisibleCount = _searchView.Count; ViewBacklogCount = Math.Max(0, VisibleCount - SourceCount); - AlertCount = frame.Snapshots.Count(static snapshot => snapshot.IsAlert); + var alertCount = 0; + foreach (ref readonly var snapshot in frame.Snapshots.AsSpan()) + { + if (snapshot.IsAlert) + { + alertCount++; + } + } + + AlertCount = alertCount; GenerationMilliseconds = frame.GenerationTime.TotalMilliseconds; AllocatedKilobytesPerFrame = frame.AllocatedBytes / BytesPerKilobyte; HotTickCount = _engine.HotTickCapacityCount; @@ -570,7 +726,7 @@ private void ApplyUiFrame(MarketFrame frame) ReactiveListVersion = _tape.Version; QuaternaryListCount = _indexedSnapshots.Count; QuaternaryDictionaryCount = _latestByInstrument.Count; - Interlocked.Increment(ref _uiFrames); + _ = Interlocked.Increment(ref _uiFrames); if (_rateWindow.ElapsedMilliseconds < MetricsSampleMilliseconds) { @@ -595,7 +751,7 @@ private void UpdateMatrix(InstrumentSnapshot[] snapshots) var values = snapshots.AsSpan(start, MatrixColumnCount); var minimum = double.MaxValue; var maximum = double.MinValue; - var total = 0d; + var total = 0D; var formattedValues = new StringBuilder(MatrixTextCapacity); for (var column = 0; column < values.Length; column++) { @@ -606,10 +762,10 @@ private void UpdateMatrix(InstrumentSnapshot[] snapshots) total += price; if (column > 0) { - formattedValues.Append(" "); + _ = formattedValues.Append(" "); } - formattedValues.Append(CultureInfo.CurrentCulture, $"{price:N2}"); + _ = formattedValues.Append(CultureInfo.CurrentCulture, $"{price:N2}"); } _matrixRows[row] = new( @@ -629,11 +785,11 @@ private void PublishDynamicFilter() var search = SearchText.Trim(); var alertsOnly = OnlyAlerts; _dynamicFilter.OnNext(snapshot => - (!alertsOnly || snapshot.IsAlert) && - (search.Length == 0 || - snapshot.Symbol.Contains(search, StringComparison.OrdinalIgnoreCase) || - snapshot.Sector.Contains(search, StringComparison.OrdinalIgnoreCase) || - snapshot.Venue.Contains(search, StringComparison.OrdinalIgnoreCase))); + (!alertsOnly || snapshot.IsAlert) + && (search.Length == 0 + || snapshot.Symbol.Contains(search, StringComparison.OrdinalIgnoreCase) + || snapshot.Sector.Contains(search, StringComparison.OrdinalIgnoreCase) + || snapshot.Venue.Contains(search, StringComparison.OrdinalIgnoreCase))); LastOperation = alertsOnly ? "Dynamic view is filtering search results to alerts" : "Dynamic view predicate updated"; } @@ -652,7 +808,7 @@ private void RecordStream(string source, CacheAction action, int count) var item = new StreamEvent( Interlocked.Increment(ref _streamSequence), - DateTime.Now.ToString("HH:mm:ss.fff"), + _timeProvider.GetLocalNow().ToString("HH:mm:ss.fff"), source, action.ToString(), count, diff --git a/src/ReactiveListTestApp/Models/InstrumentSnapshot.cs b/src/ReactiveListTestApp/Models/InstrumentSnapshot.cs index 1afaff9..14c570f 100644 --- a/src/ReactiveListTestApp/Models/InstrumentSnapshot.cs +++ b/src/ReactiveListTestApp/Models/InstrumentSnapshot.cs @@ -16,7 +16,7 @@ namespace ReactiveListTestApp.Models; /// The simulated processing latency. /// Whether the snapshot crosses an alert threshold. /// The projection timestamp. -internal readonly record struct InstrumentSnapshot( +public readonly record struct InstrumentSnapshot( long Sequence, int InstrumentId, string Symbol, diff --git a/src/ReactiveListTestApp/Services/LiveDataEngine.cs b/src/ReactiveListTestApp/Services/LiveDataEngine.cs index 07728ec..e7744cd 100644 --- a/src/ReactiveListTestApp/Services/LiveDataEngine.cs +++ b/src/ReactiveListTestApp/Services/LiveDataEngine.cs @@ -12,91 +12,140 @@ namespace ReactiveListTestApp.Services; /// Generates high-rate value events and publishes bounded immutable frames. internal sealed class LiveDataEngine : IDisposable { + /// The number of instruments projected into each frame. private const int InstrumentCount = 40; + /// The maximum number of raw ticks sampled into each frame. private const int SampleCount = 12; + /// The number of frames generated each second. private const int FramesPerSecond = 8; + /// The minimum accepted target event rate. private const int MinimumRate = 1_000; + /// The maximum accepted target event rate. private const int MaximumRate = 100_000; + /// The midpoint subtracted from generated movement units. private const int MovementCentre = 1_024; + /// The exclusive upper range used for generated volume. private const int VolumeRange = 500; - private const double BaseOpeningPrice = 70d; + /// The opening price of the first instrument. + private const double BaseOpeningPrice = 70D; - private const double OpeningPriceStep = 2.75d; + /// The opening-price increment between consecutive instruments. + private const double OpeningPriceStep = 2.75D; - private const double MovementScale = 0.00000015d; + /// The scale applied to generated price movement units. + private const double MovementScale = 0.00000015D; - private const double MinimumPrice = 0.01d; + /// The lower bound applied to generated prices. + private const double MinimumPrice = 0.01D; - private const double PercentageMultiplier = 100d; + /// The multiplier used to express a ratio as a percentage. + private const double PercentageMultiplier = 100D; - private const double BaseLatencyMilliseconds = 0.04d; + /// The baseline simulated latency. + private const double BaseLatencyMilliseconds = 0.04D; - private const double LatencyDivisor = 34d; + /// The divisor used to scale simulated latency. + private const double LatencyDivisor = 34D; - private const double ChangeAlertThreshold = 0.65d; + /// The absolute percentage change that raises an alert. + private const double ChangeAlertThreshold = 0.65D; - private const double LatencyAlertThreshold = 6d; + /// The simulated latency that raises an alert. + private const double LatencyAlertThreshold = 6D; - private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(1_000d / FramesPerSecond); + /// The delay between continuous producer frames. + private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(1_000D / FramesPerSecond); + /// The stable symbols used by generated instruments. private static readonly string[] Symbols = CreateSymbols(); + /// The sectors distributed across generated instruments. private static readonly string[] Sectors = ["Energy", "Finance", "Health", "Industrials", "Technology"]; + /// The venues distributed across generated instruments. private static readonly string[] Venues = ["LSE", "XNAS", "XNYS", "XEUR"]; + /// The producer-owned mutable aggregation states. private readonly InstrumentState[] _states = CreateStates(); + /// The pooled raw tick scratch collection. private readonly QuadList _hotTicks = []; + /// The pooled latest-snapshot aggregation dictionary. private readonly QuadDictionary _latestByInstrument = []; - private readonly object _lifecycleGate = new(); + /// Coordinates producer startup, cancellation, and disposal. + private readonly System.Threading.Lock _lifecycleGate = new(); - private readonly object _generationGate = new(); + /// Serializes frame generation and reset operations. + private readonly System.Threading.Lock _generationGate = new(); + /// The clock used to timestamp generated data. + private readonly TimeProvider _timeProvider; + + /// Cancels the continuous producer. private CancellationTokenSource? _cancellation; + /// The active continuous producer task. private Task? _runTask; + /// The latest generated raw-event sequence. private long _sequence; + /// The cumulative number of generated raw events. private long _totalEvents; - private uint _randomState = 0xA341316Cu; + /// The current state of the local xorshift generator. + private uint _randomState = 0xA341316CU; + /// Indicates whether continuous frame publication is paused. private bool _paused; + /// Tracks whether owned resources have been released. private bool _disposed; + /// Initializes a new instance of the class. + internal LiveDataEngine() + : this(TimeProvider.System) + { + } + + /// Initializes a new instance of the class. + /// The clock used to timestamp generated data. + internal LiveDataEngine(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } + /// Occurs after the worker has generated and aggregated one projection frame. - public event EventHandler? FrameProduced; + internal event EventHandler? FrameProduced; /// Gets or sets the requested raw event throughput. - public int TargetEventsPerSecond + internal int TargetEventsPerSecond { get => Volatile.Read(ref field); set => Volatile.Write(ref field, Math.Clamp(value, MinimumRate, MaximumRate)); } = 10_000; /// Gets a value indicating whether continuous publication is paused. - public bool IsPaused => Volatile.Read(ref _paused); + internal bool IsPaused => Volatile.Read(ref _paused); /// Gets the number of raw ticks in the current pooled scratch collection. - public int HotTickCapacityCount => _hotTicks.Count; + internal int HotTickCapacityCount => _hotTicks.Count; /// Gets the number of current entries in the pooled aggregation dictionary. - public int HotDictionaryCount => _latestByInstrument.Count; + internal int HotDictionaryCount => _latestByInstrument.Count; /// Starts or resumes the continuous producer. - public void Start() + internal void Start() { ObjectDisposedException.ThrowIf(_disposed, this); lock (_lifecycleGate) @@ -114,12 +163,12 @@ public void Start() } /// Switches between continuous generation and a paused state. - public void TogglePause() => _paused = !_paused; + internal void TogglePause() => _paused = !_paused; /// Generates and aggregates one deterministic-size raw event batch. /// The number of raw events to process. /// The immutable projection frame. - public MarketFrame GenerateFrame(int eventCount) + internal MarketFrame GenerateFrame(int eventCount) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(eventCount); @@ -130,7 +179,7 @@ public MarketFrame GenerateFrame(int eventCount) } /// Resets generated state while retaining pooled collection storage. - public void Reset() + internal void Reset() { lock (_generationGate) { @@ -138,37 +187,10 @@ public void Reset() initialStates.CopyTo(_states, 0); _hotTicks.Clear(); _latestByInstrument.Clear(); - Interlocked.Exchange(ref _sequence, 0); - Interlocked.Exchange(ref _totalEvents, 0); - _randomState = 0xA341316Cu; - } - } - - /// - public void Dispose() - { - if (_disposed) - { - return; + _ = Interlocked.Exchange(ref _sequence, 0); + _ = Interlocked.Exchange(ref _totalEvents, 0); + _randomState = 0xA341316CU; } - - _disposed = true; - lock (_lifecycleGate) - { - _cancellation?.Cancel(); - } - - try - { - _runTask?.GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - } - - _cancellation?.Dispose(); - _hotTicks.Dispose(); - _latestByInstrument.Dispose(); } /// Creates stable display symbols once for the lifetime of the process. @@ -198,6 +220,36 @@ private static InstrumentState[] CreateStates() return states; } + /// Stops production and releases all owned resources. + private void DisposeCore() + { + if (_disposed) + { + return; + } + + _disposed = true; + lock (_lifecycleGate) + { + _cancellation?.Cancel(); + } + + try + { + _runTask?.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + + _cancellation?.Dispose(); + _hotTicks.Dispose(); + _latestByInstrument.Dispose(); + } + + /// + void IDisposable.Dispose() => DisposeCore(); + /// Runs the ten-frame-per-second projection clock. /// Stops the producer. /// A task that completes when cancellation is requested. @@ -240,11 +292,19 @@ private MarketFrame GenerateFrameCore(int eventCount) var sequence = Interlocked.Increment(ref _sequence); var elapsed = Stopwatch.GetElapsedTime(started); var allocated = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBefore); - return new MarketFrame(sequence, eventCount, total, elapsed, allocated, snapshots, samples, DateTimeOffset.Now); + return new( + sequence, + eventCount, + total, + elapsed, + allocated, + snapshots, + samples, + _timeProvider.GetLocalNow()); } finally { - ArrayPool.Shared.Return(rented, clearArray: false); + ArrayPool.Shared.Return(rented); } } @@ -259,7 +319,7 @@ private void GenerateTicks(Span target) ref var state = ref _states[instrumentId]; var movementUnits = (int)((random >> 8) & 0x7FF) - MovementCentre; var movement = movementUnits * MovementScale; - var price = Math.Max(MinimumPrice, state.LastPrice * (1d + movement)); + var price = Math.Max(MinimumPrice, state.LastPrice * (1D + movement)); var volume = 1 + (int)((random >> 20) % VolumeRange); target[i] = new( Interlocked.Increment(ref _sequence), @@ -267,7 +327,7 @@ private void GenerateTicks(Span target) price, volume, Stopwatch.GetTimestamp(), - (random & 1u) == 0); + (random & 1U) == 0); } } @@ -287,12 +347,12 @@ private void Aggregate(ReadOnlySpan ticks) /// The fixed-size snapshot array. private InstrumentSnapshot[] CreateSnapshots() { - var now = DateTimeOffset.Now; + var now = _timeProvider.GetLocalNow(); var snapshots = new InstrumentSnapshot[InstrumentCount]; for (var i = 0; i < snapshots.Length; i++) { ref var state = ref _states[i]; - var change = ((state.LastPrice / state.OpeningPrice) - 1d) * PercentageMultiplier; + var change = ((state.LastPrice / state.OpeningPrice) - 1D) * PercentageMultiplier; var latency = BaseLatencyMilliseconds + ((NextRandom() & 0xFF) / LatencyDivisor); var snapshot = new InstrumentSnapshot( Volatile.Read(ref _sequence), From cfda8f987c6df418b1c0a8d253a1213319941d6e Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 28 Jul 2026 02:21:33 +0100 Subject: [PATCH 2/2] fix: enforce LF checkouts for source files CI fix: - add repository Git attributes for C# and build configuration files - prevent Windows checkout conversion from rewriting analyzer-sensitive sources to CRLF - keep StyleSharp SST1532 active instead of suppressing the line-ending rule Validation: - simulated a Windows checkout with core.autocrlf=true - confirmed every checked-out C# file contains zero CR bytes - staged diff passes git whitespace checks --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ff3d47 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Keep analyzer-sensitive source and build files on LF across all checkout platforms. +*.cs text eol=lf +*.csproj text eol=lf +*.props text eol=lf +*.targets text eol=lf +*.slnx text eol=lf +.editorconfig text eol=lf +.gitattributes text eol=lf