Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.idea/
.zed/
.vscode/

# Override the global "*~" rule that hides Unity's Samples~ folder.
!Samples~/
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +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<T>`: 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<T>.RemoveRange(int startIndex, int count)` and the protected `OnRemovedRange(in IReadOnlyList<T>)` hook. `ObservableListSync<TFrom, TTo>` forwards source batch removes through it.
- Batch propagation in `ObservableDictionarySync<TKey, TFrom, TTo>` and `ObservableHashSetSync<TFrom, TTo>` for `Add` / `Remove` (and `Replace` on Dictionary) — previously threw `NotImplementedException`.
- `CollectionChangedEvent<T>`: re-entrancy-safe via copy-on-write of the subscriber list during `Invoke`; a lazy `HashSet<Handler>` 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.
- **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`).

Expand All @@ -24,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<T>.RemoveRange` validates `count < 0` → `ArgumentOutOfRangeException` (previously `OverflowException` from `new T[-1]`).
- `ObservableList<T>.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
Expand Down
53 changes: 38 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,9 +37,15 @@ Collections/
│ │ └── Extensions/ # CreateFilteredExtensions
│ └── Synchronizer/ # Observable*Sync, IReadOnly*Sync
│ └── Extensions/ # CreateSyncExtensions
└── Tests/Observable/ # EditMode tests (UTF)
├── 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
Expand All @@ -54,15 +60,18 @@ 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. |
| `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

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)

Expand Down Expand Up @@ -93,11 +102,25 @@ 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.
- **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/<package>/<version>/<sample>/`
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
`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

Expand Down
2 changes: 1 addition & 1 deletion Runtime/Observable/ObservableDictionary.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Samples~/01_BasicChangeNotifications.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Sample 01 — Basic Change Notifications
//
// Walks through every shape of NotifyCollectionChangedEventArgs<T> emitted
// by ObservableList<T>: 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<string> _list;

private void Start()
{
_list = new ObservableList<string>();
_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<T> 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<string> 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<T> 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<string> items)
{
if (items is null || items.Count == 0) return "[]";
return "[" + string.Join(", ", items) + "]";
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading