@zeix/cause-effect 0.16.1 → 0.17.0

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 (61) hide show
  1. package/.ai-context.md +71 -21
  2. package/.cursorrules +3 -2
  3. package/.github/copilot-instructions.md +59 -13
  4. package/CLAUDE.md +170 -24
  5. package/LICENSE +1 -1
  6. package/README.md +156 -52
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +19 -19
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +138 -0
  12. package/{src → archive}/state.ts +13 -11
  13. package/archive/store.ts +368 -0
  14. package/archive/task.ts +194 -0
  15. package/eslint.config.js +1 -0
  16. package/index.dev.js +899 -503
  17. package/index.js +1 -1
  18. package/index.ts +41 -22
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +272 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +304 -0
  24. package/src/classes/state.ts +98 -0
  25. package/src/classes/store.ts +210 -0
  26. package/src/diff.ts +26 -53
  27. package/src/effect.ts +9 -9
  28. package/src/errors.ts +50 -25
  29. package/src/signal.ts +58 -41
  30. package/src/system.ts +79 -42
  31. package/src/util.ts +16 -30
  32. package/test/batch.test.ts +15 -17
  33. package/test/benchmark.test.ts +4 -4
  34. package/test/collection.test.ts +796 -0
  35. package/test/computed.test.ts +138 -130
  36. package/test/diff.test.ts +2 -2
  37. package/test/effect.test.ts +36 -35
  38. package/test/list.test.ts +754 -0
  39. package/test/match.test.ts +25 -25
  40. package/test/resolve.test.ts +17 -19
  41. package/test/signal.test.ts +70 -119
  42. package/test/state.test.ts +44 -44
  43. package/test/store.test.ts +253 -929
  44. package/types/index.d.ts +10 -8
  45. package/types/src/classes/collection.d.ts +32 -0
  46. package/types/src/classes/composite.d.ts +15 -0
  47. package/types/src/classes/computed.d.ts +97 -0
  48. package/types/src/classes/list.d.ts +41 -0
  49. package/types/src/classes/state.d.ts +52 -0
  50. package/types/src/classes/store.d.ts +51 -0
  51. package/types/src/diff.d.ts +8 -12
  52. package/types/src/errors.d.ts +12 -11
  53. package/types/src/signal.d.ts +27 -14
  54. package/types/src/system.d.ts +41 -20
  55. package/types/src/util.d.ts +6 -3
  56. package/src/store.ts +0 -474
  57. package/types/src/collection.d.ts +0 -26
  58. package/types/src/computed.d.ts +0 -33
  59. package/types/src/scheduler.d.ts +0 -55
  60. package/types/src/state.d.ts +0 -24
  61. package/types/src/store.d.ts +0 -65
@@ -0,0 +1,754 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ createEffect,
4
+ List,
5
+ createStore,
6
+ isCollection,
7
+ isList,
8
+ isStore,
9
+ Memo,
10
+ State,
11
+ UNSET,
12
+ } from '../index.ts'
13
+
14
+ describe('list', () => {
15
+ describe('creation and basic operations', () => {
16
+ test('creates lists with initial values', () => {
17
+ const numbers = new List([1, 2, 3])
18
+ expect(numbers.at(0)?.get()).toBe(1)
19
+ expect(numbers.at(1)?.get()).toBe(2)
20
+ expect(numbers.at(2)?.get()).toBe(3)
21
+ })
22
+
23
+ test('has Symbol.toStringTag of List', () => {
24
+ const list = new List([1, 2])
25
+ expect(list[Symbol.toStringTag]).toBe('List')
26
+ })
27
+
28
+ test('isList identifies list instances correctly', () => {
29
+ const store = createStore({ a: 1 })
30
+ const list = new List([1])
31
+ const state = new State(1)
32
+ const computed = new Memo(() => 1)
33
+
34
+ expect(isList(list)).toBe(true)
35
+ expect(isStore(store)).toBe(true)
36
+ expect(isList(state)).toBe(false)
37
+ expect(isList(computed)).toBe(false)
38
+ expect(isList({})).toBe(false)
39
+ })
40
+
41
+ test('get() returns the complete list value', () => {
42
+ const numbers = new List([1, 2, 3])
43
+ expect(numbers.get()).toEqual([1, 2, 3])
44
+
45
+ // Nested structures
46
+ const participants = new List([
47
+ { name: 'Alice', tags: ['admin'] },
48
+ { name: 'Bob', tags: ['user'] },
49
+ ])
50
+ expect(participants.get()).toEqual([
51
+ { name: 'Alice', tags: ['admin'] },
52
+ { name: 'Bob', tags: ['user'] },
53
+ ])
54
+ })
55
+ })
56
+
57
+ describe('length property and sizing', () => {
58
+ test('length property works for lists', () => {
59
+ const numbers = new List([1, 2, 3])
60
+ expect(numbers.length).toBe(3)
61
+ expect(typeof numbers.length).toBe('number')
62
+ })
63
+
64
+ test('length is reactive and updates with changes', () => {
65
+ const items = new List([1, 2])
66
+ expect(items.length).toBe(2)
67
+ items.add(3)
68
+ expect(items.length).toBe(3)
69
+ items.remove(1)
70
+ expect(items.length).toBe(2)
71
+ })
72
+ })
73
+
74
+ describe('data access and modification', () => {
75
+ test('items can be accessed and modified via signals', () => {
76
+ const items = new List(['a', 'b'])
77
+ expect(items.at(0)?.get()).toBe('a')
78
+ expect(items.at(1)?.get()).toBe('b')
79
+ items.at(0)?.set('alpha')
80
+ items.at(1)?.set('beta')
81
+ expect(items.at(0)?.get()).toBe('alpha')
82
+ expect(items.at(1)?.get()).toBe('beta')
83
+ })
84
+
85
+ test('returns undefined for non-existent properties', () => {
86
+ const items = new List(['a'])
87
+ expect(items.at(5)).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('add() and remove() methods', () => {
92
+ test('add() method appends to end', () => {
93
+ const fruits = new List(['apple', 'banana'])
94
+ fruits.add('cherry')
95
+ expect(fruits.at(2)?.get()).toBe('cherry')
96
+ })
97
+
98
+ test('remove() method removes by index', () => {
99
+ const items = new List(['a', 'b', 'c'])
100
+ items.remove(1) // Remove 'b'
101
+ expect(items.get()).toEqual(['a', 'c'])
102
+ expect(items.length).toBe(2)
103
+ })
104
+
105
+ test('add method prevents null values', () => {
106
+ const items = new List([1])
107
+ // @ts-expect-error testing null values
108
+ expect(() => items.add(null)).toThrow()
109
+ })
110
+
111
+ test('remove method handles non-existent indices gracefully', () => {
112
+ const items = new List(['a'])
113
+ expect(() => items.remove(5)).not.toThrow()
114
+ expect(items.get()).toEqual(['a'])
115
+ })
116
+ })
117
+
118
+ describe('sort() method', () => {
119
+ test('sorts lists with different compare functions', () => {
120
+ const numbers = new List([3, 1, 2])
121
+
122
+ numbers.sort()
123
+ expect(numbers.get()).toEqual([1, 2, 3])
124
+
125
+ numbers.sort((a, b) => b - a)
126
+ expect(numbers.get()).toEqual([3, 2, 1])
127
+
128
+ const names = new List(['Charlie', 'Alice', 'Bob'])
129
+ names.sort((a, b) => a.localeCompare(b))
130
+ expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
131
+ })
132
+
133
+ test('emits sort notification with new order', () => {
134
+ const numbers = new List([3, 1, 2])
135
+ let sortNotification: readonly string[] = []
136
+ numbers.on('sort', sort => {
137
+ sortNotification = sort
138
+ })
139
+ numbers.sort()
140
+ expect(sortNotification).toHaveLength(3)
141
+ expect(sortNotification).toEqual(['1', '2', '0'])
142
+ })
143
+
144
+ test('sort is reactive - watchers are notified', () => {
145
+ const numbers = new List([3, 1, 2])
146
+ let effectCount = 0
147
+ let lastValue: number[] = []
148
+ createEffect(() => {
149
+ lastValue = numbers.get()
150
+ effectCount++
151
+ })
152
+
153
+ expect(effectCount).toBe(1)
154
+ expect(lastValue).toEqual([3, 1, 2])
155
+
156
+ numbers.sort()
157
+ expect(effectCount).toBe(2)
158
+ expect(lastValue).toEqual([1, 2, 3])
159
+ })
160
+ })
161
+
162
+ describe('splice() method', () => {
163
+ test('splice() removes elements without adding new ones', () => {
164
+ const numbers = new List([1, 2, 3, 4])
165
+ const deleted = numbers.splice(1, 2)
166
+ expect(deleted).toEqual([2, 3])
167
+ expect(numbers.get()).toEqual([1, 4])
168
+ })
169
+
170
+ test('splice() adds elements without removing any', () => {
171
+ const numbers = new List([1, 3])
172
+ const deleted = numbers.splice(1, 0, 2)
173
+ expect(deleted).toEqual([])
174
+ expect(numbers.get()).toEqual([1, 2, 3])
175
+ })
176
+
177
+ test('splice() replaces elements (remove and add)', () => {
178
+ const numbers = new List([1, 2, 3])
179
+ const deleted = numbers.splice(1, 1, 4, 5)
180
+ expect(deleted).toEqual([2])
181
+ expect(numbers.get()).toEqual([1, 4, 5, 3])
182
+ })
183
+
184
+ test('splice() handles negative start index', () => {
185
+ const numbers = new List([1, 2, 3])
186
+ const deleted = numbers.splice(-1, 1, 4)
187
+ expect(deleted).toEqual([3])
188
+ expect(numbers.get()).toEqual([1, 2, 4])
189
+ })
190
+ })
191
+
192
+ describe('reactivity', () => {
193
+ test('list-level get() is reactive', () => {
194
+ const numbers = new List([1, 2, 3])
195
+ let lastArray: number[] = []
196
+ createEffect(() => {
197
+ lastArray = numbers.get()
198
+ })
199
+
200
+ expect(lastArray).toEqual([1, 2, 3])
201
+ numbers.add(4)
202
+ expect(lastArray).toEqual([1, 2, 3, 4])
203
+ })
204
+
205
+ test('individual signal reactivity works', () => {
206
+ const items = new List([{ count: 5 }])
207
+ let lastItem = 0
208
+ let itemEffectRuns = 0
209
+ createEffect(() => {
210
+ lastItem = items.at(0)?.get().count ?? 0
211
+ itemEffectRuns++
212
+ })
213
+
214
+ expect(lastItem).toBe(5)
215
+ expect(itemEffectRuns).toBe(1)
216
+
217
+ items.at(0)?.set({ count: 10 })
218
+ expect(lastItem).toBe(10)
219
+ expect(itemEffectRuns).toBe(2)
220
+ })
221
+
222
+ test('updates are reactive', () => {
223
+ const numbers = new List([1, 2])
224
+ let lastArray: number[] = []
225
+ let arrayEffectRuns = 0
226
+ createEffect(() => {
227
+ lastArray = numbers.get()
228
+ arrayEffectRuns++
229
+ })
230
+
231
+ expect(lastArray).toEqual([1, 2])
232
+ expect(arrayEffectRuns).toBe(1)
233
+
234
+ numbers.update(arr => [...arr, 3])
235
+ expect(lastArray).toEqual([1, 2, 3])
236
+ expect(arrayEffectRuns).toBe(2)
237
+ })
238
+ })
239
+
240
+ describe('computed integration', () => {
241
+ test('works with computed signals', () => {
242
+ const numbers = new List([1, 2, 3])
243
+ const sum = new Memo(() =>
244
+ numbers.get().reduce((acc, n) => acc + n, 0),
245
+ )
246
+
247
+ expect(sum.get()).toBe(6)
248
+ numbers.add(4)
249
+ expect(sum.get()).toBe(10)
250
+ })
251
+
252
+ test('computed handles additions and removals', () => {
253
+ const numbers = new List([1, 2, 3])
254
+ const sum = new Memo(() => {
255
+ const array = numbers.get()
256
+ return array.reduce((total, n) => total + n, 0)
257
+ })
258
+
259
+ expect(sum.get()).toBe(6)
260
+
261
+ numbers.add(4)
262
+ expect(sum.get()).toBe(10)
263
+
264
+ numbers.remove(0)
265
+ const finalArray = numbers.get()
266
+ expect(finalArray).toEqual([2, 3, 4])
267
+ expect(sum.get()).toBe(9)
268
+ })
269
+
270
+ test('computed sum using list iteration with length tracking', () => {
271
+ const numbers = new List([1, 2, 3])
272
+
273
+ const sum = new Memo(() => {
274
+ // Access length to make it reactive
275
+ const _length = numbers.length
276
+ let total = 0
277
+ for (const signal of numbers) {
278
+ total += signal.get()
279
+ }
280
+ return total
281
+ })
282
+
283
+ expect(sum.get()).toBe(6)
284
+ numbers.add(4)
285
+ expect(sum.get()).toBe(10)
286
+ })
287
+ })
288
+
289
+ describe('iteration and spreading', () => {
290
+ test('supports for...of iteration', () => {
291
+ const numbers = new List([10, 20, 30])
292
+ const signals = [...numbers]
293
+ expect(signals).toHaveLength(3)
294
+ expect(signals[0].get()).toBe(10)
295
+ expect(signals[1].get()).toBe(20)
296
+ expect(signals[2].get()).toBe(30)
297
+ })
298
+
299
+ test('Symbol.isConcatSpreadable is true', () => {
300
+ const numbers = new List([1, 2, 3])
301
+ expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
302
+ })
303
+ })
304
+
305
+ describe('change notifications', () => {
306
+ test('emits add notifications', () => {
307
+ const numbers = new List([1, 2])
308
+ let arrayAddNotification: readonly string[] = []
309
+ let newArray: number[] = []
310
+ numbers.on('add', add => {
311
+ arrayAddNotification = add
312
+ newArray = numbers.get()
313
+ })
314
+ numbers.add(3)
315
+ expect(arrayAddNotification).toHaveLength(1)
316
+ expect(newArray).toEqual([1, 2, 3])
317
+ })
318
+
319
+ test('emits change notifications when properties are modified', () => {
320
+ const items = new List([{ value: 10 }])
321
+ let arrayChangeNotification: readonly string[] = []
322
+ let newArray: { value: number }[] = []
323
+ items.on('change', change => {
324
+ arrayChangeNotification = change
325
+ newArray = items.get()
326
+ })
327
+ items.at(0)?.set({ value: 20 })
328
+ expect(arrayChangeNotification).toHaveLength(1)
329
+ expect(newArray).toEqual([{ value: 20 }])
330
+ })
331
+
332
+ test('emits remove notifications when items are removed', () => {
333
+ const items = new List([1, 2, 3])
334
+ let arrayRemoveNotification: readonly string[] = []
335
+ let newArray: number[] = []
336
+ items.on('remove', remove => {
337
+ arrayRemoveNotification = remove
338
+ newArray = items.get()
339
+ })
340
+ items.remove(1)
341
+ expect(arrayRemoveNotification).toHaveLength(1)
342
+ expect(newArray).toEqual([1, 3])
343
+ })
344
+ })
345
+
346
+ describe('edge cases', () => {
347
+ test('handles empty lists correctly', () => {
348
+ const empty = new List([])
349
+ expect(empty.get()).toEqual([])
350
+ expect(empty.length).toBe(0)
351
+ })
352
+
353
+ test('handles UNSET values', () => {
354
+ const list = new List([UNSET, 'valid'])
355
+ expect(list.get()).toEqual([UNSET, 'valid'])
356
+ })
357
+
358
+ test('handles primitive values', () => {
359
+ const list = new List([42, 'text', true])
360
+ expect(list.at(0)?.get()).toBe(42)
361
+ expect(list.at(1)?.get()).toBe('text')
362
+ expect(list.at(2)?.get()).toBe(true)
363
+ })
364
+ })
365
+
366
+ describe('deriveCollection() method', () => {
367
+ describe('synchronous transformations', () => {
368
+ test('transforms list values with sync callback', () => {
369
+ const numbers = new List([1, 2, 3])
370
+ const doubled = numbers.deriveCollection(
371
+ (value: number) => value * 2,
372
+ )
373
+
374
+ expect(isCollection(doubled)).toBe(true)
375
+ expect(doubled.length).toBe(3)
376
+ expect(doubled.get()).toEqual([2, 4, 6])
377
+ })
378
+
379
+ test('transforms object values with sync callback', () => {
380
+ const users = new List([
381
+ { name: 'Alice', age: 25 },
382
+ { name: 'Bob', age: 30 },
383
+ ])
384
+ const userInfo = users.deriveCollection(user => ({
385
+ displayName: user.name.toUpperCase(),
386
+ isAdult: user.age >= 18,
387
+ }))
388
+
389
+ expect(userInfo.length).toBe(2)
390
+ expect(userInfo.get()).toEqual([
391
+ { displayName: 'ALICE', isAdult: true },
392
+ { displayName: 'BOB', isAdult: true },
393
+ ])
394
+ })
395
+
396
+ test('transforms string values to different types', () => {
397
+ const words = new List(['hello', 'world', 'test'])
398
+ const wordLengths = words.deriveCollection((word: string) => ({
399
+ word,
400
+ length: word.length,
401
+ }))
402
+
403
+ expect(wordLengths.get()).toEqual([
404
+ { word: 'hello', length: 5 },
405
+ { word: 'world', length: 5 },
406
+ { word: 'test', length: 4 },
407
+ ])
408
+ })
409
+
410
+ test('collection reactivity with sync transformations', () => {
411
+ const numbers = new List([1, 2])
412
+ const doubled = numbers.deriveCollection(
413
+ (value: number) => value * 2,
414
+ )
415
+
416
+ let collectionValue: number[] = []
417
+ let effectRuns = 0
418
+ createEffect(() => {
419
+ collectionValue = doubled.get()
420
+ effectRuns++
421
+ })
422
+
423
+ expect(collectionValue).toEqual([2, 4])
424
+ expect(effectRuns).toBe(1)
425
+
426
+ // Add new item
427
+ numbers.add(3)
428
+ expect(collectionValue).toEqual([2, 4, 6])
429
+ expect(effectRuns).toBe(2)
430
+
431
+ // Modify existing item
432
+ numbers.at(0)?.set(5)
433
+ expect(collectionValue).toEqual([10, 4, 6])
434
+ expect(effectRuns).toBe(3)
435
+ })
436
+
437
+ test('collection responds to source removal', () => {
438
+ const numbers = new List([1, 2, 3])
439
+ const doubled = numbers.deriveCollection(
440
+ (value: number) => value * 2,
441
+ )
442
+
443
+ expect(doubled.get()).toEqual([2, 4, 6])
444
+
445
+ numbers.remove(1) // Remove middle item (2)
446
+ expect(doubled.get()).toEqual([2, 6])
447
+ expect(doubled.length).toBe(2)
448
+ })
449
+ })
450
+
451
+ describe('asynchronous transformations', () => {
452
+ test('transforms values with async callback', async () => {
453
+ const numbers = new List([1, 2, 3])
454
+ const asyncDoubled = numbers.deriveCollection(
455
+ async (value: number, abort: AbortSignal) => {
456
+ // Simulate async operation
457
+ await new Promise(resolve => setTimeout(resolve, 10))
458
+ if (abort.aborted) throw new Error('Aborted')
459
+ return value * 2
460
+ },
461
+ )
462
+
463
+ // Trigger initial computation by accessing the collection
464
+ const initialLength = asyncDoubled.length
465
+ expect(initialLength).toBe(3)
466
+
467
+ // Access each computed signal to trigger computation
468
+ for (let i = 0; i < asyncDoubled.length; i++) {
469
+ asyncDoubled.at(i)?.get()
470
+ }
471
+
472
+ // Allow async operations to complete
473
+ await new Promise(resolve => setTimeout(resolve, 50))
474
+
475
+ expect(asyncDoubled.get()).toEqual([2, 4, 6])
476
+ })
477
+
478
+ test('async collection with object transformation', async () => {
479
+ const users = new List([
480
+ { id: 1, name: 'Alice' },
481
+ { id: 2, name: 'Bob' },
482
+ ])
483
+
484
+ const enrichedUsers = users.deriveCollection(
485
+ async (user, abort: AbortSignal) => {
486
+ // Simulate API call
487
+ await new Promise(resolve => setTimeout(resolve, 10))
488
+ if (abort.aborted) throw new Error('Aborted')
489
+
490
+ return {
491
+ ...user,
492
+ slug: user.name.toLowerCase(),
493
+ timestamp: Date.now(),
494
+ }
495
+ },
496
+ )
497
+
498
+ // Trigger initial computation by accessing each computed signal
499
+ for (let i = 0; i < enrichedUsers.length; i++) {
500
+ enrichedUsers.at(i)?.get()
501
+ }
502
+
503
+ // Allow async operations to complete
504
+ await new Promise(resolve => setTimeout(resolve, 50))
505
+
506
+ const result = enrichedUsers.get()
507
+ expect(result).toHaveLength(2)
508
+ expect(result[0].slug).toBe('alice')
509
+ expect(result[1].slug).toBe('bob')
510
+ expect(typeof result[0].timestamp).toBe('number')
511
+ })
512
+
513
+ test('async collection reactivity', async () => {
514
+ const numbers = new List([1, 2])
515
+ const asyncDoubled = numbers.deriveCollection(
516
+ async (value: number, abort: AbortSignal) => {
517
+ await new Promise(resolve => setTimeout(resolve, 5))
518
+ if (abort.aborted) throw new Error('Aborted')
519
+ return value * 2
520
+ },
521
+ )
522
+
523
+ const effectValues: number[][] = []
524
+
525
+ // Set up effect to track changes reactively
526
+ createEffect(() => {
527
+ const currentValue = asyncDoubled.get()
528
+ effectValues.push([...currentValue])
529
+ })
530
+
531
+ // Allow initial computation
532
+ await new Promise(resolve => setTimeout(resolve, 20))
533
+ expect(effectValues[effectValues.length - 1]).toEqual([2, 4])
534
+
535
+ // Add new item
536
+ numbers.add(3)
537
+ await new Promise(resolve => setTimeout(resolve, 20))
538
+ expect(effectValues[effectValues.length - 1]).toEqual([2, 4, 6])
539
+
540
+ // Modify existing item
541
+ numbers.at(0)?.set(5)
542
+ await new Promise(resolve => setTimeout(resolve, 20))
543
+ expect(effectValues[effectValues.length - 1]).toEqual([
544
+ 10, 4, 6,
545
+ ])
546
+ })
547
+
548
+ test('handles AbortSignal cancellation', async () => {
549
+ const numbers = new List([1])
550
+ let abortCalled = false
551
+
552
+ const slowCollection = numbers.deriveCollection(
553
+ async (value: number, abort: AbortSignal) => {
554
+ return new Promise<number>((resolve, reject) => {
555
+ const timeout = setTimeout(
556
+ () => resolve(value * 2),
557
+ 50,
558
+ )
559
+ abort.addEventListener('abort', () => {
560
+ clearTimeout(timeout)
561
+ abortCalled = true
562
+ reject(new Error('Aborted'))
563
+ })
564
+ })
565
+ },
566
+ )
567
+
568
+ // Trigger initial computation
569
+ slowCollection.at(0)?.get()
570
+
571
+ // Change the value to trigger cancellation of the first computation
572
+ numbers.at(0)?.set(2)
573
+
574
+ // Allow some time for operations
575
+ await new Promise(resolve => setTimeout(resolve, 100))
576
+
577
+ expect(abortCalled).toBe(true)
578
+ expect(slowCollection.get()).toEqual([4]) // Last value (2 * 2)
579
+ })
580
+ })
581
+
582
+ describe('derived collection chaining', () => {
583
+ test('chains multiple sync derivations', () => {
584
+ const numbers = new List([1, 2, 3])
585
+ const doubled = numbers.deriveCollection(
586
+ (value: number) => value * 2,
587
+ )
588
+ const quadrupled = doubled.deriveCollection(
589
+ (value: number) => value * 2,
590
+ )
591
+
592
+ expect(quadrupled.get()).toEqual([4, 8, 12])
593
+
594
+ numbers.add(4)
595
+ expect(quadrupled.get()).toEqual([4, 8, 12, 16])
596
+ })
597
+
598
+ test('chains sync and async derivations', async () => {
599
+ const numbers = new List([1, 2])
600
+ const doubled = numbers.deriveCollection(
601
+ (value: number) => value * 2,
602
+ )
603
+ const asyncSquared = doubled.deriveCollection(
604
+ async (value: number, abort: AbortSignal) => {
605
+ await new Promise(resolve => setTimeout(resolve, 10))
606
+ if (abort.aborted) throw new Error('Aborted')
607
+ return value * value
608
+ },
609
+ )
610
+
611
+ // Trigger initial computation by accessing each computed signal
612
+ for (let i = 0; i < asyncSquared.length; i++) {
613
+ asyncSquared.at(i)?.get()
614
+ }
615
+
616
+ await new Promise(resolve => setTimeout(resolve, 50))
617
+ expect(asyncSquared.get()).toEqual([4, 16]) // (1*2)^2, (2*2)^2
618
+ })
619
+ })
620
+
621
+ describe('collection access methods', () => {
622
+ test('supports index-based access to computed signals', () => {
623
+ const numbers = new List([1, 2, 3])
624
+ const doubled = numbers.deriveCollection(
625
+ (value: number) => value * 2,
626
+ )
627
+
628
+ expect(doubled.at(0)?.get()).toBe(2)
629
+ expect(doubled.at(1)?.get()).toBe(4)
630
+ expect(doubled.at(2)?.get()).toBe(6)
631
+ expect(doubled.at(3)).toBeUndefined()
632
+ })
633
+
634
+ test('supports key-based access', () => {
635
+ const numbers = new List([10, 20])
636
+ const doubled = numbers.deriveCollection(
637
+ (value: number) => value * 2,
638
+ )
639
+
640
+ const key0 = numbers.keyAt(0)
641
+ const key1 = numbers.keyAt(1)
642
+
643
+ expect(key0).toBeDefined()
644
+ expect(key1).toBeDefined()
645
+
646
+ if (key0 && key1) {
647
+ expect(doubled.byKey(key0)?.get()).toBe(20)
648
+ expect(doubled.byKey(key1)?.get()).toBe(40)
649
+ }
650
+ })
651
+
652
+ test('supports iteration', () => {
653
+ const numbers = new List([1, 2, 3])
654
+ const doubled = numbers.deriveCollection(
655
+ (value: number) => value * 2,
656
+ )
657
+
658
+ const signals = [...doubled]
659
+ expect(signals).toHaveLength(3)
660
+ expect(signals[0].get()).toBe(2)
661
+ expect(signals[1].get()).toBe(4)
662
+ expect(signals[2].get()).toBe(6)
663
+ })
664
+ })
665
+
666
+ describe('collection event handling', () => {
667
+ test('emits add events when source adds items', () => {
668
+ const numbers = new List([1, 2])
669
+ const doubled = numbers.deriveCollection(
670
+ (value: number) => value * 2,
671
+ )
672
+
673
+ let addedKeys: readonly string[] = []
674
+ doubled.on('add', keys => {
675
+ addedKeys = keys
676
+ })
677
+
678
+ numbers.add(3)
679
+ expect(addedKeys).toHaveLength(1)
680
+ })
681
+
682
+ test('emits remove events when source removes items', () => {
683
+ const numbers = new List([1, 2, 3])
684
+ const doubled = numbers.deriveCollection(
685
+ (value: number) => value * 2,
686
+ )
687
+
688
+ let removedKeys: readonly string[] = []
689
+ doubled.on('remove', keys => {
690
+ removedKeys = keys
691
+ })
692
+
693
+ numbers.remove(1)
694
+ expect(removedKeys).toHaveLength(1)
695
+ })
696
+
697
+ test('emits sort events when source is sorted', () => {
698
+ const numbers = new List([3, 1, 2])
699
+ const doubled = numbers.deriveCollection(
700
+ (value: number) => value * 2,
701
+ )
702
+
703
+ let sortedKeys: readonly string[] = []
704
+ doubled.on('sort', keys => {
705
+ sortedKeys = keys
706
+ })
707
+
708
+ numbers.sort()
709
+ expect(sortedKeys).toHaveLength(3)
710
+ expect(doubled.get()).toEqual([2, 4, 6]) // Sorted and doubled
711
+ })
712
+ })
713
+
714
+ describe('edge cases', () => {
715
+ test('handles empty list derivation', () => {
716
+ const empty = new List<number>([])
717
+ const doubled = empty.deriveCollection(
718
+ (value: number) => value * 2,
719
+ )
720
+
721
+ expect(doubled.get()).toEqual([])
722
+ expect(doubled.length).toBe(0)
723
+ })
724
+
725
+ test('handles UNSET values in transformation', () => {
726
+ const list = new List([1, UNSET, 3])
727
+ const processed = list.deriveCollection(value => {
728
+ return value === UNSET ? 0 : value * 2
729
+ })
730
+
731
+ // UNSET values are filtered out before transformation
732
+ expect(processed.get()).toEqual([2, 6])
733
+ })
734
+
735
+ test('handles complex object transformations', () => {
736
+ const items = new List([
737
+ { id: 1, data: { value: 10, active: true } },
738
+ { id: 2, data: { value: 20, active: false } },
739
+ ])
740
+
741
+ const transformed = items.deriveCollection(item => ({
742
+ itemId: item.id,
743
+ processedValue: item.data.value * 2,
744
+ status: item.data.active ? 'enabled' : 'disabled',
745
+ }))
746
+
747
+ expect(transformed.get()).toEqual([
748
+ { itemId: 1, processedValue: 20, status: 'enabled' },
749
+ { itemId: 2, processedValue: 40, status: 'disabled' },
750
+ ])
751
+ })
752
+ })
753
+ })
754
+ })