@zeix/cause-effect 0.16.0 → 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.
- package/.ai-context.md +71 -21
- package/.cursorrules +3 -2
- package/.github/copilot-instructions.md +59 -13
- package/CLAUDE.md +170 -24
- package/LICENSE +1 -1
- package/README.md +156 -52
- package/archive/benchmark.ts +688 -0
- package/archive/collection.ts +312 -0
- package/{src → archive}/computed.ts +33 -34
- package/archive/list.ts +551 -0
- package/archive/memo.ts +138 -0
- package/archive/state.ts +89 -0
- package/archive/store.ts +368 -0
- package/archive/task.ts +194 -0
- package/eslint.config.js +1 -0
- package/index.dev.js +902 -501
- package/index.js +1 -1
- package/index.ts +42 -22
- package/package.json +1 -1
- package/src/classes/collection.ts +272 -0
- package/src/classes/composite.ts +176 -0
- package/src/classes/computed.ts +333 -0
- package/src/classes/list.ts +304 -0
- package/src/classes/state.ts +98 -0
- package/src/classes/store.ts +210 -0
- package/src/diff.ts +28 -52
- package/src/effect.ts +9 -9
- package/src/errors.ts +50 -25
- package/src/signal.ts +58 -41
- package/src/system.ts +79 -42
- package/src/util.ts +16 -34
- package/test/batch.test.ts +15 -17
- package/test/benchmark.test.ts +4 -4
- package/test/collection.test.ts +796 -0
- package/test/computed.test.ts +138 -130
- package/test/diff.test.ts +2 -2
- package/test/effect.test.ts +36 -35
- package/test/list.test.ts +754 -0
- package/test/match.test.ts +25 -25
- package/test/resolve.test.ts +17 -19
- package/test/signal.test.ts +72 -121
- package/test/state.test.ts +44 -44
- package/test/store.test.ts +344 -1663
- package/types/index.d.ts +11 -9
- package/types/src/classes/collection.d.ts +32 -0
- package/types/src/classes/composite.d.ts +15 -0
- package/types/src/classes/computed.d.ts +97 -0
- package/types/src/classes/list.d.ts +41 -0
- package/types/src/classes/state.d.ts +52 -0
- package/types/src/classes/store.d.ts +51 -0
- package/types/src/diff.d.ts +8 -12
- package/types/src/errors.d.ts +12 -11
- package/types/src/signal.d.ts +27 -14
- package/types/src/system.d.ts +41 -20
- package/types/src/util.d.ts +6 -3
- package/src/state.ts +0 -98
- package/src/store.ts +0 -525
- package/types/src/collection.d.ts +0 -26
- package/types/src/computed.d.ts +0 -33
- package/types/src/scheduler.d.ts +0 -55
- package/types/src/state.d.ts +0 -24
- package/types/src/store.d.ts +0 -66
|
@@ -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
|
+
})
|