From 9c4bfef6089aa80e25df9ce4514e264c2aa97ad6 Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Sat, 23 May 2026 14:33:26 +0300 Subject: [PATCH 1/7] Move tests to Tests/Runtime and refresh package metadata - Reorganize tests under `Tests/Runtime/Observable/` to match Unity's standard runtime/editor split, and rename the test asmdef to `Aspid.Collections.Tests` with an empty `rootNamespace`. - Drop `Editor`-only `includePlatforms` from the test and performance asmdefs so tests can run on player platforms too, and remove the redundant `ASPID_COLLECTIONS_PERFORMANCE_TESTING` from the perf asmdef's `defineConstraints` (it remains in `versionDefines`). - Expand `package.json` with `unityRelease`, a `com.unity.test-framework` dependency, author URL, and changelog/documentation/license URLs. - Repoint `ObservableDictionary.cs.meta` to the new Aspid script icon GUID. --- Runtime/Observable/ObservableDictionary.cs.meta | 2 +- Tests/Runtime.meta | 8 ++++++++ .../Aspid.Collections.Tests.asmdef} | 9 ++++----- .../Aspid.Collections.Tests.asmdef.meta} | 0 Tests/{ => Runtime}/Observable.meta | 0 .../Observable/CreateFilteredExtensionsTests.cs | 0 .../CreateFilteredExtensionsTests.cs.meta | 0 .../Observable/CreateSyncDictionaryTests.cs | 0 .../CreateSyncDictionaryTests.cs.meta | 0 .../Observable/CreateSyncHashSetTests.cs | 0 .../Observable/CreateSyncHashSetTests.cs.meta | 0 .../Observable/CreateSyncListTests.cs | 0 .../Observable/CreateSyncListTests.cs.meta | 0 .../Observable/CreateSyncQueueTests.cs | 0 .../Observable/CreateSyncQueueTests.cs.meta | 0 .../Observable/CreateSyncStackTests.cs | 0 .../Observable/CreateSyncStackTests.cs.meta | 0 .../Observable/FilteredListTests.cs | 0 .../Observable/FilteredListTests.cs.meta | 0 Tests/{ => Runtime}/Observable/Helpers.meta | 0 .../Observable/Helpers/DisposableItem.cs | 0 .../Observable/Helpers/DisposableItem.cs.meta | 0 .../Observable/Helpers/EventCapture.cs | 0 .../Observable/Helpers/EventCapture.cs.meta | 0 .../NotifyCollectionChangedEventArgsTests.cs | 0 ...otifyCollectionChangedEventArgsTests.cs.meta | 0 .../Observable/ObservableDictionaryTests.cs | 0 .../ObservableDictionaryTests.cs.meta | 0 .../Observable/ObservableHashSetTests.cs | 0 .../Observable/ObservableHashSetTests.cs.meta | 0 .../Observable/ObservableListExtensionsTests.cs | 0 .../ObservableListExtensionsTests.cs.meta | 0 .../Observable/ObservableListTests.cs | 0 .../Observable/ObservableListTests.cs.meta | 0 .../Observable/ObservableQueueTests.cs | 0 .../Observable/ObservableQueueTests.cs.meta | 0 .../Observable/ObservableStackTests.cs | 0 .../Observable/ObservableStackTests.cs.meta | 0 Tests/{ => Runtime}/Observable/Performance.meta | 0 ...llections.Observable.PerformanceTests.asmdef | 11 ++++------- ...ions.Observable.PerformanceTests.asmdef.meta | 0 .../Performance/FilteredListPerformanceTests.cs | 0 .../FilteredListPerformanceTests.cs.meta | 0 .../Observable/SplitEventsExtensionsTests.cs | 0 .../SplitEventsExtensionsTests.cs.meta | 0 package.json | 17 ++++++++++++----- 46 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 Tests/Runtime.meta rename Tests/{Observable/Aspid.Collections.Observable.Tests.asmdef => Runtime/Aspid.Collections.Tests.asmdef} (81%) rename Tests/{Observable/Aspid.Collections.Observable.Tests.asmdef.meta => Runtime/Aspid.Collections.Tests.asmdef.meta} (100%) rename Tests/{ => Runtime}/Observable.meta (100%) rename Tests/{ => Runtime}/Observable/CreateFilteredExtensionsTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateFilteredExtensionsTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/CreateSyncDictionaryTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateSyncDictionaryTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/CreateSyncHashSetTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateSyncHashSetTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/CreateSyncListTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateSyncListTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/CreateSyncQueueTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateSyncQueueTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/CreateSyncStackTests.cs (100%) rename Tests/{ => Runtime}/Observable/CreateSyncStackTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/FilteredListTests.cs (100%) rename Tests/{ => Runtime}/Observable/FilteredListTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/Helpers.meta (100%) rename Tests/{ => Runtime}/Observable/Helpers/DisposableItem.cs (100%) rename Tests/{ => Runtime}/Observable/Helpers/DisposableItem.cs.meta (100%) rename Tests/{ => Runtime}/Observable/Helpers/EventCapture.cs (100%) rename Tests/{ => Runtime}/Observable/Helpers/EventCapture.cs.meta (100%) rename Tests/{ => Runtime}/Observable/NotifyCollectionChangedEventArgsTests.cs (100%) rename Tests/{ => Runtime}/Observable/NotifyCollectionChangedEventArgsTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableDictionaryTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableDictionaryTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableHashSetTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableHashSetTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableListExtensionsTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableListExtensionsTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableListTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableListTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableQueueTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableQueueTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/ObservableStackTests.cs (100%) rename Tests/{ => Runtime}/Observable/ObservableStackTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/Performance.meta (100%) rename Tests/{ => Runtime}/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef (75%) rename Tests/{ => Runtime}/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta (100%) rename Tests/{ => Runtime}/Observable/Performance/FilteredListPerformanceTests.cs (100%) rename Tests/{ => Runtime}/Observable/Performance/FilteredListPerformanceTests.cs.meta (100%) rename Tests/{ => Runtime}/Observable/SplitEventsExtensionsTests.cs (100%) rename Tests/{ => Runtime}/Observable/SplitEventsExtensionsTests.cs.meta (100%) diff --git a/Runtime/Observable/ObservableDictionary.cs.meta b/Runtime/Observable/ObservableDictionary.cs.meta index 878080e..196c9eb 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: 0d0d85abe18854107bff963db8208bbc, type: 3} + icon: {fileID: 2800000, guid: c602f5f36a5ff4b97aaa3917dabae411, type: 3} userData: assetBundleName: assetBundleVariant: diff --git a/Tests/Runtime.meta b/Tests/Runtime.meta new file mode 100644 index 0000000..03f90fc --- /dev/null +++ b/Tests/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4f2384b67f3854c7db90d4f5260467f0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Observable/Aspid.Collections.Observable.Tests.asmdef b/Tests/Runtime/Aspid.Collections.Tests.asmdef similarity index 81% rename from Tests/Observable/Aspid.Collections.Observable.Tests.asmdef rename to Tests/Runtime/Aspid.Collections.Tests.asmdef index f8fea06..e0d52a9 100644 --- a/Tests/Observable/Aspid.Collections.Observable.Tests.asmdef +++ b/Tests/Runtime/Aspid.Collections.Tests.asmdef @@ -1,13 +1,12 @@ { - "name": "Aspid.Collections.Observable.Tests", + "name": "Aspid.Collections.Tests", + "rootNamespace": "", "references": [ "Aspid.Collections.Observable", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], - "includePlatforms": [ - "Editor" - ], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, @@ -20,4 +19,4 @@ ], "versionDefines": [], "noEngineReferences": true -} +} \ No newline at end of file diff --git a/Tests/Observable/Aspid.Collections.Observable.Tests.asmdef.meta b/Tests/Runtime/Aspid.Collections.Tests.asmdef.meta similarity index 100% rename from Tests/Observable/Aspid.Collections.Observable.Tests.asmdef.meta rename to Tests/Runtime/Aspid.Collections.Tests.asmdef.meta diff --git a/Tests/Observable.meta b/Tests/Runtime/Observable.meta similarity index 100% rename from Tests/Observable.meta rename to Tests/Runtime/Observable.meta diff --git a/Tests/Observable/CreateFilteredExtensionsTests.cs b/Tests/Runtime/Observable/CreateFilteredExtensionsTests.cs similarity index 100% rename from Tests/Observable/CreateFilteredExtensionsTests.cs rename to Tests/Runtime/Observable/CreateFilteredExtensionsTests.cs diff --git a/Tests/Observable/CreateFilteredExtensionsTests.cs.meta b/Tests/Runtime/Observable/CreateFilteredExtensionsTests.cs.meta similarity index 100% rename from Tests/Observable/CreateFilteredExtensionsTests.cs.meta rename to Tests/Runtime/Observable/CreateFilteredExtensionsTests.cs.meta diff --git a/Tests/Observable/CreateSyncDictionaryTests.cs b/Tests/Runtime/Observable/CreateSyncDictionaryTests.cs similarity index 100% rename from Tests/Observable/CreateSyncDictionaryTests.cs rename to Tests/Runtime/Observable/CreateSyncDictionaryTests.cs diff --git a/Tests/Observable/CreateSyncDictionaryTests.cs.meta b/Tests/Runtime/Observable/CreateSyncDictionaryTests.cs.meta similarity index 100% rename from Tests/Observable/CreateSyncDictionaryTests.cs.meta rename to Tests/Runtime/Observable/CreateSyncDictionaryTests.cs.meta diff --git a/Tests/Observable/CreateSyncHashSetTests.cs b/Tests/Runtime/Observable/CreateSyncHashSetTests.cs similarity index 100% rename from Tests/Observable/CreateSyncHashSetTests.cs rename to Tests/Runtime/Observable/CreateSyncHashSetTests.cs diff --git a/Tests/Observable/CreateSyncHashSetTests.cs.meta b/Tests/Runtime/Observable/CreateSyncHashSetTests.cs.meta similarity index 100% rename from Tests/Observable/CreateSyncHashSetTests.cs.meta rename to Tests/Runtime/Observable/CreateSyncHashSetTests.cs.meta diff --git a/Tests/Observable/CreateSyncListTests.cs b/Tests/Runtime/Observable/CreateSyncListTests.cs similarity index 100% rename from Tests/Observable/CreateSyncListTests.cs rename to Tests/Runtime/Observable/CreateSyncListTests.cs diff --git a/Tests/Observable/CreateSyncListTests.cs.meta b/Tests/Runtime/Observable/CreateSyncListTests.cs.meta similarity index 100% rename from Tests/Observable/CreateSyncListTests.cs.meta rename to Tests/Runtime/Observable/CreateSyncListTests.cs.meta diff --git a/Tests/Observable/CreateSyncQueueTests.cs b/Tests/Runtime/Observable/CreateSyncQueueTests.cs similarity index 100% rename from Tests/Observable/CreateSyncQueueTests.cs rename to Tests/Runtime/Observable/CreateSyncQueueTests.cs diff --git a/Tests/Observable/CreateSyncQueueTests.cs.meta b/Tests/Runtime/Observable/CreateSyncQueueTests.cs.meta similarity index 100% rename from Tests/Observable/CreateSyncQueueTests.cs.meta rename to Tests/Runtime/Observable/CreateSyncQueueTests.cs.meta diff --git a/Tests/Observable/CreateSyncStackTests.cs b/Tests/Runtime/Observable/CreateSyncStackTests.cs similarity index 100% rename from Tests/Observable/CreateSyncStackTests.cs rename to Tests/Runtime/Observable/CreateSyncStackTests.cs diff --git a/Tests/Observable/CreateSyncStackTests.cs.meta b/Tests/Runtime/Observable/CreateSyncStackTests.cs.meta similarity index 100% rename from Tests/Observable/CreateSyncStackTests.cs.meta rename to Tests/Runtime/Observable/CreateSyncStackTests.cs.meta diff --git a/Tests/Observable/FilteredListTests.cs b/Tests/Runtime/Observable/FilteredListTests.cs similarity index 100% rename from Tests/Observable/FilteredListTests.cs rename to Tests/Runtime/Observable/FilteredListTests.cs diff --git a/Tests/Observable/FilteredListTests.cs.meta b/Tests/Runtime/Observable/FilteredListTests.cs.meta similarity index 100% rename from Tests/Observable/FilteredListTests.cs.meta rename to Tests/Runtime/Observable/FilteredListTests.cs.meta diff --git a/Tests/Observable/Helpers.meta b/Tests/Runtime/Observable/Helpers.meta similarity index 100% rename from Tests/Observable/Helpers.meta rename to Tests/Runtime/Observable/Helpers.meta diff --git a/Tests/Observable/Helpers/DisposableItem.cs b/Tests/Runtime/Observable/Helpers/DisposableItem.cs similarity index 100% rename from Tests/Observable/Helpers/DisposableItem.cs rename to Tests/Runtime/Observable/Helpers/DisposableItem.cs diff --git a/Tests/Observable/Helpers/DisposableItem.cs.meta b/Tests/Runtime/Observable/Helpers/DisposableItem.cs.meta similarity index 100% rename from Tests/Observable/Helpers/DisposableItem.cs.meta rename to Tests/Runtime/Observable/Helpers/DisposableItem.cs.meta diff --git a/Tests/Observable/Helpers/EventCapture.cs b/Tests/Runtime/Observable/Helpers/EventCapture.cs similarity index 100% rename from Tests/Observable/Helpers/EventCapture.cs rename to Tests/Runtime/Observable/Helpers/EventCapture.cs diff --git a/Tests/Observable/Helpers/EventCapture.cs.meta b/Tests/Runtime/Observable/Helpers/EventCapture.cs.meta similarity index 100% rename from Tests/Observable/Helpers/EventCapture.cs.meta rename to Tests/Runtime/Observable/Helpers/EventCapture.cs.meta diff --git a/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs b/Tests/Runtime/Observable/NotifyCollectionChangedEventArgsTests.cs similarity index 100% rename from Tests/Observable/NotifyCollectionChangedEventArgsTests.cs rename to Tests/Runtime/Observable/NotifyCollectionChangedEventArgsTests.cs diff --git a/Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta b/Tests/Runtime/Observable/NotifyCollectionChangedEventArgsTests.cs.meta similarity index 100% rename from Tests/Observable/NotifyCollectionChangedEventArgsTests.cs.meta rename to Tests/Runtime/Observable/NotifyCollectionChangedEventArgsTests.cs.meta diff --git a/Tests/Observable/ObservableDictionaryTests.cs b/Tests/Runtime/Observable/ObservableDictionaryTests.cs similarity index 100% rename from Tests/Observable/ObservableDictionaryTests.cs rename to Tests/Runtime/Observable/ObservableDictionaryTests.cs diff --git a/Tests/Observable/ObservableDictionaryTests.cs.meta b/Tests/Runtime/Observable/ObservableDictionaryTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableDictionaryTests.cs.meta rename to Tests/Runtime/Observable/ObservableDictionaryTests.cs.meta diff --git a/Tests/Observable/ObservableHashSetTests.cs b/Tests/Runtime/Observable/ObservableHashSetTests.cs similarity index 100% rename from Tests/Observable/ObservableHashSetTests.cs rename to Tests/Runtime/Observable/ObservableHashSetTests.cs diff --git a/Tests/Observable/ObservableHashSetTests.cs.meta b/Tests/Runtime/Observable/ObservableHashSetTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableHashSetTests.cs.meta rename to Tests/Runtime/Observable/ObservableHashSetTests.cs.meta diff --git a/Tests/Observable/ObservableListExtensionsTests.cs b/Tests/Runtime/Observable/ObservableListExtensionsTests.cs similarity index 100% rename from Tests/Observable/ObservableListExtensionsTests.cs rename to Tests/Runtime/Observable/ObservableListExtensionsTests.cs diff --git a/Tests/Observable/ObservableListExtensionsTests.cs.meta b/Tests/Runtime/Observable/ObservableListExtensionsTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableListExtensionsTests.cs.meta rename to Tests/Runtime/Observable/ObservableListExtensionsTests.cs.meta diff --git a/Tests/Observable/ObservableListTests.cs b/Tests/Runtime/Observable/ObservableListTests.cs similarity index 100% rename from Tests/Observable/ObservableListTests.cs rename to Tests/Runtime/Observable/ObservableListTests.cs diff --git a/Tests/Observable/ObservableListTests.cs.meta b/Tests/Runtime/Observable/ObservableListTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableListTests.cs.meta rename to Tests/Runtime/Observable/ObservableListTests.cs.meta diff --git a/Tests/Observable/ObservableQueueTests.cs b/Tests/Runtime/Observable/ObservableQueueTests.cs similarity index 100% rename from Tests/Observable/ObservableQueueTests.cs rename to Tests/Runtime/Observable/ObservableQueueTests.cs diff --git a/Tests/Observable/ObservableQueueTests.cs.meta b/Tests/Runtime/Observable/ObservableQueueTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableQueueTests.cs.meta rename to Tests/Runtime/Observable/ObservableQueueTests.cs.meta diff --git a/Tests/Observable/ObservableStackTests.cs b/Tests/Runtime/Observable/ObservableStackTests.cs similarity index 100% rename from Tests/Observable/ObservableStackTests.cs rename to Tests/Runtime/Observable/ObservableStackTests.cs diff --git a/Tests/Observable/ObservableStackTests.cs.meta b/Tests/Runtime/Observable/ObservableStackTests.cs.meta similarity index 100% rename from Tests/Observable/ObservableStackTests.cs.meta rename to Tests/Runtime/Observable/ObservableStackTests.cs.meta diff --git a/Tests/Observable/Performance.meta b/Tests/Runtime/Observable/Performance.meta similarity index 100% rename from Tests/Observable/Performance.meta rename to Tests/Runtime/Observable/Performance.meta diff --git a/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef b/Tests/Runtime/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef similarity index 75% rename from Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef rename to Tests/Runtime/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef index 1416c3c..d4242d1 100644 --- a/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef +++ b/Tests/Runtime/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef @@ -2,14 +2,12 @@ "name": "Aspid.Collections.Observable.PerformanceTests", "rootNamespace": "", "references": [ + "Aspid.Collections.Observable", "UnityEditor.TestRunner", "UnityEngine.TestRunner", - "Unity.PerformanceTesting", - "Aspid.Collections.Observable" - ], - "includePlatforms": [ - "Editor" + "Unity.PerformanceTesting" ], + "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, @@ -18,8 +16,7 @@ ], "autoReferenced": false, "defineConstraints": [ - "UNITY_INCLUDE_TESTS", - "ASPID_COLLECTIONS_PERFORMANCE_TESTING" + "UNITY_INCLUDE_TESTS" ], "versionDefines": [ { diff --git a/Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta b/Tests/Runtime/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta similarity index 100% rename from Tests/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta rename to Tests/Runtime/Observable/Performance/Aspid.Collections.Observable.PerformanceTests.asmdef.meta diff --git a/Tests/Observable/Performance/FilteredListPerformanceTests.cs b/Tests/Runtime/Observable/Performance/FilteredListPerformanceTests.cs similarity index 100% rename from Tests/Observable/Performance/FilteredListPerformanceTests.cs rename to Tests/Runtime/Observable/Performance/FilteredListPerformanceTests.cs diff --git a/Tests/Observable/Performance/FilteredListPerformanceTests.cs.meta b/Tests/Runtime/Observable/Performance/FilteredListPerformanceTests.cs.meta similarity index 100% rename from Tests/Observable/Performance/FilteredListPerformanceTests.cs.meta rename to Tests/Runtime/Observable/Performance/FilteredListPerformanceTests.cs.meta diff --git a/Tests/Observable/SplitEventsExtensionsTests.cs b/Tests/Runtime/Observable/SplitEventsExtensionsTests.cs similarity index 100% rename from Tests/Observable/SplitEventsExtensionsTests.cs rename to Tests/Runtime/Observable/SplitEventsExtensionsTests.cs diff --git a/Tests/Observable/SplitEventsExtensionsTests.cs.meta b/Tests/Runtime/Observable/SplitEventsExtensionsTests.cs.meta similarity index 100% rename from Tests/Observable/SplitEventsExtensionsTests.cs.meta rename to Tests/Runtime/Observable/SplitEventsExtensionsTests.cs.meta diff --git a/package.json b/package.json index 2302299..ae92479 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,19 @@ { "name": "tech.aspid.collections", - "version": "1.0.2", "displayName": "Aspid.Collections", - "description": "A set of covariate observation collections with easy synchronization between two collections, filtering and sorting.", + "version": "1.0.2", "unity": "2021.3", - "author": - { + "unityRelease": "0f1", + "description": "A set of covariate observation collections with easy synchronization between two collections, filtering and sorting.", + "dependencies": { + "com.unity.test-framework": "1.6.0" + }, + "author": { "name": "Vladislav Panin", + "url": "https://github.com/VPDPersonal", "email": "vpd.aspid@gmail.com" - } + }, + "changelogUrl": "https://github.com/VPDPersonal/Aspid.Collections/blob/main/CHANGELOG.md", + "documentationUrl": "https://github.com/VPDPersonal/Aspid.Collections", + "licensesUrl": "https://github.com/VPDPersonal/Aspid.Collections/blob/main/LICENSE" } From 7d24bab895e896518b560af709802241925e29c1 Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Sat, 23 May 2026 14:48:17 +0300 Subject: [PATCH 2/7] Sync CLAUDE.md with new test layout and asmdef rename - Repoint all `Tests/Observable/` paths to `Tests/Runtime/Observable/` in the layout block, testing notes, and performance gotcha. - Rename `Aspid.Collections.Observable.Tests.asmdef` to `Aspid.Collections.Tests.asmdef` in the assemblies table and note that it is now `noEngineReferences: true` and unconstrained on `includePlatforms` (tests can run EditMode or PlayMode). - Update the perf-tests gotcha to reflect the new gating: the asmdef resolves only when `com.unity.test-framework.performance` is installed (it references `Unity.PerformanceTesting` directly), and `ASPID_COLLECTIONS_PERFORMANCE_TESTING` is now purely a code-level `#if`-gate via `versionDefines` rather than a compile constraint. --- CLAUDE.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d37246..8808526 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Collections/ │ │ └── Extensions/ # CreateFilteredExtensions │ └── Synchronizer/ # Observable*Sync, IReadOnly*Sync │ └── Extensions/ # CreateSyncExtensions -└── Tests/Observable/ # EditMode tests (UTF) +└── Tests/Runtime/Observable/ # Unity Test Framework tests ├── Helpers/ └── Performance/ # Optional perf benchmarks (gated) ``` @@ -54,15 +54,17 @@ Collections/ | asmdef | Purpose | |--------|---------| | `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) | +| `Aspid.Collections.Tests.asmdef` | Unity Test Framework tests (`noEngineReferences: true`, compiled when `UNITY_INCLUDE_TESTS` is defined) | +| `Aspid.Collections.Observable.PerformanceTests.asmdef` | Perf benchmarks; compiled when `UNITY_INCLUDE_TESTS` is defined and references `Unity.PerformanceTesting` directly, so the asmdef also requires `com.unity.test-framework.performance` to be installed. `ASPID_COLLECTIONS_PERFORMANCE_TESTING` is auto-set via `versionDefines` for code-level `#if`-gating. | ## Testing -Tests live in `Tests/Observable/` and run via Unity Test Runner -(EditMode). Convention: one `FooTests.cs` per collection/extension (e.g. -`ObservableListTests.cs`, `CreateSyncDictionaryTests.cs`, -`FilteredListTests.cs`). Shared helpers in `Tests/Observable/Helpers/`. +Tests live in `Tests/Runtime/Observable/` and run via Unity Test Runner +(the asmdef does not pin `includePlatforms`, so tests compile for every +build target and can run as EditMode or PlayMode). Convention: one +`FooTests.cs` per collection/extension (e.g. `ObservableListTests.cs`, +`CreateSyncDictionaryTests.cs`, `FilteredListTests.cs`). Shared helpers +in `Tests/Runtime/Observable/Helpers/`. ## Conventions (delta over parent CLAUDE.md) @@ -93,11 +95,14 @@ Tests live in `Tests/Observable/` and run via Unity Test Runner `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. +- **Performance tests need the perf package.** + `Tests/Runtime/Observable/Performance/` references + `Unity.PerformanceTesting` directly, so the asmdef only resolves when + `com.unity.test-framework.performance` is installed in the consuming + project. The `versionDefines` block sets + `ASPID_COLLECTIONS_PERFORMANCE_TESTING` when that package is present — + use it for `#if`-gating inside benchmark code. The compile gate + itself is just `UNITY_INCLUDE_TESTS` in `defineConstraints`. ## Pointers From 759ce8340ae9fad720d2e08b9889b6d6a6ff7b7a Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Sat, 23 May 2026 16:07:30 +0300 Subject: [PATCH 3/7] Add .gitignore for IDE folders and unhide Samples~ - Ignore .idea/, .zed/, .vscode/ at the package root. - Negate the global "*~" rule that was silently excluding the entire Samples~/ folder, so all five UPM samples (asmdefs, scripts, scenes) are now actually tracked. --- .gitignore | 6 + Samples~/01_BasicChangeNotifications.meta | 8 + ...ns.Samples.BasicChangeNotifications.asmdef | 16 ++ ...mples.BasicChangeNotifications.asmdef.meta | 7 + .../BasicChangeNotificationsSample.cs | 94 ++++++++++ .../BasicChangeNotificationsSample.cs.meta | 2 + .../BasicChangeNotificationsSample.unity | 170 +++++++++++++++++ .../BasicChangeNotificationsSample.unity.meta | 7 + Samples~/02_InventorySync.meta | 8 + ...d.Collections.Samples.InventorySync.asmdef | 16 ++ ...lections.Samples.InventorySync.asmdef.meta | 7 + Samples~/02_InventorySync/InventoryItem.cs | 19 ++ .../02_InventorySync/InventoryItem.cs.meta | 2 + .../02_InventorySync/InventoryItemView.cs | 30 +++ .../InventoryItemView.cs.meta | 2 + Samples~/02_InventorySync/InventorySync.unity | 170 +++++++++++++++++ .../02_InventorySync/InventorySync.unity.meta | 7 + .../02_InventorySync/InventorySyncSample.cs | 67 +++++++ .../InventorySyncSample.cs.meta | 2 + Samples~/03_FilteredInventory.meta | 8 + ...llections.Samples.FilteredInventory.asmdef | 16 ++ ...ions.Samples.FilteredInventory.asmdef.meta | 7 + Samples~/03_FilteredInventory/Category.cs | 9 + .../03_FilteredInventory/Category.cs.meta | 3 + .../FilteredInventory.unity | 172 ++++++++++++++++++ .../FilteredInventory.unity.meta | 7 + .../FilteredInventorySample.cs | 114 ++++++++++++ .../FilteredInventorySample.cs.meta | 2 + Samples~/03_FilteredInventory/Item.cs | 21 +++ Samples~/03_FilteredInventory/Item.cs.meta | 3 + Samples~/04_DictionaryKeyedTable.meta | 8 + ...ctions.Samples.DictionaryKeyedTable.asmdef | 16 ++ ...s.Samples.DictionaryKeyedTable.asmdef.meta | 7 + .../04_DictionaryKeyedTable/Leaderboard.unity | 170 +++++++++++++++++ .../Leaderboard.unity.meta | 7 + .../LeaderboardSample.cs | 75 ++++++++ .../LeaderboardSample.cs.meta | 2 + .../04_DictionaryKeyedTable/PlayerScore.cs | 15 ++ .../PlayerScore.cs.meta | 3 + Samples~/05_SplitByEvents.meta | 8 + Samples~/05_SplitByEvents/AnalyticsTap.unity | 170 +++++++++++++++++ .../05_SplitByEvents/AnalyticsTap.unity.meta | 7 + .../05_SplitByEvents/AnalyticsTapSample.cs | 71 ++++++++ .../AnalyticsTapSample.cs.meta | 2 + ...d.Collections.Samples.SplitByEvents.asmdef | 16 ++ ...lections.Samples.SplitByEvents.asmdef.meta | 7 + 46 files changed, 1586 insertions(+) create mode 100644 .gitignore create mode 100644 Samples~/01_BasicChangeNotifications.meta create mode 100644 Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef create mode 100644 Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef.meta create mode 100644 Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs create mode 100644 Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs.meta create mode 100644 Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity create mode 100644 Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity.meta create mode 100644 Samples~/02_InventorySync.meta create mode 100644 Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef create mode 100644 Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef.meta create mode 100644 Samples~/02_InventorySync/InventoryItem.cs create mode 100644 Samples~/02_InventorySync/InventoryItem.cs.meta create mode 100644 Samples~/02_InventorySync/InventoryItemView.cs create mode 100644 Samples~/02_InventorySync/InventoryItemView.cs.meta create mode 100644 Samples~/02_InventorySync/InventorySync.unity create mode 100644 Samples~/02_InventorySync/InventorySync.unity.meta create mode 100644 Samples~/02_InventorySync/InventorySyncSample.cs create mode 100644 Samples~/02_InventorySync/InventorySyncSample.cs.meta create mode 100644 Samples~/03_FilteredInventory.meta create mode 100644 Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef create mode 100644 Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef.meta create mode 100644 Samples~/03_FilteredInventory/Category.cs create mode 100644 Samples~/03_FilteredInventory/Category.cs.meta create mode 100644 Samples~/03_FilteredInventory/FilteredInventory.unity create mode 100644 Samples~/03_FilteredInventory/FilteredInventory.unity.meta create mode 100644 Samples~/03_FilteredInventory/FilteredInventorySample.cs create mode 100644 Samples~/03_FilteredInventory/FilteredInventorySample.cs.meta create mode 100644 Samples~/03_FilteredInventory/Item.cs create mode 100644 Samples~/03_FilteredInventory/Item.cs.meta create mode 100644 Samples~/04_DictionaryKeyedTable.meta create mode 100644 Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef create mode 100644 Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef.meta create mode 100644 Samples~/04_DictionaryKeyedTable/Leaderboard.unity create mode 100644 Samples~/04_DictionaryKeyedTable/Leaderboard.unity.meta create mode 100644 Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs create mode 100644 Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs.meta create mode 100644 Samples~/04_DictionaryKeyedTable/PlayerScore.cs create mode 100644 Samples~/04_DictionaryKeyedTable/PlayerScore.cs.meta create mode 100644 Samples~/05_SplitByEvents.meta create mode 100644 Samples~/05_SplitByEvents/AnalyticsTap.unity create mode 100644 Samples~/05_SplitByEvents/AnalyticsTap.unity.meta create mode 100644 Samples~/05_SplitByEvents/AnalyticsTapSample.cs create mode 100644 Samples~/05_SplitByEvents/AnalyticsTapSample.cs.meta create mode 100644 Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef create mode 100644 Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef.meta diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb5a11c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.zed/ +.vscode/ + +# Override the global "*~" rule that hides Unity's Samples~ folder. +!Samples~/ diff --git a/Samples~/01_BasicChangeNotifications.meta b/Samples~/01_BasicChangeNotifications.meta new file mode 100644 index 0000000..94dbcaa --- /dev/null +++ b/Samples~/01_BasicChangeNotifications.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a6405993f1e34fc0b3c4f255d69da7c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef b/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef new file mode 100644 index 0000000..9de9bae --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.Collections.Samples.BasicChangeNotifications", + "rootNamespace": "Aspid.Collections.Samples.BasicChangeNotifications", + "references": [ + "Aspid.Collections.Observable" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef.meta b/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef.meta new file mode 100644 index 0000000..6bbdc24 --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/Aspid.Collections.Samples.BasicChangeNotifications.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0f197d92bcfe6426eb82885e7cfc5982 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs new file mode 100644 index 0000000..50e1bd6 --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs @@ -0,0 +1,94 @@ +// Sample 01 — Basic Change Notifications +// +// Walks through every shape of NotifyCollectionChangedEventArgs emitted +// by ObservableList: single-item Add/Remove/Replace/Move, batch Add/Remove, +// and Reset (from Clear). The receiver is one handler that demonstrates how +// to dispatch on Action and on IsSingleItem. + +using UnityEngine; +using System.Collections.Generic; +using Aspid.Collections.Observable; +using System.Collections.Specialized; + +namespace Aspid.Collections.Samples.BasicChangeNotifications +{ + public sealed class BasicChangeNotificationsSample : MonoBehaviour + { + private ObservableList _list; + + private void Start() + { + _list = new ObservableList(); + _list.CollectionChanged += OnChanged; + + // Single-item Add -> Action=Add, IsSingleItem=true, NewItem set + _list.Add("apple"); + _list.Add("banana"); + + // Batch Add -> Action=Add, IsSingleItem=false, NewItems set + _list.AddRange("cherry", "date", "elderberry"); + + // Replace -> Action=Replace, IsSingleItem=true, OldItem + NewItem + _list[0] = "apricot"; + + // Move -> Action=Move, IsSingleItem=true, Old/NewStartingIndex + _list.Move(0, 2); + + // Single Remove -> Action=Remove, IsSingleItem=true, OldItem + _list.RemoveAt(1); + + // Batch Remove -> Action=Remove, IsSingleItem=false, OldItems + _list.RemoveRange(0, 2); + + // Reset -> Action=Reset. No items carried in the args by design; + // subscribers must re-read the collection. + _list.Clear(); + } + + private void OnDestroy() + { + // Dispose() of an ObservableList drops all subscribers AND clears the items + // (Clear() raises one final Reset event). If you only want to detach this one + // handler, use `_list.CollectionChanged -= OnChanged` instead. + _list?.Dispose(); + } + + private static void OnChanged(INotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + if (args.IsSingleItem) Debug.Log($"[+] Added \"{args.NewItem}\" at {args.NewStartingIndex}"); + else Debug.Log($"[+] Added batch {Format(args.NewItems)} starting at {args.NewStartingIndex}"); + break; + + case NotifyCollectionChangedAction.Remove: + if (args.IsSingleItem) Debug.Log($"[-] Removed \"{args.OldItem}\" at {args.OldStartingIndex}"); + else Debug.Log($"[-] Removed batch {Format(args.OldItems)} starting at {args.OldStartingIndex}"); + break; + + case NotifyCollectionChangedAction.Replace: + // ObservableList only emits single-item Replace today, so a batch + // branch is not required here. Keep it explicit for safety. + Debug.Log($"[~] Replaced \"{args.OldItem}\" -> \"{args.NewItem}\" at {args.NewStartingIndex}"); + break; + + case NotifyCollectionChangedAction.Move: + Debug.Log($"[>] Moved \"{args.NewItem}\" {args.OldStartingIndex} -> {args.NewStartingIndex}"); + break; + + case NotifyCollectionChangedAction.Reset: + // Reset carries no payload — neither OldItems nor NewItems is populated. + // Treat it as "re-read the whole collection". + Debug.Log("[*] Reset"); + break; + } + } + + private static string Format(IReadOnlyList items) + { + if (items is null || items.Count == 0) return "[]"; + return "[" + string.Join(", ", items) + "]"; + } + } +} diff --git a/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs.meta b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs.meta new file mode 100644 index 0000000..69513f7 --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f98372cab00b842ea9c49cff1e4d5356 \ No newline at end of file diff --git a/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity new file mode 100644 index 0000000..b16ad0f --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity @@ -0,0 +1,170 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 + m_Name: BasicChangeNotificationsSample + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &101 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f98372cab00b842ea9c49cff1e4d5356, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 101} diff --git a/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity.meta b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity.meta new file mode 100644 index 0000000..f5d0c58 --- /dev/null +++ b/Samples~/01_BasicChangeNotifications/BasicChangeNotificationsSample.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 77e665fb9f59482a87a6e36cd6b6c28c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/02_InventorySync.meta b/Samples~/02_InventorySync.meta new file mode 100644 index 0000000..8c811e4 --- /dev/null +++ b/Samples~/02_InventorySync.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2188d82b624784e728f6019e6bf6d59c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef b/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef new file mode 100644 index 0000000..fa68753 --- /dev/null +++ b/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.Collections.Samples.InventorySync", + "rootNamespace": "Aspid.Collections.Samples.InventorySync", + "references": [ + "Aspid.Collections.Observable" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef.meta b/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef.meta new file mode 100644 index 0000000..39bd7ef --- /dev/null +++ b/Samples~/02_InventorySync/Aspid.Collections.Samples.InventorySync.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c474e57ada26746a0a78995db2fa7c96 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/02_InventorySync/InventoryItem.cs b/Samples~/02_InventorySync/InventoryItem.cs new file mode 100644 index 0000000..3d71016 --- /dev/null +++ b/Samples~/02_InventorySync/InventoryItem.cs @@ -0,0 +1,19 @@ +namespace Aspid.Collections.Samples.InventorySync +{ + // Plain model. Lives in pure C# land — no MonoBehaviour, no GameObject. + public sealed class InventoryItem + { + public string Id { get; } + + public int Quantity { get; } + + public string DisplayName { get; } + + public InventoryItem(string id, string displayName, int quantity) + { + Id = id; + Quantity = quantity; + DisplayName = displayName; + } + } +} diff --git a/Samples~/02_InventorySync/InventoryItem.cs.meta b/Samples~/02_InventorySync/InventoryItem.cs.meta new file mode 100644 index 0000000..afc784a --- /dev/null +++ b/Samples~/02_InventorySync/InventoryItem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54a1c465b2e8942048370512491c0c3d \ No newline at end of file diff --git a/Samples~/02_InventorySync/InventoryItemView.cs b/Samples~/02_InventorySync/InventoryItemView.cs new file mode 100644 index 0000000..31d18e7 --- /dev/null +++ b/Samples~/02_InventorySync/InventoryItemView.cs @@ -0,0 +1,30 @@ +using System; +using UnityEngine; + +namespace Aspid.Collections.Samples.InventorySync +{ + // The view side of the sync — a GameObject wrapper around a single InventoryItem. + // Implements IDisposable so the *Sync wrapper can clean it up when its model row + // is removed (we pass isDisposable: true when creating the sync below). + public sealed class InventoryItemView : IDisposable + { + public InventoryItem Item { get; } + + public GameObject GameObject { get; } + + public InventoryItemView(InventoryItem item, Transform parent) + { + Item = item; + GameObject = new GameObject($"InventoryItemView[{item.Id}]"); + GameObject.transform.SetParent(parent, worldPositionStays: false); + } + + public void Dispose() + { + // The *Sync wrapper invokes Dispose() on us when our row leaves the source list + // (Remove, RemoveRange, Replace's old element, or Clear). Destroy the visual. + if (GameObject != null) + UnityEngine.Object.Destroy(GameObject); + } + } +} diff --git a/Samples~/02_InventorySync/InventoryItemView.cs.meta b/Samples~/02_InventorySync/InventoryItemView.cs.meta new file mode 100644 index 0000000..2e5db4a --- /dev/null +++ b/Samples~/02_InventorySync/InventoryItemView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52106a67bf6c54b298b5f30cf093a605 \ No newline at end of file diff --git a/Samples~/02_InventorySync/InventorySync.unity b/Samples~/02_InventorySync/InventorySync.unity new file mode 100644 index 0000000..56185e5 --- /dev/null +++ b/Samples~/02_InventorySync/InventorySync.unity @@ -0,0 +1,170 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 + m_Name: InventorySync + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &101 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 79c3e8704107443c3aa7fa4087431335, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 101} diff --git a/Samples~/02_InventorySync/InventorySync.unity.meta b/Samples~/02_InventorySync/InventorySync.unity.meta new file mode 100644 index 0000000..d8dcccb --- /dev/null +++ b/Samples~/02_InventorySync/InventorySync.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 97797fbb3ed44fbd98fb085dd5d6cb82 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/02_InventorySync/InventorySyncSample.cs b/Samples~/02_InventorySync/InventorySyncSample.cs new file mode 100644 index 0000000..6e49c0d --- /dev/null +++ b/Samples~/02_InventorySync/InventorySyncSample.cs @@ -0,0 +1,67 @@ +// Sample 02 — Inventory Sync (Model -> View) +// +// Pattern: keep the model as a pure C# ObservableList, and let the +// view side be a synchronized projection ObservableList built +// via CreateSync(). The MonoBehaviour owns the sync handle and disposes it in +// OnDestroy — that detaches the source subscription AND disposes every view. +// +// Two CreateSync overloads are demonstrated: +// - isDisposable: true -> Dispose() is called on each view that leaves the sync +// - Action remove -> custom callback when a view leaves the sync +// +// Public surface: expose the view via IReadOnlyObservableListSync, +// not the mutable wrapper, so callers can't mutate the projection directly. + +using UnityEngine; +using Aspid.Collections.Observable; +using Aspid.Collections.Observable.Synchronizer; + +namespace Aspid.Collections.Samples.InventorySync +{ + public sealed class InventorySyncSample : MonoBehaviour + { + private ObservableList _model; + + // Read-only projection of the view side. Callers iterate this; they can't + // (and shouldn't) mutate the views directly — mutations go through the model. + public IReadOnlyObservableListSync Views { get; private set; } + + private void Start() + { + _model = new ObservableList(); + + // The sync is configured to call Dispose() on each InventoryItemView whose model + // row leaves the list (Remove/RemoveRange/Replace-old/Clear). InventoryItemView's + // Dispose() destroys its GameObject. + Views = _model.CreateSync( + converter: item => new InventoryItemView(item, transform), + isDisposable: true); + + // Driving the model — single-item and batch operations both propagate. + _model.Add(new InventoryItem("sword", "Iron Sword", 1)); + _model.Add(new InventoryItem("shield", "Wooden Shield", 1)); + + _model.AddRange( + new InventoryItem("potion", "Healing Potion", 5), + new InventoryItem("scroll", "Scroll of Teleport", 2)); + + // Replace propagates as: old InventoryItemView gets Dispose()d, new one is built. + _model[0] = new InventoryItem("sword", "Steel Sword", 1); + + // Range remove disposes both views' GameObjects in one event. + _model.RemoveRange(1, 2); + + Debug.Log($"Model size = {_model.Count}, view size = {Views.Count}"); + } + + private void OnDestroy() + { + // ORDER MATTERS — dispose the view side first so it can detach its subscription + // from the model. Disposing the model first would still work (Dispose just nulls + // its handler list), but reversing the order makes the ownership graph obvious: + // the wrapper depends on the source, not the other way round. + Views?.Dispose(); + _model?.Dispose(); + } + } +} diff --git a/Samples~/02_InventorySync/InventorySyncSample.cs.meta b/Samples~/02_InventorySync/InventorySyncSample.cs.meta new file mode 100644 index 0000000..fa04add --- /dev/null +++ b/Samples~/02_InventorySync/InventorySyncSample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79c3e8704107443c3aa7fa4087431335 \ No newline at end of file diff --git a/Samples~/03_FilteredInventory.meta b/Samples~/03_FilteredInventory.meta new file mode 100644 index 0000000..7926e67 --- /dev/null +++ b/Samples~/03_FilteredInventory.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6af844177a4943ef8224d0bad92d006 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef b/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef new file mode 100644 index 0000000..62318ba --- /dev/null +++ b/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.Collections.Samples.FilteredInventory", + "rootNamespace": "Aspid.Collections.Samples.FilteredInventory", + "references": [ + "Aspid.Collections.Observable" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef.meta b/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef.meta new file mode 100644 index 0000000..f5b5d54 --- /dev/null +++ b/Samples~/03_FilteredInventory/Aspid.Collections.Samples.FilteredInventory.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6fcda4fd3911343b297d68728022d23a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/03_FilteredInventory/Category.cs b/Samples~/03_FilteredInventory/Category.cs new file mode 100644 index 0000000..24aadc2 --- /dev/null +++ b/Samples~/03_FilteredInventory/Category.cs @@ -0,0 +1,9 @@ +namespace Aspid.Collections.Samples.FilteredInventory +{ + public enum Category + { + Weapon, + Potion, + Quest, + } +} diff --git a/Samples~/03_FilteredInventory/Category.cs.meta b/Samples~/03_FilteredInventory/Category.cs.meta new file mode 100644 index 0000000..528b6d4 --- /dev/null +++ b/Samples~/03_FilteredInventory/Category.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e39fa91d8ca14574bb720a6c55730f62 +timeCreated: 1779538791 \ No newline at end of file diff --git a/Samples~/03_FilteredInventory/FilteredInventory.unity b/Samples~/03_FilteredInventory/FilteredInventory.unity new file mode 100644 index 0000000..e0ad661 --- /dev/null +++ b/Samples~/03_FilteredInventory/FilteredInventory.unity @@ -0,0 +1,172 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 + m_Name: FilteredInventory + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &101 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1992b519fa30d43d5a3334a1386716e4, type: 3} + m_Name: + m_EditorClassIdentifier: + _categoryFilter: 0 + _searchFilter: +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 101} diff --git a/Samples~/03_FilteredInventory/FilteredInventory.unity.meta b/Samples~/03_FilteredInventory/FilteredInventory.unity.meta new file mode 100644 index 0000000..a40788f --- /dev/null +++ b/Samples~/03_FilteredInventory/FilteredInventory.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b75cc9b24ee54b75b1a3be621cdb6a19 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/03_FilteredInventory/FilteredInventorySample.cs b/Samples~/03_FilteredInventory/FilteredInventorySample.cs new file mode 100644 index 0000000..9ac0138 --- /dev/null +++ b/Samples~/03_FilteredInventory/FilteredInventorySample.cs @@ -0,0 +1,114 @@ +// Sample 03 — Filtered Inventory (Search + Categories) +// +// Demonstrates chaining: ObservableList -> FilteredList (category) -> FilteredList +// (search). Each FilteredList subscribes to its source and re-projects incrementally +// when the source changes. Set Filter / Comparer at any time to re-evaluate. +// +// Gotchas this sample makes visible: +// 1. Dispose order is bottom-up. The search wrapper depends on the category wrapper +// which depends on the source. Dispose them in reverse construction order so each +// detaches its subscription cleanly. +// 2. FilteredList is single-thread. The source ObservableList guards itself with +// SyncRoot, but the filter's internal index map is mutated in place from the +// source's CollectionChanged callback. Touch the chain from one thread only. + +using System; +using UnityEngine; +using System.Collections.Generic; +using Aspid.Collections.Observable; +using Aspid.Collections.Observable.Filtered; + +namespace Aspid.Collections.Samples.FilteredInventory +{ + public sealed class FilteredInventorySample : MonoBehaviour + { + [Tooltip("Category to keep. Change at runtime to re-evaluate the category filter.")] + [SerializeField] private Category _categoryFilter = Category.Weapon; + + [Tooltip("Sub-string to keep. Empty string disables the search filter.")] + [SerializeField] private string _searchFilter = ""; + + private ObservableList _source; + private FilteredList _bySearch; + private FilteredList _byCategory; + + private void Start() + { + _source = new ObservableList(new Item[] + { + new("Iron Sword", Category.Weapon, 1), + new("Steel Sword", Category.Weapon, 5), + new("Healing Potion", Category.Potion, 1), + new("Greater Potion", Category.Potion, 3), + new("Royal Letter", Category.Quest, 1), + }); + + // First wrapper filters by category, and sorts alphabetically by name. + // Positional args here — there are two CreateFiltered overloads with the same + // parameter names in different positions, so calling by name would be ambiguous. + // The lambda fixes the second-arg type to Predicate, picking the correct overload. + _byCategory = _source.CreateFiltered( + item => item.Category == _categoryFilter, + Comparer.Create((a, b) => string.CompareOrdinal(a.Name, b.Name))); + + // Second wrapper feeds off the first. CreateFiltered detects that the source is + // an IReadOnlyFilteredList and subscribes to its untyped CollectionChanged event + // (the inner wrapper re-projects fully whenever the outer source mutates). + _bySearch = _byCategory.CreateFiltered(item => + string.IsNullOrEmpty(_searchFilter) + || item.Name.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0); + + _bySearch.CollectionChanged += OnViewChanged; + OnViewChanged(); + + // Drive the source — both wrappers re-evaluate incrementally. + _source.Add(new Item("Dragon Slayer", Category.Weapon, 10)); + _source.Insert(0, new Item("Mana Potion", Category.Potion, 2)); + } + + // Public setters that mutate the filters at runtime. Set Filter on a FilteredList + // and the wrapper rebuilds its index map and fires CollectionChanged. + public void SetCategory(Category category) + { + _categoryFilter = category; + // Re-assigning Filter forces an internal Update() — needed because we capture + // _categoryFilter by closure, not via FilteredList's value. + _byCategory.Filter = item => item.Category == category; + } + + public void SetSearch(string search) + { + _searchFilter = search; + _bySearch.Filter = item => string.IsNullOrEmpty(search) + || item.Name.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private void OnViewChanged() + { + var snapshot = "(none)"; + if (_bySearch.Count > 0) + { + var names = new string[_bySearch.Count]; + for (var i = 0; i < _bySearch.Count; i++) + names[i] = _bySearch[i].Name; + snapshot = string.Join(", ", names); + } + + Debug.Log($"[Filtered view] count={_bySearch.Count}: {snapshot}"); + } + + private void OnDestroy() + { + // Reverse construction order: each wrapper detaches its subscription from + // the source it was built on. Skipping any link here leaves the source + // holding a live event-handler reference and keeps the chain in memory. + if (_bySearch is not null) + { + _bySearch.CollectionChanged -= OnViewChanged; + _bySearch.Dispose(); + } + _byCategory?.Dispose(); + _source?.Dispose(); + } + } +} diff --git a/Samples~/03_FilteredInventory/FilteredInventorySample.cs.meta b/Samples~/03_FilteredInventory/FilteredInventorySample.cs.meta new file mode 100644 index 0000000..5879e5e --- /dev/null +++ b/Samples~/03_FilteredInventory/FilteredInventorySample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1992b519fa30d43d5a3334a1386716e4 \ No newline at end of file diff --git a/Samples~/03_FilteredInventory/Item.cs b/Samples~/03_FilteredInventory/Item.cs new file mode 100644 index 0000000..558f267 --- /dev/null +++ b/Samples~/03_FilteredInventory/Item.cs @@ -0,0 +1,21 @@ +namespace Aspid.Collections.Samples.FilteredInventory +{ + public sealed class Item + { + public int Level { get; } + + public string Name { get; } + + public Category Category { get; } + + public Item(string name, Category category, int level) + { + Name = name; + Level = level; + Category = category; + } + + public override string ToString() => + $"{Name} ({Category}, lvl {Level})"; + } +} diff --git a/Samples~/03_FilteredInventory/Item.cs.meta b/Samples~/03_FilteredInventory/Item.cs.meta new file mode 100644 index 0000000..626aafe --- /dev/null +++ b/Samples~/03_FilteredInventory/Item.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a847666b0ed04840ba0d03f4d099d1e8 +timeCreated: 1779538803 \ No newline at end of file diff --git a/Samples~/04_DictionaryKeyedTable.meta b/Samples~/04_DictionaryKeyedTable.meta new file mode 100644 index 0000000..f5863b5 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 703358a67d8b04cb0ab7f8654af08e2a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef b/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef new file mode 100644 index 0000000..95287d7 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.Collections.Samples.DictionaryKeyedTable", + "rootNamespace": "Aspid.Collections.Samples.DictionaryKeyedTable", + "references": [ + "Aspid.Collections.Observable" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef.meta b/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef.meta new file mode 100644 index 0000000..3789f4c --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/Aspid.Collections.Samples.DictionaryKeyedTable.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 38b818b556fa94a1a8496018abdabba6 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/04_DictionaryKeyedTable/Leaderboard.unity b/Samples~/04_DictionaryKeyedTable/Leaderboard.unity new file mode 100644 index 0000000..2c07827 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/Leaderboard.unity @@ -0,0 +1,170 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 + m_Name: Leaderboard + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &101 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e9c0b604b6b3b417eb3ba27749a68dd2, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 101} diff --git a/Samples~/04_DictionaryKeyedTable/Leaderboard.unity.meta b/Samples~/04_DictionaryKeyedTable/Leaderboard.unity.meta new file mode 100644 index 0000000..aeeb307 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/Leaderboard.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4a32b38baa0c44a3a479560ad149cf9c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs b/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs new file mode 100644 index 0000000..81b029a --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs @@ -0,0 +1,75 @@ +// Sample 04 — Dictionary as Keyed Table +// +// ObservableDictionary drives a keyed UI table — think leaderboard +// indexed by playerId. Unlike the list, every event carries a KeyValuePair, and the relevant index is the key, not a positional one: +// +// - Add -> NewItem is the (key, value) pair just inserted +// - Replace -> OldItem and NewItem share the same key; value changed +// - Remove -> OldItem is the (key, value) pair just deleted +// - Reset -> from Clear(); no payload +// +// NewStartingIndex / OldStartingIndex are -1 for the dictionary because position +// is meaningless. Subscribers should dispatch on `args.NewItem.Key`, not on index. + +using UnityEngine; +using System.Collections.Generic; +using Aspid.Collections.Observable; +using System.Collections.Specialized; + +namespace Aspid.Collections.Samples.DictionaryKeyedTable +{ + public sealed class LeaderboardSample : MonoBehaviour + { + private ObservableDictionary _scores; + + // Expose only the read-only projection. Callers can subscribe and read by key, + // but mutation stays in the owner (this MonoBehaviour). + public IReadOnlyObservableDictionary Scores => _scores; + + private void Start() + { + _scores = new ObservableDictionary(); + _scores.CollectionChanged += OnLeaderboardChanged; + + // Add new entries by key. + _scores.Add("p-001", new PlayerScore("alice", 100)); + _scores.Add("p-002", new PlayerScore("bob", 80)); + + // Setting an existing key emits Replace. Setting a missing key emits Add + // (ObservableDictionary's indexer setter routes to Add() when key is absent). + _scores["p-001"] = new PlayerScore("alice", 150); + + // Remove by key. + _scores.Remove("p-002"); + + Debug.Log($"Leaderboard size = {_scores.Count}"); + } + + private void OnDestroy() => + _scores?.Dispose(); + + private static void OnLeaderboardChanged(INotifyCollectionChangedEventArgs> args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Log($"[+] {args.NewItem.Key}: \"{args.NewItem.Value.DisplayName}\" with {args.NewItem.Value.Score}"); + break; + + case NotifyCollectionChangedAction.Replace: + // Same key, different value. Old payload comes via OldItem. + Debug.Log($"[~] {args.NewItem.Key}: {args.OldItem.Value.Score} -> {args.NewItem.Value.Score}"); + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Log($"[-] {args.OldItem.Key}: \"{args.OldItem.Value.DisplayName}\""); + break; + + case NotifyCollectionChangedAction.Reset: + Debug.Log("[*] Leaderboard cleared"); + break; + } + } + } +} diff --git a/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs.meta b/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs.meta new file mode 100644 index 0000000..fc72050 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/LeaderboardSample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9c0b604b6b3b417eb3ba27749a68dd2 \ No newline at end of file diff --git a/Samples~/04_DictionaryKeyedTable/PlayerScore.cs b/Samples~/04_DictionaryKeyedTable/PlayerScore.cs new file mode 100644 index 0000000..46f83cb --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/PlayerScore.cs @@ -0,0 +1,15 @@ +namespace Aspid.Collections.Samples.DictionaryKeyedTable +{ + public readonly struct PlayerScore + { + public int Score { get; } + + public string DisplayName { get; } + + public PlayerScore(string displayName, int score) + { + DisplayName = displayName; + Score = score; + } + } +} diff --git a/Samples~/04_DictionaryKeyedTable/PlayerScore.cs.meta b/Samples~/04_DictionaryKeyedTable/PlayerScore.cs.meta new file mode 100644 index 0000000..424a247 --- /dev/null +++ b/Samples~/04_DictionaryKeyedTable/PlayerScore.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 83ee92d6660e4f948e19302da3d1ea66 +timeCreated: 1779538999 \ No newline at end of file diff --git a/Samples~/05_SplitByEvents.meta b/Samples~/05_SplitByEvents.meta new file mode 100644 index 0000000..b47f778 --- /dev/null +++ b/Samples~/05_SplitByEvents.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 54e043b04e5df49cda199b2b73188e8b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/05_SplitByEvents/AnalyticsTap.unity b/Samples~/05_SplitByEvents/AnalyticsTap.unity new file mode 100644 index 0000000..57edb64 --- /dev/null +++ b/Samples~/05_SplitByEvents/AnalyticsTap.unity @@ -0,0 +1,170 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 + m_Name: AnalyticsTap + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &101 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 100} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5934a217d52b84ed99c110f21ab717b9, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 101} diff --git a/Samples~/05_SplitByEvents/AnalyticsTap.unity.meta b/Samples~/05_SplitByEvents/AnalyticsTap.unity.meta new file mode 100644 index 0000000..4263ca6 --- /dev/null +++ b/Samples~/05_SplitByEvents/AnalyticsTap.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d37a309286b24f19ab3d7e1aacb38f66 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/05_SplitByEvents/AnalyticsTapSample.cs b/Samples~/05_SplitByEvents/AnalyticsTapSample.cs new file mode 100644 index 0000000..e7aa5e8 --- /dev/null +++ b/Samples~/05_SplitByEvents/AnalyticsTapSample.cs @@ -0,0 +1,71 @@ +// Sample 05 — SplitByEvents (Analytics Tap) +// +// SplitByEvents() takes one fat CollectionChanged event and splits it into +// per-action callbacks (Added / Removed / Replaced / Moved / Reset). The +// extension returns an IObservableEvents handle that owns the subscription: +// hold the handle for as long as you want the tap alive, Dispose() it to detach. +// +// Why this matters: subscribing to CollectionChanged += ... directly is easy to +// get wrong — people forget to unsubscribe on scene unload and leak the source. +// The Dispose-as-subscription-token pattern makes ownership explicit. + +using UnityEngine; +using System.Collections.Generic; +using Aspid.Collections.Observable; + +namespace Aspid.Collections.Samples.SplitByEvents +{ + public sealed class AnalyticsTapSample : MonoBehaviour + { + private ObservableList _list; + + // The tap is the source of truth for "this MonoBehaviour holds an active + // subscription on _list". Dispose it in OnDestroy. + private IObservableEvents _tap; + + private void Start() + { + _list = new ObservableList(); + + _tap = _list.SplitByEvents( + added: OnAdded, + removed: OnRemoved, + replaced: OnReplaced, + moved: OnMoved, + reset: OnReset); + + // Each operation hits exactly one callback above. Single-item events + // surface as a one-element IReadOnlyList on the Added/Removed side, + // so callers don't have to branch on IsSingleItem themselves. + _list.Add("first"); + _list.AddRange("second", "third"); + _list[0] = "FIRST"; + _list.Move(0, 2); + _list.RemoveAt(0); + _list.Clear(); + } + + private void OnDestroy() + { + // Disposing the tap unsubscribes from _list and frees the callback delegates. + // After this, mutations to _list will no longer reach OnAdded / etc. + _tap?.Dispose(); + _list?.Dispose(); + } + + private static void OnAdded(IReadOnlyList items, int startIndex) => + Debug.Log($"[analytics:add] start={startIndex} items=[{string.Join(",", items)}]"); + + private static void OnRemoved(IReadOnlyList items, int startIndex) => + Debug.Log($"[analytics:remove] start={startIndex} items=[{string.Join(",", items)}]"); + + private static void OnReplaced(IReadOnlyList oldItems, IReadOnlyList newItems, int startIndex) => + Debug.Log($"[analytics:replace] at={startIndex} [{string.Join(",", oldItems)}] -> [{string.Join(",", newItems)}]"); + + private static void OnMoved(IReadOnlyList items, int oldIndex, int newIndex) => + Debug.Log($"[analytics:move] {oldIndex}->{newIndex} items=[{string.Join(",", items)}]"); + + private static void OnReset() => + Debug.Log("[analytics:reset]"); + } +} diff --git a/Samples~/05_SplitByEvents/AnalyticsTapSample.cs.meta b/Samples~/05_SplitByEvents/AnalyticsTapSample.cs.meta new file mode 100644 index 0000000..8b2f49d --- /dev/null +++ b/Samples~/05_SplitByEvents/AnalyticsTapSample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5934a217d52b84ed99c110f21ab717b9 \ No newline at end of file diff --git a/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef b/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef new file mode 100644 index 0000000..e414f2a --- /dev/null +++ b/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Aspid.Collections.Samples.SplitByEvents", + "rootNamespace": "Aspid.Collections.Samples.SplitByEvents", + "references": [ + "Aspid.Collections.Observable" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef.meta b/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef.meta new file mode 100644 index 0000000..2fbdae8 --- /dev/null +++ b/Samples~/05_SplitByEvents/Aspid.Collections.Samples.SplitByEvents.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5c16b0c7f86824d009302950a24ed8fe +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 4c382ec47a144698bc241dd3b645e8df90e61eee Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Sat, 23 May 2026 17:02:48 +0300 Subject: [PATCH 4/7] Expose Samples~ via package.json manifest and refresh docs - package.json: add the "samples" array so the Package Manager UI lists all five samples as importable (displayName, description, path each). - CLAUDE.md: add Samples~ to the directory layout, add the Aspid.Collections.Samples.* row to the asmdef table, and document the "subfolder + samples-manifest entry" rule for adding new samples. - CHANGELOG.md: note the new Samples~/ directory under Unreleased > Added. --- CHANGELOG.md | 1 + CLAUDE.md | 24 +++++++++++++++++++++--- package.json | 29 ++++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6d035..25cb493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. +- `Samples~/` directory with five importable samples (`package.json` `samples` manifest): basic change-notification dispatch, model→view sync via `CreateSync`, chained `FilteredList` for search+category, `ObservableDictionary` as a keyed table, and `SplitByEvents` as an analytics tap. Each sample ships its own `Aspid.Collections.Samples.*` asmdef. ### Changed - `ObservableQueueSync` / `ObservableStackSync` / `ObservableDictionarySync`: unsupported actions now throw `NotSupportedException` with a descriptive message instead of `NotImplementedException`. diff --git a/CLAUDE.md b/CLAUDE.md index 8808526..cb3dbfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,9 +37,15 @@ Collections/ │ │ └── Extensions/ # CreateFilteredExtensions │ └── Synchronizer/ # Observable*Sync, IReadOnly*Sync │ └── Extensions/ # CreateSyncExtensions -└── Tests/Runtime/Observable/ # Unity Test Framework tests - ├── Helpers/ - └── Performance/ # Optional perf benchmarks (gated) +├── Tests/Runtime/Observable/ # Unity Test Framework tests +│ ├── Helpers/ +│ └── Performance/ # Optional perf benchmarks (gated) +└── Samples~/ # UPM-importable samples + ├── 01_BasicChangeNotifications/ # (Unity hides the `~` folder from + ├── 02_InventorySync/ # the AssetDatabase until imported + ├── 03_FilteredInventory/ # via Package Manager UI) + ├── 04_DictionaryKeyedTable/ + └── 05_SplitByEvents/ ``` ## Namespaces @@ -56,6 +62,7 @@ Collections/ | `Aspid.Collections.Observable.asmdef` | Runtime (`noEngineReferences: true`) | | `Aspid.Collections.Tests.asmdef` | Unity Test Framework tests (`noEngineReferences: true`, compiled when `UNITY_INCLUDE_TESTS` is defined) | | `Aspid.Collections.Observable.PerformanceTests.asmdef` | Perf benchmarks; compiled when `UNITY_INCLUDE_TESTS` is defined and references `Unity.PerformanceTesting` directly, so the asmdef also requires `com.unity.test-framework.performance` to be installed. `ASPID_COLLECTIONS_PERFORMANCE_TESTING` is auto-set via `versionDefines` for code-level `#if`-gating. | +| `Aspid.Collections.Samples.*.asmdef` (5 of them, one per sample folder under `Samples~/`) | Importable samples. Unlike the runtime, samples *do* reference `UnityEngine` (`noEngineReferences: false`) — they use `MonoBehaviour`, `GameObject`, `Debug.Log`. They are not compiled in-place: Unity copies a sample into `Assets/Samples/...` on import (Package Manager UI), and only then the asmdef participates in the consuming project's build. | ## Testing @@ -95,6 +102,17 @@ in `Tests/Runtime/Observable/Helpers/`. `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). +- **Samples live in `Samples~/` and are listed in `package.json`.** Each + importable sample is declared in the `samples` array of `package.json` + with a `displayName`, `description`, and `path`. The `~` suffix tells + Unity to skip the folder during asset import in the package itself — + the contents only land in `Assets/Samples////` + after the user clicks "Import" in the Package Manager UI. Adding a new + sample means **both**: a new subfolder under `Samples~/` *and* a new + entry in the `samples` manifest. Sample asmdefs reference + `Aspid.Collections.Observable` and freely use `UnityEngine` types — + this is the one place in the package where engine references are + allowed. - **Performance tests need the perf package.** `Tests/Runtime/Observable/Performance/` references `Unity.PerformanceTesting` directly, so the asmdef only resolves when diff --git a/package.json b/package.json index ae92479..c00df9e 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,32 @@ }, "changelogUrl": "https://github.com/VPDPersonal/Aspid.Collections/blob/main/CHANGELOG.md", "documentationUrl": "https://github.com/VPDPersonal/Aspid.Collections", - "licensesUrl": "https://github.com/VPDPersonal/Aspid.Collections/blob/main/LICENSE" + "licensesUrl": "https://github.com/VPDPersonal/Aspid.Collections/blob/main/LICENSE", + "samples": [ + { + "displayName": "01 Basic Change Notifications", + "description": "Hello-world for ObservableList. Walks through single-item vs batch forms of NotifyCollectionChangedEventArgs, the custom struct event args, and the Action switch.", + "path": "Samples~/01_BasicChangeNotifications" + }, + { + "displayName": "02 Inventory Sync (Model -> View)", + "description": "Projects an ObservableList POCO model into an Observable*Sync view via CreateSync, with proper Dispose ownership in OnDestroy.", + "path": "Samples~/02_InventorySync" + }, + { + "displayName": "03 Filtered Inventory (Search + Categories)", + "description": "Chains FilteredList wrappers for search + category filtering, and demonstrates the single-thread mutation rule and the Dispose-in-reverse-order discipline.", + "path": "Samples~/03_FilteredInventory" + }, + { + "displayName": "04 Dictionary as Keyed Table", + "description": "ObservableDictionary driving a keyed UI table (leaderboard by playerId). Shows per-key Add/Replace/Remove events and exposing an IReadOnlyObservableDictionary projection.", + "path": "Samples~/04_DictionaryKeyedTable" + }, + { + "displayName": "05 SplitByEvents (Analytics Tap)", + "description": "Attaches an analytics/logging tap to an ObservableCollection via SplitByEvents. The returned IObservableEvents handle owns the subscription — Dispose it to unsubscribe.", + "path": "Samples~/05_SplitByEvents" + } + ] } From 98fe881963530f0e8641b3f64fc2b00704527508 Mon Sep 17 00:00:00 2001 From: Vladislav Panin Date: Sat, 23 May 2026 17:23:22 +0300 Subject: [PATCH 5/7] Bump version to 1.1.0 and finalize changelog Promote the [Unreleased] block to [1.1.0] dated 2026-05-23 and fill in items that were missing: the package rename from com.aspid.collections to tech.aspid.collections (called out as BREAKING with a manifest.json migration note), the Tests/Runtime reorganization and asmdef rename, removal of Editor-only includePlatforms, removal of the redundant ASPID_COLLECTIONS_PERFORMANCE_TESTING from defineConstraints, the new package.json metadata (unityRelease, com.unity.test-framework dependency, URLs), and the negative startIndex validation in ObservableList.RemoveRange. Also fix the stale Tests/Observable/Performance/ path to Tests/Runtime/Observable/Performance/. --- CHANGELOG.md | 10 ++++++++-- package.json | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cb493..4836aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] — 2026-05-23 + ### 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. +- Performance benchmark scaffold under `Tests/Runtime/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. - `Samples~/` directory with five importable samples (`package.json` `samples` manifest): basic change-notification dispatch, model→view sync via `CreateSync`, chained `FilteredList` for search+category, `ObservableDictionary` as a keyed table, and `SplitByEvents` as an analytics tap. Each sample ships its own `Aspid.Collections.Samples.*` asmdef. +- `package.json` metadata: `unityRelease` field, a `com.unity.test-framework@1.6.0` dependency, and `author.url` / `changelogUrl` / `documentationUrl` / `licensesUrl` entries. ### Changed +- **BREAKING** — package renamed from `com.aspid.collections` to `tech.aspid.collections`. Consumers must update the identifier in their project's `Packages/manifest.json` (and any scoped-registry / Git-URL references) when upgrading from 1.0.x. +- Tests reorganized under `Tests/Runtime/Observable/` to match Unity's standard runtime/editor split. The test assembly is renamed from `Aspid.Collections.Observable.Tests` to `Aspid.Collections.Tests` (now with an empty `rootNamespace`), and the `Editor`-only `includePlatforms` constraint is dropped from both the test and performance asmdefs so suites compile and run on player platforms too. +- Redundant `ASPID_COLLECTIONS_PERFORMANCE_TESTING` entry removed from the performance asmdef's `defineConstraints` — the define still ships via `versionDefines` when `com.unity.test-framework.performance` is installed. - `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`). @@ -25,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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]`). +- `ObservableList.RemoveRange` validates both `startIndex < 0` and `count < 0` → `ArgumentOutOfRangeException` before taking the lock (previously `OverflowException` from `new T[-1]` for `count`, and `IndexOutOfRangeException` from the per-item read for `startIndex`). - `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 diff --git a/package.json b/package.json index c00df9e..ccd0d37 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tech.aspid.collections", "displayName": "Aspid.Collections", - "version": "1.0.2", + "version": "1.1.0", "unity": "2021.3", "unityRelease": "0f1", "description": "A set of covariate observation collections with easy synchronization between two collections, filtering and sorting.", From abf7df807926c2cedec4449337c492fa08d44ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:35:35 +0000 Subject: [PATCH 6/7] Clarify perf asmdef breaking change in CHANGELOG [1.1.0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old bullet understated the impact — without com.unity.test-framework.performance the perf asmdef now fails to compile rather than being silently excluded. https://claude.ai/code/session_01BBwbkSzkSkbgDaY3aSjV79 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4836aeb..8995cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING** — package renamed from `com.aspid.collections` to `tech.aspid.collections`. Consumers must update the identifier in their project's `Packages/manifest.json` (and any scoped-registry / Git-URL references) when upgrading from 1.0.x. - Tests reorganized under `Tests/Runtime/Observable/` to match Unity's standard runtime/editor split. The test assembly is renamed from `Aspid.Collections.Observable.Tests` to `Aspid.Collections.Tests` (now with an empty `rootNamespace`), and the `Editor`-only `includePlatforms` constraint is dropped from both the test and performance asmdefs so suites compile and run on player platforms too. -- Redundant `ASPID_COLLECTIONS_PERFORMANCE_TESTING` entry removed from the performance asmdef's `defineConstraints` — the define still ships via `versionDefines` when `com.unity.test-framework.performance` is installed. +- **BREAKING** — `ASPID_COLLECTIONS_PERFORMANCE_TESTING` removed from the performance asmdef's `defineConstraints`. Without `com.unity.test-framework.performance` installed the perf asmdef now **fails to compile** instead of being silently excluded; the define is still auto-set via `versionDefines` when the package is present. - `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`). From 7e0ef29ed28a31cc27193765cb0b51bc6142e98f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:37:11 +0000 Subject: [PATCH 7/7] Update CLAUDE.md package version to v1.1.0 https://claude.ai/code/session_01RXusYuELejVsywy3Juzv1u --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb3dbfc..73ded1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ collections with synchronization, filtering, and sorting. ## Package Info -- **Package name**: `tech.aspid.collections` (`package.json`, v1.0.2) +- **Package name**: `tech.aspid.collections` (`package.json`, v1.1.0) - **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