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