mvc-kit 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,231 @@
1
+ import { Collection } from './Collection';
2
+ import { walkPrototypeChain } from './walkPrototypeChain';
3
+ import { wrapAsyncMethods } from './wrapAsyncMethods';
4
+ import type { InternalTaskState } from './wrapAsyncMethods';
5
+ import type { Listener, TaskState } from './types';
6
+
7
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
8
+
9
+ // ── Async tracking types ────────────────────────────────────────
10
+
11
+ const DEFAULT_TASK_STATE: TaskState = Object.freeze({ loading: false, error: null, errorCode: null });
12
+
13
+ export type ResourceAsyncMethodKeys<T> = {
14
+ [K in Exclude<keyof T, keyof Resource<any>>]: T[K] extends (...args: any[]) => Promise<any> ? K : never;
15
+ }[Exclude<keyof T, keyof Resource<any>>];
16
+
17
+ type ResourceAsyncMap<T> = {
18
+ readonly [K in ResourceAsyncMethodKeys<T>]: TaskState;
19
+ };
20
+
21
+ const RESERVED_ASYNC_KEYS = ['async', 'subscribeAsync'] as const;
22
+ const LIFECYCLE_HOOKS = new Set(['onInit', 'onDispose']);
23
+
24
+ // ── Resource ────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Collection + async tracking toolkit. Extends Collection with lifecycle
28
+ * (init/dispose) and automatic async method tracking. Optionally delegates
29
+ * to an external Collection for shared data scenarios.
30
+ */
31
+ export class Resource<T extends { id: string | number }> extends Collection<T> {
32
+ private _external: Collection<T> | null = null;
33
+ private _initialized = false;
34
+
35
+ // ── Async tracking fields ──
36
+ private _asyncStates = new Map<string, InternalTaskState>();
37
+ private _asyncSnapshots = new Map<string, TaskState>();
38
+ private _asyncListeners = new Set<() => void>();
39
+ private _asyncProxy: ResourceAsyncMap<this> | null = null;
40
+ private _activeOps: Map<string, number> | null = null;
41
+
42
+ /** DEV-only timeout (ms) for detecting ghost async operations after dispose. */
43
+ static GHOST_TIMEOUT = 3000;
44
+
45
+ constructor(collectionOrItems?: Collection<T> | T[]) {
46
+ const isExternal = collectionOrItems != null && !Array.isArray(collectionOrItems);
47
+ super(isExternal ? [] : (collectionOrItems as T[]) ?? []);
48
+
49
+ if (isExternal) {
50
+ this._external = collectionOrItems as Collection<T>;
51
+
52
+ if (__DEV__) {
53
+ const Ctor = this.constructor as typeof Resource;
54
+ if (Ctor.MAX_SIZE > 0 || Ctor.TTL > 0) {
55
+ console.warn(
56
+ `[mvc-kit] Resource "${Ctor.name}" has MAX_SIZE or TTL set but uses an ` +
57
+ `injected Collection. Configure these on the Collection instead.`
58
+ );
59
+ }
60
+ }
61
+ }
62
+
63
+ this._guardReservedKeys();
64
+ }
65
+
66
+ // ── Lifecycle ─────────────────────────────────────────────────
67
+
68
+ /** Whether init() has been called. */
69
+ get initialized(): boolean {
70
+ return this._initialized;
71
+ }
72
+
73
+ /** Initializes the instance. Called automatically by React hooks after mount. */
74
+ init(): void | Promise<void> {
75
+ if (this._initialized || this.disposed) return;
76
+ this._initialized = true;
77
+
78
+ if (__DEV__) {
79
+ this._activeOps = new Map();
80
+ }
81
+
82
+ wrapAsyncMethods({
83
+ instance: this,
84
+ stopPrototype: Resource.prototype,
85
+ reservedKeys: RESERVED_ASYNC_KEYS,
86
+ lifecycleHooks: LIFECYCLE_HOOKS,
87
+ isDisposed: () => this.disposed,
88
+ isInitialized: () => this._initialized,
89
+ asyncStates: this._asyncStates,
90
+ asyncSnapshots: this._asyncSnapshots,
91
+ asyncListeners: this._asyncListeners,
92
+ notifyAsync: () => this._notifyAsync(),
93
+ addCleanup: (fn) => this.addCleanup(fn),
94
+ ghostTimeout: (this.constructor as typeof Resource).GHOST_TIMEOUT,
95
+ className: 'Resource',
96
+ activeOps: this._activeOps,
97
+ });
98
+
99
+ return this.onInit?.();
100
+ }
101
+
102
+ /** Lifecycle hook called at the end of init(). Override to load initial data. @protected */
103
+ protected onInit?(): void | Promise<void>;
104
+
105
+ // ── Collection delegation ─────────────────────────────────────
106
+
107
+ /** Current items array. Delegates to external Collection when injected. */
108
+ get state(): T[] {
109
+ return this._external ? this._external.state : super.state;
110
+ }
111
+
112
+ /** The raw array of items. Delegates to external Collection when injected. */
113
+ get items(): T[] {
114
+ return this._external ? this._external.items : super.items;
115
+ }
116
+
117
+ /** Number of items. Delegates to external Collection when injected. */
118
+ get length(): number {
119
+ return this._external ? this._external.length : super.length;
120
+ }
121
+
122
+ add(...items: T[]): void {
123
+ this._external ? this._external.add(...items) : super.add(...items);
124
+ }
125
+
126
+ upsert(...items: T[]): void {
127
+ this._external ? this._external.upsert(...items) : super.upsert(...items);
128
+ }
129
+
130
+ update(id: T['id'], changes: Partial<T>): void {
131
+ this._external ? this._external.update(id, changes) : super.update(id, changes);
132
+ }
133
+
134
+ remove(...ids: T['id'][]): void {
135
+ this._external ? this._external.remove(...ids) : super.remove(...ids);
136
+ }
137
+
138
+ reset(items: T[]): void {
139
+ this._external ? this._external.reset(items) : super.reset(items);
140
+ }
141
+
142
+ clear(): void {
143
+ this._external ? this._external.clear() : super.clear();
144
+ }
145
+
146
+ optimistic(callback: () => void): () => void {
147
+ return this._external ? this._external.optimistic(callback) : super.optimistic(callback);
148
+ }
149
+
150
+ get(id: T['id']): T | undefined {
151
+ return this._external ? this._external.get(id) : super.get(id);
152
+ }
153
+
154
+ has(id: T['id']): boolean {
155
+ return this._external ? this._external.has(id) : super.has(id);
156
+ }
157
+
158
+ find(predicate: (item: T) => boolean): T | undefined {
159
+ return this._external ? this._external.find(predicate) : super.find(predicate);
160
+ }
161
+
162
+ filter(predicate: (item: T) => boolean): T[] {
163
+ return this._external ? this._external.filter(predicate) : super.filter(predicate);
164
+ }
165
+
166
+ sorted(compareFn: (a: T, b: T) => number): T[] {
167
+ return this._external ? this._external.sorted(compareFn) : super.sorted(compareFn);
168
+ }
169
+
170
+ map<U>(fn: (item: T) => U): U[] {
171
+ return this._external ? this._external.map(fn) : super.map(fn);
172
+ }
173
+
174
+ subscribe(listener: Listener<T[]>): () => void {
175
+ if (this.disposed) return () => {};
176
+ return this._external ? this._external.subscribe(listener) : super.subscribe(listener);
177
+ }
178
+
179
+ // ── Async tracking API ────────────────────────────────────────
180
+
181
+ /** Proxy providing `TaskState` (loading, error, errorCode) per async method. */
182
+ get async(): ResourceAsyncMap<this> {
183
+ if (!this._asyncProxy) {
184
+ const self = this;
185
+ this._asyncProxy = new Proxy({} as ResourceAsyncMap<this>, {
186
+ get(_, prop: string) {
187
+ return self._asyncSnapshots.get(prop) ?? DEFAULT_TASK_STATE;
188
+ },
189
+ has(_, prop: string) {
190
+ return self._asyncSnapshots.has(prop);
191
+ },
192
+ ownKeys() {
193
+ return Array.from(self._asyncSnapshots.keys());
194
+ },
195
+ getOwnPropertyDescriptor(_, prop: string) {
196
+ if (self._asyncSnapshots.has(prop)) {
197
+ return { configurable: true, enumerable: true, value: self._asyncSnapshots.get(prop) };
198
+ }
199
+ return undefined;
200
+ },
201
+ });
202
+ }
203
+ return this._asyncProxy;
204
+ }
205
+
206
+ /** Subscribes to async state changes. Used by `useInstance` for React integration. */
207
+ subscribeAsync(listener: () => void): () => void {
208
+ if (this.disposed) return () => {};
209
+ this._asyncListeners.add(listener);
210
+ return () => { this._asyncListeners.delete(listener); };
211
+ }
212
+
213
+ // ── Private: async tracking internals ─────────────────────────
214
+
215
+ private _notifyAsync(): void {
216
+ for (const listener of this._asyncListeners) {
217
+ listener();
218
+ }
219
+ }
220
+
221
+ private _guardReservedKeys(): void {
222
+ walkPrototypeChain(this, Resource.prototype, (key) => {
223
+ if (RESERVED_ASYNC_KEYS.includes(key as any)) {
224
+ throw new Error(
225
+ `[mvc-kit] "${key}" is a reserved property on Resource and cannot be overridden.`
226
+ );
227
+ }
228
+ });
229
+ }
230
+
231
+ }
@@ -0,0 +1,155 @@
1
+ # Selection
2
+
3
+ Key-based selection state manager. Tracks a set of selected keys with toggle, select-all, and filter semantics. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use Selection as a ViewModel property to manage row/item selection in tables and lists. Auto-tracking ensures ViewModel getters that read from Selection auto-invalidate when selection changes.
10
+
11
+ ---
12
+
13
+ ## Creating a Selection Instance
14
+
15
+ ```typescript
16
+ import { Selection } from 'mvc-kit';
17
+
18
+ // String keys (default)
19
+ readonly selection = new Selection<string>();
20
+
21
+ // Numeric keys
22
+ readonly selection = new Selection<number>();
23
+ ```
24
+
25
+ ---
26
+
27
+ ## API
28
+
29
+ ### Readable State
30
+
31
+ #### `selected: ReadonlySet<K>`
32
+
33
+ The set of selected keys. Returns a new Set reference on each change (safe for React rendering).
34
+
35
+ #### `count: number`
36
+
37
+ Number of selected keys.
38
+
39
+ #### `hasSelection: boolean`
40
+
41
+ Whether any keys are selected.
42
+
43
+ ### Query
44
+
45
+ #### `isSelected(key: K): boolean`
46
+
47
+ Whether the given key is selected.
48
+
49
+ ### Actions
50
+
51
+ #### `toggle(key: K): void`
52
+
53
+ Toggle a key in/out of the selection.
54
+
55
+ #### `select(...keys: K[]): void`
56
+
57
+ Add keys to the selection. No-op (no notification) if all keys already selected.
58
+
59
+ #### `deselect(...keys: K[]): void`
60
+
61
+ Remove keys from the selection. No-op (no notification) if none were selected.
62
+
63
+ #### `toggleAll(allKeys: K[]): void`
64
+
65
+ If all `allKeys` are selected, deselect all. Otherwise, select all. Empty `allKeys` array is a no-op.
66
+
67
+ #### `set(...keys: K[]): void`
68
+
69
+ Replace the entire selection atomically. Single notification. No-op if the new keys are identical to the current selection.
70
+
71
+ ```typescript
72
+ // Replace selection with specific keys
73
+ vm.selection.set('a', 'c');
74
+
75
+ // Clear via set (equivalent to clear())
76
+ vm.selection.set();
77
+ ```
78
+
79
+ #### `clear(): void`
80
+
81
+ Remove all selections. No-op if already empty.
82
+
83
+ ### Utility
84
+
85
+ #### `selectedFrom<T>(items: T[], keyOf: (item: T) => K): T[]`
86
+
87
+ Filter items to only those whose key is in the selection.
88
+
89
+ ```typescript
90
+ const selectedUsers = vm.selection.selectedFrom(users, u => u.id);
91
+ ```
92
+
93
+ ### Subscribable Interface
94
+
95
+ #### `subscribe(cb: () => void): () => void`
96
+
97
+ Subscribe to state changes. Returns an unsubscribe function.
98
+
99
+ ---
100
+
101
+ ## ViewModel Integration
102
+
103
+ ```typescript
104
+ class UsersVM extends ViewModel<FilterState> {
105
+ readonly selection = new Selection<string>();
106
+ private users = singleton(UsersResource);
107
+
108
+ get selectedUsers(): User[] {
109
+ return this.selection.selectedFrom(this.users.items, u => u.id);
110
+ }
111
+
112
+ async deleteSelected() {
113
+ const ids = [...this.selection.selected];
114
+ await this.usersService.deleteMany(ids, this.disposeSignal);
115
+ this.selection.clear();
116
+ }
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## React Usage
123
+
124
+ Pass the Selection helper directly to DataTable — it duck-types the interface and handles `toggleAll(allKeys)` automatically:
125
+
126
+ ```tsx
127
+ <DataTable
128
+ items={vm.items}
129
+ columns={columns}
130
+ selection={vm.selection}
131
+ />
132
+ ```
133
+
134
+ Or use the object-literal form for custom usage:
135
+
136
+ ```tsx
137
+ <DataTable
138
+ items={vm.items}
139
+ columns={columns}
140
+ selection={{
141
+ selected: vm.selection.selected,
142
+ onToggle: id => vm.selection.toggle(id),
143
+ onToggleAll: allKeys => vm.selection.toggleAll(allKeys),
144
+ }}
145
+ />
146
+ ```
147
+
148
+ ## Method Binding
149
+
150
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
151
+
152
+ ```tsx
153
+ const { toggle, clear } = selection;
154
+ <Checkbox onChange={toggle} /> // point-free works
155
+ ```
@@ -0,0 +1,326 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Selection } from './Selection';
3
+ import { ViewModel } from './ViewModel';
4
+
5
+ describe('Selection', () => {
6
+ describe('initial state', () => {
7
+ it('starts empty', () => {
8
+ const s = new Selection();
9
+ expect(s.count).toBe(0);
10
+ expect(s.hasSelection).toBe(false);
11
+ expect(s.selected.size).toBe(0);
12
+ });
13
+ });
14
+
15
+ describe('toggle', () => {
16
+ it('toggles selection on', () => {
17
+ const s = new Selection<string>();
18
+ s.toggle('a');
19
+ expect(s.isSelected('a')).toBe(true);
20
+ expect(s.count).toBe(1);
21
+ });
22
+
23
+ it('toggles selection off', () => {
24
+ const s = new Selection<string>();
25
+ s.toggle('a');
26
+ s.toggle('a');
27
+ expect(s.isSelected('a')).toBe(false);
28
+ expect(s.count).toBe(0);
29
+ });
30
+ });
31
+
32
+ describe('select / deselect', () => {
33
+ it('select adds keys', () => {
34
+ const s = new Selection<string>();
35
+ s.select('a', 'b', 'c');
36
+ expect(s.count).toBe(3);
37
+ expect(s.isSelected('a')).toBe(true);
38
+ expect(s.isSelected('b')).toBe(true);
39
+ expect(s.isSelected('c')).toBe(true);
40
+ });
41
+
42
+ it('select does not notify if keys already selected', () => {
43
+ const s = new Selection<string>();
44
+ s.select('a');
45
+ const listener = vi.fn();
46
+ s.subscribe(listener);
47
+ s.select('a');
48
+ expect(listener).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('deselect removes keys', () => {
52
+ const s = new Selection<string>();
53
+ s.select('a', 'b', 'c');
54
+ s.deselect('b');
55
+ expect(s.count).toBe(2);
56
+ expect(s.isSelected('b')).toBe(false);
57
+ });
58
+
59
+ it('deselect does not notify if keys not selected', () => {
60
+ const s = new Selection<string>();
61
+ const listener = vi.fn();
62
+ s.subscribe(listener);
63
+ s.deselect('x');
64
+ expect(listener).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
68
+ describe('toggleAll', () => {
69
+ it('selects all when none selected', () => {
70
+ const s = new Selection<string>();
71
+ s.toggleAll(['a', 'b', 'c']);
72
+ expect(s.count).toBe(3);
73
+ });
74
+
75
+ it('selects all when partially selected', () => {
76
+ const s = new Selection<string>();
77
+ s.select('a');
78
+ s.toggleAll(['a', 'b', 'c']);
79
+ expect(s.count).toBe(3);
80
+ });
81
+
82
+ it('deselects all when all selected', () => {
83
+ const s = new Selection<string>();
84
+ s.select('a', 'b', 'c');
85
+ s.toggleAll(['a', 'b', 'c']);
86
+ expect(s.count).toBe(0);
87
+ });
88
+
89
+ it('handles empty allKeys', () => {
90
+ const s = new Selection<string>();
91
+ s.select('a');
92
+ s.toggleAll([]);
93
+ // Empty list: allKeys.every() is vacuously true with no items,
94
+ // but length check prevents deselecting
95
+ expect(s.count).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe('set', () => {
100
+ it('replaces entire selection atomically', () => {
101
+ const s = new Selection<string>();
102
+ s.select('a', 'b');
103
+ s.set('c', 'd');
104
+ expect(s.count).toBe(2);
105
+ expect(s.isSelected('a')).toBe(false);
106
+ expect(s.isSelected('b')).toBe(false);
107
+ expect(s.isSelected('c')).toBe(true);
108
+ expect(s.isSelected('d')).toBe(true);
109
+ });
110
+
111
+ it('fires single notification', () => {
112
+ const s = new Selection<string>();
113
+ s.select('a', 'b');
114
+ const listener = vi.fn();
115
+ s.subscribe(listener);
116
+ s.set('c', 'd');
117
+ expect(listener).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('no-op when keys are identical', () => {
121
+ const s = new Selection<string>();
122
+ s.select('a', 'b');
123
+ const listener = vi.fn();
124
+ s.subscribe(listener);
125
+ s.set('a', 'b');
126
+ expect(listener).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('can set to empty (like clear)', () => {
130
+ const s = new Selection<string>();
131
+ s.select('a', 'b');
132
+ s.set();
133
+ expect(s.count).toBe(0);
134
+ expect(s.hasSelection).toBe(false);
135
+ });
136
+
137
+ it('updates selected reference', () => {
138
+ const s = new Selection<string>();
139
+ s.select('a');
140
+ const ref1 = s.selected;
141
+ s.set('b');
142
+ const ref2 = s.selected;
143
+ expect(ref1).not.toBe(ref2);
144
+ });
145
+
146
+ it('detects change when size differs', () => {
147
+ const s = new Selection<string>();
148
+ s.select('a');
149
+ const listener = vi.fn();
150
+ s.subscribe(listener);
151
+ s.set('a', 'b');
152
+ expect(listener).toHaveBeenCalledTimes(1);
153
+ expect(s.count).toBe(2);
154
+ });
155
+ });
156
+
157
+ describe('clear', () => {
158
+ it('clears all selections', () => {
159
+ const s = new Selection<string>();
160
+ s.select('a', 'b');
161
+ s.clear();
162
+ expect(s.count).toBe(0);
163
+ expect(s.hasSelection).toBe(false);
164
+ });
165
+
166
+ it('does not notify if already empty', () => {
167
+ const s = new Selection<string>();
168
+ const listener = vi.fn();
169
+ s.subscribe(listener);
170
+ s.clear();
171
+ expect(listener).not.toHaveBeenCalled();
172
+ });
173
+ });
174
+
175
+ describe('selectedFrom', () => {
176
+ it('filters items by selected keys', () => {
177
+ const s = new Selection<string>();
178
+ const items = [
179
+ { id: 'a', name: 'Alice' },
180
+ { id: 'b', name: 'Bob' },
181
+ { id: 'c', name: 'Charlie' },
182
+ ];
183
+ s.select('a', 'c');
184
+ const result = s.selectedFrom(items, item => item.id);
185
+ expect(result).toEqual([
186
+ { id: 'a', name: 'Alice' },
187
+ { id: 'c', name: 'Charlie' },
188
+ ]);
189
+ });
190
+ });
191
+
192
+ describe('numeric keys', () => {
193
+ it('works with number keys', () => {
194
+ const s = new Selection<number>();
195
+ s.select(1, 2, 3);
196
+ expect(s.isSelected(1)).toBe(true);
197
+ s.toggle(2);
198
+ expect(s.isSelected(2)).toBe(false);
199
+ expect(s.count).toBe(2);
200
+ });
201
+ });
202
+
203
+ describe('subscribe notifications', () => {
204
+ it('notifies on toggle', () => {
205
+ const s = new Selection<string>();
206
+ const listener = vi.fn();
207
+ s.subscribe(listener);
208
+ s.toggle('a');
209
+ expect(listener).toHaveBeenCalledTimes(1);
210
+ });
211
+
212
+ it('notifies on select', () => {
213
+ const s = new Selection<string>();
214
+ const listener = vi.fn();
215
+ s.subscribe(listener);
216
+ s.select('a', 'b');
217
+ expect(listener).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it('notifies on deselect', () => {
221
+ const s = new Selection<string>();
222
+ s.select('a');
223
+ const listener = vi.fn();
224
+ s.subscribe(listener);
225
+ s.deselect('a');
226
+ expect(listener).toHaveBeenCalledTimes(1);
227
+ });
228
+
229
+ it('notifies on toggleAll', () => {
230
+ const s = new Selection<string>();
231
+ const listener = vi.fn();
232
+ s.subscribe(listener);
233
+ s.toggleAll(['a', 'b']);
234
+ expect(listener).toHaveBeenCalledTimes(1);
235
+ });
236
+
237
+ it('notifies on set', () => {
238
+ const s = new Selection<string>();
239
+ s.select('a');
240
+ const listener = vi.fn();
241
+ s.subscribe(listener);
242
+ s.set('b', 'c');
243
+ expect(listener).toHaveBeenCalledTimes(1);
244
+ });
245
+
246
+ it('notifies on clear', () => {
247
+ const s = new Selection<string>();
248
+ s.select('a');
249
+ const listener = vi.fn();
250
+ s.subscribe(listener);
251
+ s.clear();
252
+ expect(listener).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it('unsubscribe stops notifications', () => {
256
+ const s = new Selection<string>();
257
+ const listener = vi.fn();
258
+ const unsub = s.subscribe(listener);
259
+ unsub();
260
+ s.toggle('a');
261
+ expect(listener).not.toHaveBeenCalled();
262
+ });
263
+ });
264
+
265
+ describe('selected returns new reference on change', () => {
266
+ it('reference changes on mutation', () => {
267
+ const s = new Selection<string>();
268
+ s.toggle('a');
269
+ const ref1 = s.selected;
270
+ s.toggle('b');
271
+ const ref2 = s.selected;
272
+ expect(ref1).not.toBe(ref2);
273
+ });
274
+ });
275
+
276
+ describe('auto-tracking integration', () => {
277
+ it('ViewModel getter invalidates when selection changes', () => {
278
+ let computeCount = 0;
279
+
280
+ class TestVM extends ViewModel {
281
+ readonly selection = new Selection<string>();
282
+ private _items = [
283
+ { id: 'a', name: 'Alice' },
284
+ { id: 'b', name: 'Bob' },
285
+ ];
286
+
287
+ get selectedItems() {
288
+ computeCount++;
289
+ return this.selection.selectedFrom(this._items, i => i.id);
290
+ }
291
+ }
292
+
293
+ const vm = new TestVM();
294
+ vm.init();
295
+
296
+ expect(vm.selectedItems).toEqual([]);
297
+ expect(computeCount).toBe(1);
298
+
299
+ // Cached
300
+ vm.selectedItems;
301
+ expect(computeCount).toBe(1);
302
+
303
+ // Change selection — getter should recompute
304
+ vm.selection.select('a');
305
+ expect(vm.selectedItems).toEqual([{ id: 'a', name: 'Alice' }]);
306
+ expect(computeCount).toBe(2);
307
+
308
+ vm.dispose();
309
+ });
310
+ });
311
+
312
+ describe('method binding', () => {
313
+ it('destructured methods work point-free', () => {
314
+ const selection = new Selection<string>();
315
+ const { toggle, select, deselect, clear, isSelected } = selection;
316
+ toggle('a');
317
+ expect(isSelected('a')).toBe(true);
318
+ select('b', 'c');
319
+ expect(selection.count).toBe(3);
320
+ deselect('b');
321
+ expect(selection.count).toBe(2);
322
+ clear();
323
+ expect(selection.count).toBe(0);
324
+ });
325
+ });
326
+ });