diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b23fb45..d445f7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,7 +141,7 @@ jobs: trap 'rm -f "$UPM_INDEX"' EXIT export GIT_INDEX_FILE="$UPM_INDEX" git read-tree "$TAG" - git rm -r --cached --quiet --ignore-unmatch .github + git rm -r --cached --quiet --ignore-unmatch .github CLAUDE.md CLAUDE.md.meta TREE=$(git write-tree) unset GIT_INDEX_FILE PARENT=$(git rev-parse "$TAG^{commit}") diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1b63b..cd6d035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `FilteredList`: incremental dispatch of source `Add`/`Remove` (single and batch), `Replace` and `Move` (single) — no full rebuild on every event for the common cases. Reset and batch `Replace`/`Move` still fall back to a full `Update()`. +- `ObservableList.RemoveRange(int startIndex, int count)` and the protected `OnRemovedRange(in IReadOnlyList)` hook. `ObservableListSync` forwards source batch removes through it. +- Batch propagation in `ObservableDictionarySync` and `ObservableHashSetSync` for `Add` / `Remove` (and `Replace` on Dictionary) — previously threw `NotImplementedException`. +- `CollectionChangedEvent`: re-entrancy-safe via copy-on-write of the subscriber list during `Invoke`; a lazy `HashSet` tracks unsubscribes during the invoke chain for O(1) skip of removed handlers. +- Performance benchmark scaffold under `Tests/Observable/Performance/`, gated by the `ASPID_COLLECTIONS_PERFORMANCE_TESTING` define. +- Aspid script icon at `Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png`; runtime and test `.cs.meta` files point to it so scripts surface the project icon in the Unity Project view. + +### Changed +- `ObservableQueueSync` / `ObservableStackSync` / `ObservableDictionarySync`: unsupported actions now throw `NotSupportedException` with a descriptive message instead of `NotImplementedException`. +- Release workflow excludes `CLAUDE.md` / `CLAUDE.md.meta` from the published UPM tree (alongside `.github`). + +### Fixed +- `ObservableStackSync.OnPoppedRange` and `ObservableQueueSync.OnDequeuedRange` overrides recursed into themselves via C# overload resolution, causing `StackOverflowException` on `Dispose` and any batch dequeue/pop. +- `ObservableDictionarySync.OnReplaced` passed `newItem.Value` to `OnRemoved`, disposing/notifying the freshly inserted value instead of the discarded one. +- `FilteredList.ApplyMoveSingle` did not re-seat the moved entry when a `Comparer` was set; for comparer-equal items the source-index tie-break could drift from a fresh `Update()` rebuild. +- `ObservableHashSetSync.AddOne` ignored the `Add` return value, silently corrupting `_sync` on a non-injective converter — now throws `InvalidOperationException`. `RemoveOne` uses `TryGetValue` instead of the indexer to avoid `KeyNotFoundException` inside the source's `Invoke`. +- `ObservableList.RemoveRange` validates `count < 0` → `ArgumentOutOfRangeException` (previously `OverflowException` from `new T[-1]`). +- `FilteredList` 3-arg constructor: source subscription now happens after `_filter` / `_comparer` are set, closing a subscribe-before-init window. + ## [1.0.2] — 2026-05-18 ### Added diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..935eebc --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 84d90a7706acd4c4c8e66d9b534bd81c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/CLAUDE.md b/CLAUDE.md index 467229b..8d37246 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,9 @@ collections with synchronization, filtering, and sorting. ## Package Info -- **Package name**: `tech.aspid.collections` (`package.json`, v1.0.1) -- **Unity**: 2022.3+ +- **Package name**: `tech.aspid.collections` (`package.json`, v1.0.2) +- **Unity**: `package.json` declares `2021.3` as the manifest minimum; the + active development target is 2022.3+ (matches the parent MVVM project). - **Engine dependency**: none — `Aspid.Collections.Observable.asmdef` sets `noEngineReferences: true`. Runtime is pure C# and must stay that way so the package works in non-engine assemblies too. @@ -29,12 +30,16 @@ Collections/ │ ├── INotifyCollectionChangedEventArgs.cs │ ├── NotifyCollectionChangedEventHandler.cs │ ├── CollectionChangedEvent.cs -│ ├── Events/ # IObservableEvents + SplitByEvents +│ ├── Events/ # IObservableEvents, ObservableCollectionEvents +│ │ └── Extensions/ # SplitEventsExtensions │ ├── Extensions/ # ObservableListExtensions -│ ├── Filtered/ # FilteredList + CreateFiltered -│ └── Synchronizer/ # Observable*Sync + CreateSync +│ ├── Filtered/ # FilteredList, IReadOnlyFilteredList +│ │ └── Extensions/ # CreateFilteredExtensions +│ └── Synchronizer/ # Observable*Sync, IReadOnly*Sync +│ └── Extensions/ # CreateSyncExtensions └── Tests/Observable/ # EditMode tests (UTF) - └── Helpers/ + ├── Helpers/ + └── Performance/ # Optional perf benchmarks (gated) ``` ## Namespaces @@ -50,6 +55,7 @@ Collections/ |--------|---------| | `Aspid.Collections.Observable.asmdef` | Runtime (`noEngineReferences: true`) | | `Aspid.Collections.Observable.Tests.asmdef` | Unity Test Framework tests | +| `Aspid.Collections.Observable.PerformanceTests.asmdef` | Perf benchmarks; compiled only when both `UNITY_INCLUDE_TESTS` and `ASPID_COLLECTIONS_PERFORMANCE_TESTING` are defined (the latter is auto-set when `com.unity.test-framework.performance` is installed) | ## Testing @@ -83,11 +89,23 @@ Tests live in `Tests/Observable/` and run via Unity Test Runner `CreateFiltered`. Each wrapper holds a subscription on its source; if you drop the reference without calling `Dispose()`, the source keeps the chain alive. +- **FilteredList is single-thread.** Source collections are guarded by + `SyncRoot`, but `FilteredList` itself is not — its internal index map + is mutated in place from `OnCollectionChanged`. Build / read / + enumerate it from one thread (the same one that mutates the source). +- **Performance tests are off by default.** `Tests/Observable/Performance/` + only compiles when `com.unity.test-framework.performance` is installed — + the asmdef sets `ASPID_COLLECTIONS_PERFORMANCE_TESTING` via + `versionDefines`. If you add benchmarks, they will silently be skipped + in projects without that package. ## Pointers - `README.md` / `README_RU.md` — full public API reference with examples (collections, events, sync, filter, sample patterns). +- `CHANGELOG.md` — release notes; the **Unreleased** block lists in-flight + work (incremental FilteredList dispatch, batch propagation in Sync + wrappers, re-entrancy safety in `CollectionChangedEvent`). - Parent framework CLAUDE.md: `../../../../CLAUDE.md` (relative to this file, resolves to `Projects/Aspid.MVVM/CLAUDE.md`) — Aspid.MVVM-wide context and conventions. diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..2e9d6b2 --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0fb6c748b30904d4697fc2516bfeb7c1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Resources.meta b/Editor/Resources.meta new file mode 100644 index 0000000..d43b9c2 --- /dev/null +++ b/Editor/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cbca27ab04dc44fbebd6d5c8f2a76a44 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Resources/Icons.meta b/Editor/Resources/Icons.meta new file mode 100644 index 0000000..e20156c --- /dev/null +++ b/Editor/Resources/Icons.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 464e12a302d964aef881b774b1df782b +timeCreated: 1777461222 \ No newline at end of file diff --git a/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png b/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png new file mode 100644 index 0000000..c137575 Binary files /dev/null and b/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png differ diff --git a/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png.meta b/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png.meta new file mode 100644 index 0000000..fb71419 --- /dev/null +++ b/Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png.meta @@ -0,0 +1,143 @@ +fileFormatVersion: 2 +guid: c602f5f36a5ff4b97aaa3917dabae411 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 2 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Observable/CollectionChangedEvent.cs b/Runtime/Observable/CollectionChangedEvent.cs index 08758e9..deb0eff 100644 --- a/Runtime/Observable/CollectionChangedEvent.cs +++ b/Runtime/Observable/CollectionChangedEvent.cs @@ -6,30 +6,98 @@ namespace Aspid.Collections.Observable { public abstract class CollectionChangedEvent : IDisposable { + // We store handlers in a List instead of combining them via Delegate.Combine because + // subscribers with different but variance-compatible T parameters cannot be combined by + // the runtime — each handler must be kept and invoked independently. private List>? _handlers; - + + // Bumped while Invoke iterates _handlers; add/remove fork the list (copy-on-write) only + // when this is non-zero, so the in-flight foreach iterates an undisturbed list. + private int _invokeDepth; + + // Handlers unsubscribed while any Invoke is on the stack. Populated lazily on the first + // removal during an invoke, cleared when _invokeDepth returns to zero. Lookup is O(1). + private HashSet>? _removedDuringInvoke; + public event NotifyCollectionChangedEventHandler? CollectionChanged { add { - _handlers ??= new List>(); - _handlers.Add(value ?? throw ThrowValueNullReferenceException()); + var v = value ?? throw ThrowValueNullReferenceException(); + + if (_invokeDepth > 0) + { + _handlers = _handlers is null + ? new List> { v } + : new List>(_handlers) { v }; + } + else + { + _handlers ??= new List>(); + _handlers.Add(v); + } + } + remove + { + var v = value ?? throw ThrowValueNullReferenceException(); + if (_handlers is null) return; + + if (_invokeDepth > 0) + { + var forked = new List>(_handlers); + + if (forked.Remove(v)) + { + (_removedDuringInvoke ??= new HashSet>()).Add(v); + _handlers = forked; + } + } + else + { + _handlers.Remove(v); + } } - remove => _handlers?.Remove(value ?? throw ThrowValueNullReferenceException()); } protected void Invoke(INotifyCollectionChangedEventArgs e) { - if (_handlers is null) return; - - foreach (var handler in _handlers) - handler.Invoke(e); + var handlers = _handlers; + if (handlers is null) return; + + var count = handlers.Count; + if (count is 0) return; + + if (count is 1) + { + // Single subscriber: nothing to iterate after the call, so the handler is free + // to mutate _handlers in place (no fork needed, no depth tracking). + handlers[0].Invoke(e); + return; + } + + _invokeDepth++; + + try + { + foreach (var handler in handlers) + { + // Skip handlers that were unsubscribed at any point during this invoke chain. + if (_removedDuringInvoke is not null && _removedDuringInvoke.Contains(handler)) + continue; + + handler.Invoke(e); + } + } + finally + { + if (--_invokeDepth == 0) _removedDuringInvoke = null; + } } private static NullReferenceException ThrowValueNullReferenceException() => throw new NullReferenceException("value"); public virtual void Dispose() => - _handlers?.Clear(); + _handlers = null; } -} \ No newline at end of file +} diff --git a/Runtime/Observable/CollectionChangedEvent.cs.meta b/Runtime/Observable/CollectionChangedEvent.cs.meta index 9c10c98..2d93100 100644 --- a/Runtime/Observable/CollectionChangedEvent.cs.meta +++ b/Runtime/Observable/CollectionChangedEvent.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Events/Extensions/SplitEventsExtensions.cs.meta b/Runtime/Observable/Events/Extensions/SplitEventsExtensions.cs.meta index 34bce74..fb609f4 100644 --- a/Runtime/Observable/Events/Extensions/SplitEventsExtensions.cs.meta +++ b/Runtime/Observable/Events/Extensions/SplitEventsExtensions.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Events/IObservableEvents.cs.meta b/Runtime/Observable/Events/IObservableEvents.cs.meta index dad2abc..3018662 100644 --- a/Runtime/Observable/Events/IObservableEvents.cs.meta +++ b/Runtime/Observable/Events/IObservableEvents.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Events/ObservableCollectionEvents.cs.meta b/Runtime/Observable/Events/ObservableCollectionEvents.cs.meta index 83364d9..6989d6a 100644 --- a/Runtime/Observable/Events/ObservableCollectionEvents.cs.meta +++ b/Runtime/Observable/Events/ObservableCollectionEvents.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Extensions/ObservableListExtensions.cs.meta b/Runtime/Observable/Extensions/ObservableListExtensions.cs.meta index 461d5c2..7a28bb9 100644 --- a/Runtime/Observable/Extensions/ObservableListExtensions.cs.meta +++ b/Runtime/Observable/Extensions/ObservableListExtensions.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Filtered/Extensions/CreateFilteredExtensions.cs.meta b/Runtime/Observable/Filtered/Extensions/CreateFilteredExtensions.cs.meta index 4f9adbd..2a9d4ca 100644 --- a/Runtime/Observable/Filtered/Extensions/CreateFilteredExtensions.cs.meta +++ b/Runtime/Observable/Filtered/Extensions/CreateFilteredExtensions.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Filtered/FilteredList.cs b/Runtime/Observable/Filtered/FilteredList.cs index d60152e..6b3e457 100644 --- a/Runtime/Observable/Filtered/FilteredList.cs +++ b/Runtime/Observable/Filtered/FilteredList.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; // ReSharper disable once CheckNamespace namespace Aspid.Collections.Observable.Filtered @@ -9,12 +10,12 @@ namespace Aspid.Collections.Observable.Filtered public sealed class FilteredList : IReadOnlyFilteredList, IDisposable { public event Action? CollectionChanged; - - private int[]? _indexes; + + private List? _indexes; private Predicate? _filter; private IComparer? _comparer; private readonly IReadOnlyList _list; - + public Predicate? Filter { get => _filter; @@ -24,7 +25,7 @@ public Predicate? Filter Update(); } } - + public IComparer? Comparer { get => _comparer; @@ -34,66 +35,72 @@ public IComparer? Comparer Update(); } } - + public int Count { get; private set; } - - public T this[int index] => _indexes is null - ? _list[index] + + public T this[int index] => _indexes is null + ? _list[index] : _list[_indexes[index]]; public FilteredList(IReadOnlyList list) { _list = list; Count = _list.Count; - - switch (list) - { - case IReadOnlyFilteredList filteredList: filteredList.CollectionChanged += Update; break; - case IReadOnlyObservableList observableList: observableList.CollectionChanged += OnCollectionChanged; break; - } + SubscribeToSource(); } - + public FilteredList(IReadOnlyList list, IComparer? comparer, Predicate? filter = null) : this(list, filter, comparer) { } public FilteredList(IReadOnlyList list, Predicate? filter, IComparer? comparer = null) - : this(list) { + _list = list; _filter = filter; _comparer = comparer; + + SubscribeToSource(); Update(); } - + + private void SubscribeToSource() + { + switch (_list) + { + case IReadOnlyFilteredList filteredList: filteredList.CollectionChanged += Update; break; + case IReadOnlyObservableList observableList: observableList.CollectionChanged += OnCollectionChanged; break; + } + } + public void Update() { - if (Comparer is not null && Filter is not null) + if (_comparer is not null && _filter is not null) { _indexes = Enumerable.Range(0, _list.Count) - .Where(i => Filter(_list[i])) - .OrderBy(i => _list[i], Comparer) - .ToArray(); + .Where(index => _filter(_list[index])) + .OrderBy(index => _list[index], _comparer) + .ToList(); } - else if (Comparer is not null) + else if (_comparer is not null) { _indexes = Enumerable.Range(0, _list.Count) - .OrderBy(i => _list[i], Comparer) - .ToArray(); + .OrderBy(index => _list[index], _comparer) + .ToList(); } - else if (Filter is not null) + else if (_filter is not null) { _indexes = Enumerable.Range(0, _list.Count) - .Where(i => Filter(_list[i])) - .ToArray(); + .Where(index => _filter(_list[index])) + .ToList(); } else { _indexes = null; } - Count = _indexes?.Length ?? _list.Count; + Count = _indexes?.Count ?? _list.Count; CollectionChanged?.Invoke(); } - + public IEnumerator GetEnumerator() { if (_indexes is not null) @@ -110,9 +117,273 @@ public IEnumerator GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - private void OnCollectionChanged(INotifyCollectionChangedEventArgs e) => - Update(); + + private void OnCollectionChanged(INotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + if (args.IsSingleItem) ApplyAddSingle(args.NewStartingIndex, args.NewItem!); + else ApplyAddBatch(args.NewStartingIndex, args.NewItems!); + break; + + case NotifyCollectionChangedAction.Remove: + if (args.IsSingleItem) ApplyRemoveSingle(args.OldStartingIndex); + else ApplyRemoveBatch(args.OldStartingIndex, args.OldItems!.Count); + break; + + case NotifyCollectionChangedAction.Replace: + if (args.IsSingleItem) + { + ApplyReplaceSingle(args.NewStartingIndex, args.NewItem!); + } + else + { + Update(); + return; + } + break; + + case NotifyCollectionChangedAction.Move: + if (args.IsSingleItem) + { + ApplyMoveSingle(args.OldStartingIndex, args.NewStartingIndex); + } + else + { + Update(); + return; + } + break; + + case NotifyCollectionChangedAction.Reset: + default: Update(); return; + } + + Count = _indexes?.Count ?? _list.Count; + CollectionChanged?.Invoke(); + } + + #region Apply Add + private void ApplyAddSingle(int sourceIndex, T item) + { + if (_indexes is null) return; + + ShiftIndexesGreaterOrEqual( + threshold: sourceIndex, + delta: 1); + + if (_filter is not null && !_filter(item)) return; + + var index = _comparer is not null + ? BinarySearchInsertByValue(sourceIndex) + : BinarySearchInsertBySource(sourceIndex); + + _indexes.Insert(index, sourceIndex); + } + + private void ApplyAddBatch(int startIndex, IReadOnlyList items) + { + if (_indexes is null) return; + + var delta = items.Count; + ShiftIndexesGreaterOrEqual( + threshold: startIndex, + delta: delta); + + for (var offset = 0; offset < delta; offset++) + { + var sourceIndex = startIndex + offset; + var item = items[offset]; + if (_filter is not null && !_filter(item)) continue; + + var index = _comparer is not null + ? BinarySearchInsertByValue(sourceIndex) + : BinarySearchInsertBySource(sourceIndex); + + _indexes.Insert(index, sourceIndex); + } + } + #endregion + + private void ApplyRemoveSingle(int sourceIndex) + { + if (_indexes is null) return; + + var position = FindViewPosition(sourceIndex); + if (position >= 0) _indexes.RemoveAt(position); + ShiftIndexesGreaterThan(sourceIndex, -1); + } + + private void ApplyRemoveBatch(int startIndex, int count) + { + if (_indexes is null) return; + + for (var sourceIndex = startIndex + count - 1; sourceIndex >= startIndex; sourceIndex--) + { + var position = FindViewPosition(sourceIndex); + if (position >= 0) _indexes.RemoveAt(position); + } + ShiftIndexesGreaterThan(startIndex + count - 1, -count); + } + + private void ApplyReplaceSingle(int sourceIndex, T newItem) + { + if (_indexes is null) return; + + var oldPosition = FindViewPosition(sourceIndex); + var wasIncluded = oldPosition >= 0; + var nowIncluded = _filter is null || _filter(newItem); + + if (!wasIncluded && !nowIncluded) return; + + if (wasIncluded && !nowIncluded) + { + _indexes.RemoveAt(oldPosition); + return; + } + + if (!wasIncluded) + { + var position = _comparer is not null + ? BinarySearchInsertByValue(sourceIndex) + : BinarySearchInsertBySource(sourceIndex); + _indexes.Insert(position, sourceIndex); + return; + } + + // wasIncluded && nowIncluded — reseat only when sort key may have changed. + if (_comparer is null) return; + + _indexes.RemoveAt(oldPosition); + var insertPosition = BinarySearchInsertByValue(sourceIndex); + _indexes.Insert(insertPosition, sourceIndex); + } + + private void ApplyMoveSingle(int oldIndex, int newIndex) + { + if (_indexes is null) return; + if (oldIndex == newIndex) return; + + // Remap source indices stored in _indexes to reflect the source rearrangement. + if (oldIndex < newIndex) + { + for (var index = 0; index < _indexes.Count; index++) + { + var value = _indexes[index]; + if (value == oldIndex) _indexes[index] = newIndex; + else if (value > oldIndex && value <= newIndex) _indexes[index] = value - 1; + } + } + else + { + for (var index = 0; index < _indexes.Count; index++) + { + var value = _indexes[index]; + if (value == oldIndex) _indexes[index] = newIndex; + else if (value >= newIndex && value < oldIndex) _indexes[index] = value + 1; + } + } + + // Reseat the moved entry. Without Comparer _indexes must stay sorted ascending by + // source index. With Comparer the comparer-equal tie-break is by source index, so a + // Move can change the tie-break order even though the value didn't change — reseat to + // match a fresh Update() rebuild. + var position = _indexes.IndexOf(newIndex); + if (position < 0) return; + + _indexes.RemoveAt(position); + var insertPosition = _comparer is not null + ? BinarySearchInsertByValue(newIndex) + : BinarySearchInsertBySource(newIndex); + _indexes.Insert(insertPosition, newIndex); + } + + #region Shift Indexes + private void ShiftIndexesGreaterOrEqual(int threshold, int delta) + { + for (var index = 0; index < _indexes?.Count; index++) + { + if (_indexes[index] >= threshold) + _indexes[index] += delta; + } + } + + private void ShiftIndexesGreaterThan(int threshold, int delta) + { + for (var index = 0; index < _indexes?.Count; index++) + { + if (_indexes[index] > threshold) + _indexes[index] += delta; + } + } + #endregion + + // Stable: ties by Comparer tie-break on ascending source index, matching Enumerable.OrderBy. + private int BinarySearchInsertByValue(int sourceIndex) + { + var item = _list[sourceIndex]; + int low = 0, high = _indexes!.Count; + + while (low < high) + { + var middle = (low + high) >> 1; + var middleSourceIndex = _indexes[middle]; + var comparison = _comparer!.Compare(_list[middleSourceIndex], item); + if (comparison < 0 || (comparison == 0 && middleSourceIndex < sourceIndex)) low = middle + 1; + else high = middle; + } + + return low; + } + + private int BinarySearchInsertBySource(int sourceIndex) + { + int low = 0, high = _indexes!.Count; + + while (low < high) + { + var middle = (low + high) >> 1; + + if (_indexes[middle] < sourceIndex) low = middle + 1; + else high = middle; + } + + return low; + } + + // Returns view position of sourceIndex, or -1 if not present. + // Uses only source-index lookups, so it is safe to call after the source mutated _list[sourceIndex]. + private int FindViewPosition(int sourceIndex) + { + if (_indexes is null) return -1; + + if (_comparer is null) + { + var low = 0; + var high = _indexes.Count; + + while (low < high) + { + var middle = (low + high) >> 1; + var current = _indexes[middle]; + + if (current < sourceIndex) low = middle + 1; + else if (current > sourceIndex) high = middle; + else return middle; + } + + return -1; + } + + for (var index = 0; index < _indexes.Count; index++) + { + if (_indexes[index] == sourceIndex) + return index; + } + + return -1; + } public void Dispose() { @@ -121,8 +392,8 @@ public void Dispose() case IReadOnlyFilteredList filteredList: filteredList.CollectionChanged -= Update; break; case IReadOnlyObservableList observableList: observableList.CollectionChanged -= OnCollectionChanged; break; } - + CollectionChanged = null; } } -} \ No newline at end of file +} diff --git a/Runtime/Observable/Filtered/FilteredList.cs.meta b/Runtime/Observable/Filtered/FilteredList.cs.meta index 85f2189..a9a5e97 100644 --- a/Runtime/Observable/Filtered/FilteredList.cs.meta +++ b/Runtime/Observable/Filtered/FilteredList.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Filtered/IReadOnlyFilteredList.cs.meta b/Runtime/Observable/Filtered/IReadOnlyFilteredList.cs.meta index 7a9cd56..f88d6b0 100644 --- a/Runtime/Observable/Filtered/IReadOnlyFilteredList.cs.meta +++ b/Runtime/Observable/Filtered/IReadOnlyFilteredList.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/INotifyCollectionChangedEventArgs.cs.meta b/Runtime/Observable/INotifyCollectionChangedEventArgs.cs.meta index d0124d4..580e550 100644 --- a/Runtime/Observable/INotifyCollectionChangedEventArgs.cs.meta +++ b/Runtime/Observable/INotifyCollectionChangedEventArgs.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/IObservableCollection.cs.meta b/Runtime/Observable/IObservableCollection.cs.meta index cab54ba..5734a4d 100644 --- a/Runtime/Observable/IObservableCollection.cs.meta +++ b/Runtime/Observable/IObservableCollection.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/IReadOnlyObservableDictionary.cs.meta b/Runtime/Observable/IReadOnlyObservableDictionary.cs.meta index 3248eaf..60b53e8 100644 --- a/Runtime/Observable/IReadOnlyObservableDictionary.cs.meta +++ b/Runtime/Observable/IReadOnlyObservableDictionary.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/IReadOnlyObservableList.cs.meta b/Runtime/Observable/IReadOnlyObservableList.cs.meta index 2d6995e..7cf60d7 100644 --- a/Runtime/Observable/IReadOnlyObservableList.cs.meta +++ b/Runtime/Observable/IReadOnlyObservableList.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/NotifyCollectionChangedEventArgs.cs.meta b/Runtime/Observable/NotifyCollectionChangedEventArgs.cs.meta index 7a4df4d..023b872 100644 --- a/Runtime/Observable/NotifyCollectionChangedEventArgs.cs.meta +++ b/Runtime/Observable/NotifyCollectionChangedEventArgs.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/NotifyCollectionChangedEventHandler.cs.meta b/Runtime/Observable/NotifyCollectionChangedEventHandler.cs.meta index f507f3c..14eb462 100644 --- a/Runtime/Observable/NotifyCollectionChangedEventHandler.cs.meta +++ b/Runtime/Observable/NotifyCollectionChangedEventHandler.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/ObservableDictionary.cs.meta b/Runtime/Observable/ObservableDictionary.cs.meta index 8d76c11..878080e 100644 --- a/Runtime/Observable/ObservableDictionary.cs.meta +++ b/Runtime/Observable/ObservableDictionary.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: 0d0d85abe18854107bff963db8208bbc, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/ObservableHashSet.cs.meta b/Runtime/Observable/ObservableHashSet.cs.meta index bc6800c..6341688 100644 --- a/Runtime/Observable/ObservableHashSet.cs.meta +++ b/Runtime/Observable/ObservableHashSet.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/ObservableList.cs b/Runtime/Observable/ObservableList.cs index 044d95c..f411f22 100644 --- a/Runtime/Observable/ObservableList.cs +++ b/Runtime/Observable/ObservableList.cs @@ -158,16 +158,36 @@ public void RemoveAt(int index) lock (SyncRoot) { var item = _list[index]; - + _list.RemoveAt(index); OnRemoved(item); - + Invoke(NotifyCollectionChangedEventArgs.Remove(item, index)); } } - + + public void RemoveRange(int startIndex, int count) + { + if (startIndex < 0) throw new ArgumentOutOfRangeException(nameof(startIndex)); + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + + lock (SyncRoot) + { + var items = new T[count]; + for (var i = 0; i < count; i++) + items[i] = _list[startIndex + i]; + + _list.RemoveRange(startIndex, count); + OnRemovedRange(items); + + Invoke(NotifyCollectionChangedEventArgs.Remove(items, startIndex)); + } + } + protected virtual void OnRemoved(in T item) { } + protected virtual void OnRemovedRange(in IReadOnlyList items) { } + public void Move(int oldIndex, int newIndex) { lock (SyncRoot) diff --git a/Runtime/Observable/ObservableList.cs.meta b/Runtime/Observable/ObservableList.cs.meta index 7a74351..52d26b1 100644 --- a/Runtime/Observable/ObservableList.cs.meta +++ b/Runtime/Observable/ObservableList.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/ObservableQueue.cs.meta b/Runtime/Observable/ObservableQueue.cs.meta index ba29cc5..a68f880 100644 --- a/Runtime/Observable/ObservableQueue.cs.meta +++ b/Runtime/Observable/ObservableQueue.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/ObservableStack.cs.meta b/Runtime/Observable/ObservableStack.cs.meta index 88e81c0..114c9f3 100644 --- a/Runtime/Observable/ObservableStack.cs.meta +++ b/Runtime/Observable/ObservableStack.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/Extensions/CreateSyncExtensions.cs.meta b/Runtime/Observable/Synchronizer/Extensions/CreateSyncExtensions.cs.meta index b88f9a2..4ed4dff 100644 --- a/Runtime/Observable/Synchronizer/Extensions/CreateSyncExtensions.cs.meta +++ b/Runtime/Observable/Synchronizer/Extensions/CreateSyncExtensions.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/IReadOnlyObservableCollectionSync.cs.meta b/Runtime/Observable/Synchronizer/IReadOnlyObservableCollectionSync.cs.meta index 59952ae..326fc57 100644 --- a/Runtime/Observable/Synchronizer/IReadOnlyObservableCollectionSync.cs.meta +++ b/Runtime/Observable/Synchronizer/IReadOnlyObservableCollectionSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/IReadOnlyObservableDictionarySync.cs.meta b/Runtime/Observable/Synchronizer/IReadOnlyObservableDictionarySync.cs.meta index 9a5d621..466ef9a 100644 --- a/Runtime/Observable/Synchronizer/IReadOnlyObservableDictionarySync.cs.meta +++ b/Runtime/Observable/Synchronizer/IReadOnlyObservableDictionarySync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/IReadOnlyObservableListSync.cs.meta b/Runtime/Observable/Synchronizer/IReadOnlyObservableListSync.cs.meta index 7b09ecf..7b1b671 100644 --- a/Runtime/Observable/Synchronizer/IReadOnlyObservableListSync.cs.meta +++ b/Runtime/Observable/Synchronizer/IReadOnlyObservableListSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs b/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs index 635ea4f..5845444 100644 --- a/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs +++ b/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs @@ -52,22 +52,43 @@ private void OnFromListChanged(INotifyCollectionChangedEventArgs oldItem, in KeyValuePair newItem) => - OnRemoved(oldItem.Key, newItem.Value); + OnRemoved(oldItem.Key, oldItem.Value); protected override void OnClearing() { diff --git a/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs.meta b/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs.meta index 917acfe..02e64d8 100644 --- a/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs.meta +++ b/Runtime/Observable/Synchronizer/ObservableDictionarySync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs b/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs index 16917e1..6a42d6c 100644 --- a/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs +++ b/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs @@ -26,12 +26,7 @@ public ObservableHashSetSync( _sync = new Dictionary(fromHashSet.Count); foreach (var from in fromHashSet) - { - var to = Convert(from); - - Add(to); - _sync.Add(from,to ); - } + AddOne(from); Subscribe(); } @@ -62,24 +57,27 @@ private void OnFromStackChanged(INotifyCollectionChangedEventArgs args) { if (args.IsSingleItem) { - var fromItem = args.NewItem!; - var item = Convert(fromItem); - - Add(item); - _sync.Add(fromItem, item); + AddOne(args.NewItem!); + } + else + { + foreach (var fromItem in args.NewItems!) + AddOne(fromItem); } - else throw new NotImplementedException(); } break; - + case NotifyCollectionChangedAction.Remove: { if (args.IsSingleItem) { - Remove(_sync[args.OldItem!]); - _sync.Remove(args.OldItem!); + RemoveOne(args.OldItem!); + } + else + { + foreach (var fromItem in args.OldItems!) + RemoveOne(fromItem); } - else throw new NotImplementedException(); } break; @@ -92,11 +90,27 @@ private void OnFromStackChanged(INotifyCollectionChangedEventArgs args) case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); - + throw new NotSupportedException("Move/Replace are not supported on ObservableHashSet."); + default: throw new ArgumentOutOfRangeException(); } } + + private void AddOne(TFrom fromItem) + { + var item = Convert(fromItem); + if (!Add(item)) + throw new InvalidOperationException( + $"Converter must be injective: produced duplicate destination value '{item}' for source item '{fromItem}'."); + _sync.Add(fromItem, item); + } + + private void RemoveOne(TFrom fromItem) + { + if (!_sync.TryGetValue(fromItem, out var item)) return; + Remove(item); + _sync.Remove(fromItem); + } protected override void OnRemoved(TTo item) { diff --git a/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs.meta b/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs.meta index b373847..712e48b 100644 --- a/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs.meta +++ b/Runtime/Observable/Synchronizer/ObservableHashSetSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/ObservableListSync.cs b/Runtime/Observable/Synchronizer/ObservableListSync.cs index 72a61b6..b2ffe40 100644 --- a/Runtime/Observable/Synchronizer/ObservableListSync.cs +++ b/Runtime/Observable/Synchronizer/ObservableListSync.cs @@ -69,21 +69,21 @@ private void OnFromListChanged(INotifyCollectionChangedEventArgs args) case NotifyCollectionChangedAction.Move: { if (args.IsSingleItem) Move(args.OldStartingIndex, args.NewStartingIndex); - else throw new NotImplementedException(); + else throw new NotSupportedException("Batch Move is not emitted by ObservableList."); } break; - + case NotifyCollectionChangedAction.Remove: { if (args.IsSingleItem) RemoveAt(args.OldStartingIndex); - else throw new NotImplementedException(); + else RemoveRange(args.OldStartingIndex, args.OldItems!.Count); } break; - + case NotifyCollectionChangedAction.Replace: { if (args.IsSingleItem) base[args.OldStartingIndex] = Convert(args.NewItem!); - else throw new NotImplementedException(); + else throw new NotSupportedException("Batch Replace is not emitted by ObservableList."); } break; @@ -110,6 +110,23 @@ protected override void OnRemoved(in TTo value) else _remove?.Invoke(value); } + protected override void OnRemovedRange(in IReadOnlyList items) + { + if (_isDisposable) + { + foreach (var item in items) + { + if (item is IDisposable disposable) + disposable.Dispose(); + } + } + else if (_remove is not null) + { + foreach (var item in items) + _remove.Invoke(item); + } + } + protected override void OnClearing() { if (_isDisposable) diff --git a/Runtime/Observable/Synchronizer/ObservableListSync.cs.meta b/Runtime/Observable/Synchronizer/ObservableListSync.cs.meta index 7881d13..3911838 100644 --- a/Runtime/Observable/Synchronizer/ObservableListSync.cs.meta +++ b/Runtime/Observable/Synchronizer/ObservableListSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/ObservableQueueSync.cs b/Runtime/Observable/Synchronizer/ObservableQueueSync.cs index 5aebaf6..d6f3f98 100644 --- a/Runtime/Observable/Synchronizer/ObservableQueueSync.cs +++ b/Runtime/Observable/Synchronizer/ObservableQueueSync.cs @@ -81,8 +81,8 @@ private void OnFromQueueChanged(INotifyCollectionChangedEventArgs args) case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); - + throw new NotSupportedException("Move/Replace are not supported on ObservableQueue."); + default: throw new ArgumentOutOfRangeException(); } } @@ -98,9 +98,9 @@ protected override void OnDequeued(TTo item) } protected override void OnDequeuedRange(in IReadOnlyList dest) => - OnDequeuedRange(dest); - - private void OnDequeuedRange(IReadOnlyCollection dest) + HandleDequeued(dest); + + private void HandleDequeued(IReadOnlyCollection dest) { if (_isDisposable) { @@ -118,7 +118,7 @@ private void OnDequeuedRange(IReadOnlyCollection dest) } protected override void OnClearing() => - OnDequeuedRange(this); + HandleDequeued(this); public override void Dispose() { diff --git a/Runtime/Observable/Synchronizer/ObservableQueueSync.cs.meta b/Runtime/Observable/Synchronizer/ObservableQueueSync.cs.meta index 85541f9..fb65b84 100644 --- a/Runtime/Observable/Synchronizer/ObservableQueueSync.cs.meta +++ b/Runtime/Observable/Synchronizer/ObservableQueueSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/Observable/Synchronizer/ObservableStackSync.cs b/Runtime/Observable/Synchronizer/ObservableStackSync.cs index 70d83a9..13c4c6b 100644 --- a/Runtime/Observable/Synchronizer/ObservableStackSync.cs +++ b/Runtime/Observable/Synchronizer/ObservableStackSync.cs @@ -81,8 +81,8 @@ private void OnFromStackChanged(INotifyCollectionChangedEventArgs args) case NotifyCollectionChangedAction.Move: case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); - + throw new NotSupportedException("Move/Replace are not supported on ObservableStack."); + default: throw new ArgumentOutOfRangeException(); } } @@ -98,15 +98,15 @@ protected override void OnPopped(TTo item) } protected override void OnPoppedRange(in IReadOnlyList dest) => - OnPoppedRange(dest); - - private void OnPoppedRange(IReadOnlyCollection dest) + HandlePopped(dest); + + private void HandlePopped(IReadOnlyCollection dest) { if (_isDisposable) { foreach (var item in dest) { - if (item is IDisposable disposable) + if (item is IDisposable disposable) disposable.Dispose(); } } @@ -118,7 +118,7 @@ private void OnPoppedRange(IReadOnlyCollection dest) } protected override void OnClearing() => - OnPoppedRange(this); + HandlePopped(this); public override void Dispose() { diff --git a/Runtime/Observable/Synchronizer/ObservableStackSync.cs.meta b/Runtime/Observable/Synchronizer/ObservableStackSync.cs.meta index 7f87efd..fe78431 100644 --- a/Runtime/Observable/Synchronizer/ObservableStackSync.cs.meta +++ b/Runtime/Observable/Synchronizer/ObservableStackSync.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateFilteredExtensionsTests.cs.meta b/Tests/Observable/CreateFilteredExtensionsTests.cs.meta index 76adcf8..57a65e8 100644 --- a/Tests/Observable/CreateFilteredExtensionsTests.cs.meta +++ b/Tests/Observable/CreateFilteredExtensionsTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateSyncDictionaryTests.cs.meta b/Tests/Observable/CreateSyncDictionaryTests.cs.meta index 089fbbc..6f815c3 100644 --- a/Tests/Observable/CreateSyncDictionaryTests.cs.meta +++ b/Tests/Observable/CreateSyncDictionaryTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateSyncHashSetTests.cs.meta b/Tests/Observable/CreateSyncHashSetTests.cs.meta index 7b7b702..fffa89e 100644 --- a/Tests/Observable/CreateSyncHashSetTests.cs.meta +++ b/Tests/Observable/CreateSyncHashSetTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateSyncListTests.cs b/Tests/Observable/CreateSyncListTests.cs index 3c508cc..9c7556e 100644 --- a/Tests/Observable/CreateSyncListTests.cs +++ b/Tests/Observable/CreateSyncListTests.cs @@ -75,6 +75,21 @@ public void SourceInsert_PropagatesAtCorrectIndex() Assert.AreEqual("2", _sync[1]); } + [Test] + public void SourceInsertRange_PropagatesAllAtCorrectIndex() + { + _source.AddRange(1, 5); + + _source.InsertRange(1, 2, 3, 4); + + Assert.AreEqual(5, _sync.Count); + Assert.AreEqual("1", _sync[0]); + Assert.AreEqual("2", _sync[1]); + Assert.AreEqual("3", _sync[2]); + Assert.AreEqual("4", _sync[3]); + Assert.AreEqual("5", _sync[4]); + } + [Test] public void SourceRemove_RemovesFromSync() { @@ -87,6 +102,32 @@ public void SourceRemove_RemovesFromSync() Assert.AreEqual("30", _sync[1]); } + [Test] + public void SourceRemoveRange_PropagatesAsBatchRemove() + { + _source.AddRange(1, 2, 3, 4, 5); + + _source.RemoveRange(1, 3); + + Assert.AreEqual(2, _sync.Count); + Assert.AreEqual("1", _sync[0]); + Assert.AreEqual("5", _sync[1]); + } + + [Test] + public void SourceRemoveRange_InvokesRemoveCallbackForEachItem() + { + var removed = new List(); + using var syncWithCb = _source.CreateSync(i => i.ToString(), removed.Add); + _source.AddRange(10, 20, 30, 40); + + _source.RemoveRange(1, 2); + + Assert.AreEqual(2, removed.Count); + Assert.AreEqual("20", removed[0]); + Assert.AreEqual("30", removed[1]); + } + [Test] public void SourceMove_MovesInSync() { diff --git a/Tests/Observable/CreateSyncListTests.cs.meta b/Tests/Observable/CreateSyncListTests.cs.meta index ded015c..6709998 100644 --- a/Tests/Observable/CreateSyncListTests.cs.meta +++ b/Tests/Observable/CreateSyncListTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateSyncQueueTests.cs.meta b/Tests/Observable/CreateSyncQueueTests.cs.meta index 91cadd6..9b9e42c 100644 --- a/Tests/Observable/CreateSyncQueueTests.cs.meta +++ b/Tests/Observable/CreateSyncQueueTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/CreateSyncStackTests.cs.meta b/Tests/Observable/CreateSyncStackTests.cs.meta index eb08ae1..00e215b 100644 --- a/Tests/Observable/CreateSyncStackTests.cs.meta +++ b/Tests/Observable/CreateSyncStackTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/FilteredListTests.cs b/Tests/Observable/FilteredListTests.cs index 63ae2c6..91353ad 100644 --- a/Tests/Observable/FilteredListTests.cs +++ b/Tests/Observable/FilteredListTests.cs @@ -1,3 +1,4 @@ +using System; using NUnit.Framework; using System.Collections; using System.Collections.Generic; @@ -229,7 +230,355 @@ public void AutoUpdate_ObservableListClear_ResetsFilteredList() Assert.AreEqual(0, fl.Count); } #endregion - + + #region AutoUpdate Incremental + [Test] + public void AutoUpdate_AddWithComparer_InsertsAtSortedPosition() + { + _source.AddRange(1, 3, 5); + using var fl = new FilteredList(_source, Comparer.Default); + + _source.Add(4); + + Assert.AreEqual(4, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(3, fl[1]); + Assert.AreEqual(4, fl[2]); + Assert.AreEqual(5, fl[3]); + } + + [Test] + public void AutoUpdate_AddWithFilter_FilteredInItemAppearsAtCorrectIndex() + { + _source.AddRange(1, 2, 3); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source.Add(4); + + Assert.AreEqual(2, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(4, fl[1]); + } + + [Test] + public void AutoUpdate_AddWithFilterAndComparer_RespectsBothPredicates() + { + _source.AddRange(5, 1, 4, 2, 3); + using var fl = new FilteredList(_source, i => i > 2, Comparer.Default); + + _source.Add(6); + _source.Add(2); // filtered out + + Assert.AreEqual(4, fl.Count); + Assert.AreEqual(3, fl[0]); + Assert.AreEqual(4, fl[1]); + Assert.AreEqual(5, fl[2]); + Assert.AreEqual(6, fl[3]); + } + + [Test] + public void AutoUpdate_InsertInMiddle_ShiftsLaterIndexes() + { + _source.AddRange(1, 2, 3, 4, 5); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source.Insert(2, 6); + // source = [1, 2, 6, 3, 4, 5]; even = [2, 6, 4] + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(6, fl[1]); + Assert.AreEqual(4, fl[2]); + } + + [Test] + public void AutoUpdate_AddRange_BatchInsert_AllItemsFilteredAndSorted() + { + _source.AddRange(1, 5); + using var fl = new FilteredList(_source, i => i > 0, Comparer.Default); + + _source.AddRange(3, 2, 4); + + Assert.AreEqual(5, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(2, fl[1]); + Assert.AreEqual(3, fl[2]); + Assert.AreEqual(4, fl[3]); + Assert.AreEqual(5, fl[4]); + } + + [Test] + public void AutoUpdate_InsertRange_FiltersAndInserts() + { + _source.AddRange(1, 5); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source.InsertRange(1, 2, 3, 4); + // source = [1, 2, 3, 4, 5]; even = [2, 4] + Assert.AreEqual(2, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(4, fl[1]); + } + + [Test] + public void AutoUpdate_InsertRange_WithComparer_RespectsSortOrder() + { + _source.AddRange(1, 5); + using var fl = new FilteredList(_source, i => i > 0, Comparer.Default); + + _source.InsertRange(1, 4, 2, 3); + // source = [1, 4, 2, 3, 5]; filter keeps all; sorted = [1, 2, 3, 4, 5] + Assert.AreEqual(5, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(2, fl[1]); + Assert.AreEqual(3, fl[2]); + Assert.AreEqual(4, fl[3]); + Assert.AreEqual(5, fl[4]); + } + + [Test] + public void AutoUpdate_RemoveAt_RemovesFromViewAndShiftsTrailing() + { + _source.AddRange(1, 2, 3, 4, 5); + using var fl = new FilteredList(_source, i => i > 1); + + _source.RemoveAt(1); // remove value 2 + // source = [1, 3, 4, 5]; filter > 1 => [3, 4, 5] + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(3, fl[0]); + Assert.AreEqual(4, fl[1]); + Assert.AreEqual(5, fl[2]); + } + + [Test] + public void AutoUpdate_RemoveAt_FilteredOutItem_ViewUnchanged() + { + _source.AddRange(1, 2, 3, 4, 5); + using var fl = new FilteredList(_source, i => i > 1); + + _source.RemoveAt(0); // remove value 1 (filtered out) + + Assert.AreEqual(4, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(3, fl[1]); + Assert.AreEqual(4, fl[2]); + Assert.AreEqual(5, fl[3]); + } + + [Test] + public void AutoUpdate_Remove_BinarySearchPositionCorrect() + { + _source.AddRange(5, 1, 3, 2, 4); + using var fl = new FilteredList(_source, Comparer.Default); + + _source.Remove(3); + // source = [5, 1, 2, 4]; sorted = [1, 2, 4, 5] + Assert.AreEqual(4, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(2, fl[1]); + Assert.AreEqual(4, fl[2]); + Assert.AreEqual(5, fl[3]); + } + + [Test] + public void AutoUpdate_Replace_ItemPassesFilterToFails_RemovedFromView() + { + _source.AddRange(2, 4, 6); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source[1] = 3; // even -> odd + // source = [2, 3, 6]; even = [2, 6] + Assert.AreEqual(2, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(6, fl[1]); + } + + [Test] + public void AutoUpdate_Replace_ItemFailsFilterToPasses_AddedToView() + { + _source.AddRange(1, 3, 5); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source[1] = 4; // odd -> even + // source = [1, 4, 5]; even = [4] + Assert.AreEqual(1, fl.Count); + Assert.AreEqual(4, fl[0]); + } + + [Test] + public void AutoUpdate_Replace_ItemStaysInFilter_ComparerReseats() + { + _source.AddRange(1, 5, 3); + using var fl = new FilteredList(_source, Comparer.Default); + + _source[0] = 4; + // source = [4, 5, 3]; sorted = [3, 4, 5] + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(3, fl[0]); + Assert.AreEqual(4, fl[1]); + Assert.AreEqual(5, fl[2]); + } + + [Test] + public void AutoUpdate_Replace_NoFilterNoComparer_NoOp() + { + _source.AddRange(1, 2, 3); + using var fl = new FilteredList(_source); + + _source[1] = 99; + + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(99, fl[1]); + Assert.AreEqual(3, fl[2]); + } + + [Test] + public void AutoUpdate_Move_NoComparer_PreservesSourceIndexOrder() + { + _source.AddRange(1, 2, 3, 4, 5); + using var fl = new FilteredList(_source, i => i > 1); + + _source.Move(1, 3); // move value 2 from idx 1 to idx 3 + // source = [1, 3, 4, 2, 5]; filter > 1 (in source order) = [3, 4, 2, 5] + Assert.AreEqual(4, fl.Count); + Assert.AreEqual(3, fl[0]); + Assert.AreEqual(4, fl[1]); + Assert.AreEqual(2, fl[2]); + Assert.AreEqual(5, fl[3]); + } + + [Test] + public void AutoUpdate_Move_WithComparer_ViewUnchanged_IndexesRemapped() + { + _source.AddRange(3, 1, 2); + using var fl = new FilteredList(_source, Comparer.Default); + + _source.Move(0, 2); // source becomes [1, 2, 3]; sorted view unchanged + + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(1, fl[0]); + Assert.AreEqual(2, fl[1]); + Assert.AreEqual(3, fl[2]); + } + + [Test] + public void AutoUpdate_Move_FilteredOutItem_ViewUnchanged() + { + _source.AddRange(1, 2, 3, 4); + using var fl = new FilteredList(_source, i => i > 1); + + _source.Move(0, 3); // moves value 1 (filtered out) to the end + // source = [2, 3, 4, 1]; filter > 1 = [2, 3, 4] + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(3, fl[1]); + Assert.AreEqual(4, fl[2]); + } + + [Test] + public void AutoUpdate_SourceRemoveRange_BatchPropagatesIncrementally() + { + _source.AddRange(1, 2, 3, 4, 5, 6); + using var fl = new FilteredList(_source, i => i % 2 == 0); + + _source.RemoveRange(1, 3); + // source = [1, 5, 6]; even = [6] + Assert.AreEqual(1, fl.Count); + Assert.AreEqual(6, fl[0]); + } + + [Test] + public void AutoUpdate_SourceRemoveRange_WithComparer_ViewStillSorted() + { + _source.AddRange(5, 1, 4, 2, 3); + using var fl = new FilteredList(_source, Comparer.Default); + + _source.RemoveRange(1, 2); // removes values 1, 4 + // source = [5, 2, 3]; sorted = [2, 3, 5] + Assert.AreEqual(3, fl.Count); + Assert.AreEqual(2, fl[0]); + Assert.AreEqual(3, fl[1]); + Assert.AreEqual(5, fl[2]); + } + + [Test] + public void AutoUpdate_ParityWithFullRebuild_RandomSequence() + { + // Validates that the incremental dispatcher produces the same view as a fresh + // full-rebuild FilteredList after each random operation, across all four + // (filter? × comparer?) combinations. + var combos = new (Predicate? filter, IComparer? comparer)[] + { + (null, null), + (i => i % 3 != 0, null), + (null, Comparer.Default), + (i => i > 25, Comparer.Default), + }; + + foreach (var (filter, comparer) in combos) + { + var rng = new Random(42); + var src = new ObservableList(); + using var fl = new FilteredList(src, filter, comparer); + + for (var i = 0; i < 10; i++) + src.Add(rng.Next(0, 100)); + + for (var op = 0; op < 200; op++) + { + var action = rng.Next(0, 7); + switch (action) + { + case 0: + src.Add(rng.Next(0, 100)); + break; + case 1: + if (src.Count > 0) src.Insert(rng.Next(0, src.Count), rng.Next(0, 100)); + else src.Add(rng.Next(0, 100)); + break; + case 2: + if (src.Count > 0) src.RemoveAt(rng.Next(0, src.Count)); + break; + case 3: + if (src.Count > 0) src[rng.Next(0, src.Count)] = rng.Next(0, 100); + break; + case 4: + if (src.Count >= 2) + { + var a = rng.Next(src.Count); + var b = rng.Next(src.Count); + src.Move(a, b); + } + break; + case 5: + if (src.Count > 5 && rng.Next(20) == 0) src.Clear(); + break; + case 6: + if (src.Count >= 2) + { + var start = rng.Next(0, src.Count); + var maxCount = src.Count - start; + src.RemoveRange(start, rng.Next(1, maxCount + 1)); + } + break; + } + + using var reference = new FilteredList(src, filter, comparer); + AssertViewEqual(reference, fl, op); + } + + src.Dispose(); + } + } + + private static void AssertViewEqual(FilteredList expected, FilteredList actual, int op) + { + Assert.AreEqual(expected.Count, actual.Count, $"Count mismatch after op {op}"); + for (var i = 0; i < expected.Count; i++) + Assert.AreEqual(expected[i], actual[i], $"Item[{i}] mismatch after op {op}"); + } + #endregion + #region Chaining [Test] public void Chaining_FilteredListSource_UpdatesProperly() @@ -298,34 +647,6 @@ public void GetEnumerator_NoFilterNoComparer_EnumeratesAllSourceItems() } #endregion - #region Count - [Test] - public void Count_AfterFilter_MatchesEnumerationCount() - { - _source.AddRange(1, 2, 3, 4, 5, 6); - using var fl = new FilteredList(_source, i => i % 2 != 0); - - var enumerationCount = 0; - foreach (var _ in fl) - enumerationCount++; - - Assert.AreEqual(fl.Count, enumerationCount); - } - - [Test] - public void Count_AfterFilterAndComparer_MatchesEnumerationCount() - { - _source.AddRange(5, 1, 4, 2, 3); - using var fl = new FilteredList(_source, i => i > 2, Comparer.Default); - - var enumerationCount = 0; - foreach (var _ in fl) - enumerationCount++; - - Assert.AreEqual(fl.Count, enumerationCount); - } - #endregion - [Test] public void EmptySource_CountIsZero() { @@ -343,16 +664,6 @@ public void AllItemsFilteredOut_CountIsZero() Assert.AreEqual(0, fl.Count); } - [Test] - public void NullFilterNullComparer_Passthrough() - { - _source.AddRange(3, 1, 2); - using var fl = new FilteredList(_source); - - Assert.AreEqual(3, fl.Count); - Assert.AreEqual(3, fl[0]); - } - #region Dispose [Test] public void Dispose_ObservableListSource_UnsubscribesFromEvents() diff --git a/Tests/Observable/FilteredListTests.cs.meta b/Tests/Observable/FilteredListTests.cs.meta index deefef5..adaab64 100644 --- a/Tests/Observable/FilteredListTests.cs.meta +++ b/Tests/Observable/FilteredListTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/Helpers/DisposableItem.cs.meta b/Tests/Observable/Helpers/DisposableItem.cs.meta index b89ccc3..0ee89a2 100644 --- a/Tests/Observable/Helpers/DisposableItem.cs.meta +++ b/Tests/Observable/Helpers/DisposableItem.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/Helpers/EventCapture.cs.meta b/Tests/Observable/Helpers/EventCapture.cs.meta index 7d33cea..8ceb664 100644 --- a/Tests/Observable/Helpers/EventCapture.cs.meta +++ b/Tests/Observable/Helpers/EventCapture.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta b/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta index efc310e..34f498a 100644 --- a/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta +++ b/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableDictionaryTests.cs.meta b/Tests/Observable/ObservableDictionaryTests.cs.meta index 1856d97..9f4cb85 100644 --- a/Tests/Observable/ObservableDictionaryTests.cs.meta +++ b/Tests/Observable/ObservableDictionaryTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableHashSetTests.cs.meta b/Tests/Observable/ObservableHashSetTests.cs.meta index ae37360..3e3bc77 100644 --- a/Tests/Observable/ObservableHashSetTests.cs.meta +++ b/Tests/Observable/ObservableHashSetTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableListExtensionsTests.cs.meta b/Tests/Observable/ObservableListExtensionsTests.cs.meta index 3e311ef..55b76d7 100644 --- a/Tests/Observable/ObservableListExtensionsTests.cs.meta +++ b/Tests/Observable/ObservableListExtensionsTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableListTests.cs b/Tests/Observable/ObservableListTests.cs index 2e7960a..7b52963 100644 --- a/Tests/Observable/ObservableListTests.cs +++ b/Tests/Observable/ObservableListTests.cs @@ -242,6 +242,50 @@ public void RemoveAt_RaisesRemoveEventWithCorrectIndex() Assert.AreEqual(3, e.OldItem); Assert.AreEqual(2, e.OldStartingIndex); } + + [Test] + public void RemoveRange_RemovesContiguousItems() + { + _list.AddRange(1, 2, 3, 4, 5); + _events.Clear(); + + _list.RemoveRange(1, 3); + + Assert.AreEqual(2, _list.Count); + Assert.AreEqual(1, _list[0]); + Assert.AreEqual(5, _list[1]); + } + + [Test] + public void RemoveRange_RaisesBatchRemoveEventWithCorrectArgs() + { + _list.AddRange(10, 20, 30, 40); + _events.Clear(); + + _list.RemoveRange(1, 2); + + Assert.AreEqual(1, _events.Count); + var e = _events.Last; + Assert.AreEqual(NotifyCollectionChangedAction.Remove, e.Action); + Assert.IsFalse(e.IsSingleItem); + Assert.AreEqual(2, e.OldItems!.Count); + Assert.AreEqual(20, e.OldItems[0]); + Assert.AreEqual(30, e.OldItems[1]); + Assert.AreEqual(1, e.OldStartingIndex); + } + + [Test] + public void RemoveRange_ZeroCount_RaisesEmptyBatchRemoveEvent() + { + _list.AddRange(1, 2, 3); + _events.Clear(); + + _list.RemoveRange(0, 0); + + Assert.AreEqual(3, _list.Count); + Assert.AreEqual(1, _events.Count); + Assert.AreEqual(0, _events.Last.OldItems!.Count); + } #endregion #region Indexer @@ -453,6 +497,59 @@ public void CollectionChanged_UnsubscribedHandler_NotInvoked() Assert.AreEqual(0, count); } + + [Test] + public void CollectionChanged_HandlerSelfUnsubscribesDuringInvoke_DoesNotThrow() + { + var invoked = 0; + NotifyCollectionChangedEventHandler handler = null!; + handler = _ => + { + invoked++; + _list.CollectionChanged -= handler; + }; + _list.CollectionChanged += handler; + + Assert.DoesNotThrow(() => _list.Add(1)); + Assert.AreEqual(1, invoked); + + _list.Add(2); + Assert.AreEqual(1, invoked); + } + + [Test] + public void CollectionChanged_HandlerSubscribesNewDuringInvoke_NewHandlerSkipsInFlightEvent() + { + var newInvoked = false; + NotifyCollectionChangedEventHandler outer = null!; + outer = _ => + { + _list.CollectionChanged -= outer; + _list.CollectionChanged += __ => newInvoked = true; + }; + _list.CollectionChanged += outer; + + _list.Add(1); + Assert.IsFalse(newInvoked); + + _list.Add(2); + Assert.IsTrue(newInvoked); + } + + [Test] + public void CollectionChanged_HandlerUnsubscribesAnotherDuringInvoke_OtherHandlerNotCalled() + { + var bInvoked = 0; + NotifyCollectionChangedEventHandler handlerB = _ => bInvoked++; + NotifyCollectionChangedEventHandler handlerA = _ => _list.CollectionChanged -= handlerB; + + _list.CollectionChanged += handlerA; + _list.CollectionChanged += handlerB; + + _list.Add(1); + + Assert.AreEqual(0, bInvoked); + } #endregion } } diff --git a/Tests/Observable/ObservableListTests.cs.meta b/Tests/Observable/ObservableListTests.cs.meta index 9c0d3bc..323b3dd 100644 --- a/Tests/Observable/ObservableListTests.cs.meta +++ b/Tests/Observable/ObservableListTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableQueueTests.cs.meta b/Tests/Observable/ObservableQueueTests.cs.meta index 8b0c3a4..fcaf07c 100644 --- a/Tests/Observable/ObservableQueueTests.cs.meta +++ b/Tests/Observable/ObservableQueueTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/ObservableStackTests.cs.meta b/Tests/Observable/ObservableStackTests.cs.meta index 958eecd..0769896 100644 --- a/Tests/Observable/ObservableStackTests.cs.meta +++ b/Tests/Observable/ObservableStackTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Observable/Performance.meta b/Tests/Observable/Performance.meta new file mode 100644 index 0000000..f7c752a --- /dev/null +++ b/Tests/Observable/Performance.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1a8932217a8f2474ba4451c039c7f62f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef b/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef new file mode 100644 index 0000000..1416c3c --- /dev/null +++ b/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef @@ -0,0 +1,32 @@ +{ + "name": "Aspid.Collections.Observable.PerformanceTests", + "rootNamespace": "", + "references": [ + "UnityEditor.TestRunner", + "UnityEngine.TestRunner", + "Unity.PerformanceTesting", + "Aspid.Collections.Observable" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "ASPID_COLLECTIONS_PERFORMANCE_TESTING" + ], + "versionDefines": [ + { + "name": "com.unity.test-framework.performance", + "expression": "", + "define": "ASPID_COLLECTIONS_PERFORMANCE_TESTING" + } + ], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta b/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta new file mode 100644 index 0000000..21626ea --- /dev/null +++ b/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3a080e9a7b3c74292865e4b1298b2431 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Observable/Performance/FilteredListPerformanceTests.cs b/Tests/Observable/Performance/FilteredListPerformanceTests.cs new file mode 100644 index 0000000..3ad6eaa --- /dev/null +++ b/Tests/Observable/Performance/FilteredListPerformanceTests.cs @@ -0,0 +1,256 @@ +using System; +using NUnit.Framework; +using System.Collections.Generic; +using Aspid.Collections.Observable.Filtered; +using Unity.PerformanceTesting; + +// ReSharper disable once CheckNamespace +namespace Aspid.Collections.Observable.Tests.Performance +{ + [TestFixture] + [Category("Performance")] + public sealed class FilteredListPerformanceTests + { + private const int WarmupCount = 3; + private const int MeasurementCount = 10; + + #region Incremental Add Scaling + // Pure incremental Add at increasing N. Tracks how the dispatcher scales on its + // own — the full-rebuild comparison lives in a separate test at smaller N, since + // forcing Update() per Add at N=10_000 pushes a single iteration past minutes. + [Test, Performance] + [TestCase(1_000)] + [TestCase(10_000)] + [TestCase(50_000)] + public void Add_Incremental_Scaling(int size) + { + Measure.Method(() => + { + var source = new ObservableList(); + using var filtered = new FilteredList(source, x => x >= 0, Comparer.Default); + for (var i = 0; i < size; i++) source.Add(i); + }) + .SampleGroup(new SampleGroup($"Incremental_{size}", SampleUnit.Millisecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + + #region Incremental Add vs Full Rebuild + // Contrasts the incremental Add path against a forced full rebuild on the same + // workload. Kept at small N because the rebuild path is O(N² log N) here: + // at N=2_000 a single iteration is already ~100 ms × 13 iterations ≈ 1.5 s. + [Test, Performance] + [TestCase(500)] + [TestCase(2_000)] + public void Add_Incremental_VsFullRebuild(int size) + { + Measure.Method(() => + { + var source = new ObservableList(); + using var filtered = new FilteredList(source, x => x >= 0, Comparer.Default); + for (var i = 0; i < size; i++) source.Add(i); + }) + .SampleGroup(new SampleGroup($"Incremental_{size}", SampleUnit.Millisecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + + Measure.Method(() => + { + var source = new ObservableList(); + using var filtered = new FilteredList(source, x => x >= 0, Comparer.Default); + for (var i = 0; i < size; i++) + { + source.Add(i); + filtered.Update(); + } + }) + .SampleGroup(new SampleGroup($"FullRebuildPerAdd_{size}", SampleUnit.Millisecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + + #region FindViewPosition: Comparer vs NoComparer + // FindViewPosition is O(log n) without a Comparer and O(n) with one. This + // test pins the gap so a future switch to an auxiliary reverse index would + // show up as a large win here. + [Test, Performance] + public void Remove_Middle_ComparerIsSlowerThanNoComparer() + { + const int size = 10_000; + ObservableList source = null; + FilteredList filtered = null; + + Measure.Method(() => source!.RemoveAt(size / 2)) + .SetUp(() => + { + source = new ObservableList(); + for (var i = 0; i < size; i++) source.Add(i); + filtered = new FilteredList(source); + }) + .CleanUp(() => + { + filtered.Dispose(); + source.Dispose(); + }) + .SampleGroup(new SampleGroup("Remove_Middle_NoComparer", SampleUnit.Microsecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + + Measure.Method(() => source!.RemoveAt(size / 2)) + .SetUp(() => + { + source = new ObservableList(); + for (var i = 0; i < size; i++) source.Add(i); + filtered = new FilteredList(source, Comparer.Default); + }) + .CleanUp(() => + { + filtered.Dispose(); + source.Dispose(); + }) + .SampleGroup(new SampleGroup("Remove_Middle_Comparer", SampleUnit.Microsecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + + #region Batch AddRange + // AddRange should stay roughly linear in the batch size. Non-linear growth + // would suggest index-shift or binary-search cost is dominating. + [Test, Performance] + [TestCase(100)] + [TestCase(1_000)] + [TestCase(10_000)] + public void AddRange_ScalesLinearlyWithBatchSize(int batchSize) + { + var items = new int[batchSize]; + for (var i = 0; i < batchSize; i++) items[i] = i; + + ObservableList source = null; + FilteredList filtered = null; + + Measure.Method(() => source!.AddRange(items)) + .SetUp(() => + { + source = new ObservableList(); + for (var i = 0; i < 1_000; i++) source.Add(-i); + filtered = new FilteredList(source, x => x >= 0, Comparer.Default); + }) + .CleanUp(() => filtered.Dispose()) + .SampleGroup(new SampleGroup($"AddRange_{batchSize}", SampleUnit.Microsecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + + #region Chain Depth + // Each FilteredList in a chain forwards notifications. A single source mutation + // pays the dispatch cost at every level; measures the multiplier. + [Test, Performance] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void Add_ChainDepth_PropagationCost(int depth) + { + const int seed = 1_000; + ObservableList source = null; + var chain = new List>(); + + Measure.Method(() => source!.Add(seed)) + .SetUp(() => + { + source = new ObservableList(); + for (var i = 0; i < seed; i++) source.Add(i); + + chain.Clear(); + IReadOnlyList upstream = source; + for (var level = 0; level < depth; level++) + { + var link = new FilteredList(upstream, x => x >= 0); + chain.Add(link); + upstream = link; + } + }) + .CleanUp(() => + { + for (var i = chain.Count - 1; i >= 0; i--) chain[i].Dispose(); + }) + .SampleGroup(new SampleGroup($"Add_ChainDepth_{depth}", SampleUnit.Microsecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + + #region Mixed Random Workload + // Realistic end-to-end workload: interleaves Add/Insert/Remove/Replace/Move/ + // RemoveRange to exercise the full dispatcher path. Deterministic seed keeps + // samples comparable across runs. + [Test, Performance] + public void MixedWorkload_5000Items_1000Ops() + { + const int initialSize = 5_000; + const int operations = 1_000; + + ObservableList source = null; + FilteredList filtered = null; + + Measure.Method(() => + { + var rng = new Random(42); + for (var op = 0; op < operations; op++) + { + switch (rng.Next(6)) + { + case 0: + source!.Add(rng.Next(0, 10_000)); + break; + case 1: + if (source!.Count > 0) + source.Insert(rng.Next(source.Count), rng.Next(0, 10_000)); + break; + case 2: + if (source!.Count > 0) source.RemoveAt(rng.Next(source.Count)); + break; + case 3: + if (source!.Count > 0) + source[rng.Next(source.Count)] = rng.Next(0, 10_000); + break; + case 4: + if (source!.Count >= 2) + source.Move(rng.Next(source.Count), rng.Next(source.Count)); + break; + case 5: + if (source!.Count >= 4) + { + var start = rng.Next(source.Count - 2); + source.RemoveRange(start, rng.Next(1, 4)); + } + break; + } + } + }) + .SetUp(() => + { + source = new ObservableList(); + var seedRng = new Random(7); + for (var i = 0; i < initialSize; i++) source.Add(seedRng.Next(0, 10_000)); + filtered = new FilteredList(source, x => x % 3 != 0, Comparer.Default); + }) + .CleanUp(() => filtered.Dispose()) + .SampleGroup(new SampleGroup("MixedWorkload_5000_1000ops", SampleUnit.Millisecond)) + .WarmupCount(WarmupCount) + .MeasurementCount(MeasurementCount) + .Run(); + } + #endregion + } +} diff --git a/Tests/Observable/Performance/FilteredListPerformanceTests.cs.meta b/Tests/Observable/Performance/FilteredListPerformanceTests.cs.meta new file mode 100644 index 0000000..97f068a --- /dev/null +++ b/Tests/Observable/Performance/FilteredListPerformanceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 877f2d69eccf34d5aab48231bc8054ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Observable/SplitEventsExtensionsTests.cs b/Tests/Observable/SplitEventsExtensionsTests.cs index bd89af3..a07f015 100644 --- a/Tests/Observable/SplitEventsExtensionsTests.cs +++ b/Tests/Observable/SplitEventsExtensionsTests.cs @@ -44,7 +44,7 @@ public void Added_InvokedOnAddRange() { var capturedIndex = -1; IReadOnlyList capturedItems = null; - + using var events = _source.SplitByEvents(added: (items, index) => { capturedIndex = index; @@ -55,22 +55,46 @@ public void Added_InvokedOnAddRange() Assert.IsNotNull(capturedItems); Assert.AreEqual(0, capturedIndex); - + Assert.AreEqual(3, capturedItems.Count); for (var i = 0; i < capturedItems.Count; i++) Assert.AreEqual(i, capturedItems[i]); } + + [Test] + public void Added_InvokedOnInsertRange() + { + _source.AddRange(10, 20, 30); + + var capturedIndex = -1; + IReadOnlyList capturedItems = null; + + using var events = _source.SplitByEvents(added: (items, index) => + { + capturedIndex = index; + capturedItems = items; + }); + + _source.InsertRange(1, 100, 200); + + Assert.IsNotNull(capturedItems); + Assert.AreEqual(1, capturedIndex); + + Assert.AreEqual(2, capturedItems.Count); + Assert.AreEqual(100, capturedItems[0]); + Assert.AreEqual(200, capturedItems[1]); + } #endregion - + [Test] public void Removed_InvokedOnRemove() { _source.Add(26); - + var capturedIndex = -1; IReadOnlyList capturedItems = null; - + using var events = _source.SplitByEvents(removed: (items, index) => { capturedItems = items; @@ -83,6 +107,31 @@ public void Removed_InvokedOnRemove() Assert.AreEqual(0, capturedIndex); Assert.AreEqual(26, capturedItems[0]); } + + [Test] + public void Removed_InvokedOnRemoveRange() + { + _source.AddRange(10, 20, 30, 40, 50); + + var capturedIndex = -1; + IReadOnlyList capturedItems = null; + + using var events = _source.SplitByEvents(removed: (items, index) => + { + capturedItems = items; + capturedIndex = index; + }); + + _source.RemoveRange(1, 3); + + Assert.IsNotNull(capturedItems); + Assert.AreEqual(1, capturedIndex); + + Assert.AreEqual(3, capturedItems.Count); + Assert.AreEqual(20, capturedItems[0]); + Assert.AreEqual(30, capturedItems[1]); + Assert.AreEqual(40, capturedItems[2]); + } [Test] public void Moved_InvokedOnMove() diff --git a/Tests/Observable/SplitEventsExtensionsTests.cs.meta b/Tests/Observable/SplitEventsExtensionsTests.cs.meta index 3795d7f..0e28a97 100644 --- a/Tests/Observable/SplitEventsExtensionsTests.cs.meta +++ b/Tests/Observable/SplitEventsExtensionsTests.cs.meta @@ -5,7 +5,7 @@ MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 - icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: