@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,796 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
Collection,
|
|
4
|
+
createEffect,
|
|
5
|
+
createStore,
|
|
6
|
+
isCollection,
|
|
7
|
+
List,
|
|
8
|
+
UNSET,
|
|
9
|
+
} from '../index.ts'
|
|
10
|
+
|
|
11
|
+
describe('collection', () => {
|
|
12
|
+
describe('creation and basic operations', () => {
|
|
13
|
+
test('creates collection with initial values from list', () => {
|
|
14
|
+
const numbers = new List([1, 2, 3])
|
|
15
|
+
const doubled = new Collection(
|
|
16
|
+
numbers,
|
|
17
|
+
(value: number) => value * 2,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(doubled.length).toBe(3)
|
|
21
|
+
expect(doubled.at(0)?.get()).toBe(2)
|
|
22
|
+
expect(doubled.at(1)?.get()).toBe(4)
|
|
23
|
+
expect(doubled.at(2)?.get()).toBe(6)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('creates collection from function source', () => {
|
|
27
|
+
const doubled = new Collection(
|
|
28
|
+
() => new List([10, 20, 30]),
|
|
29
|
+
(value: number) => value * 2,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(doubled.length).toBe(3)
|
|
33
|
+
expect(doubled.at(0)?.get()).toBe(20)
|
|
34
|
+
expect(doubled.at(1)?.get()).toBe(40)
|
|
35
|
+
expect(doubled.at(2)?.get()).toBe(60)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('has Symbol.toStringTag of Collection', () => {
|
|
39
|
+
const list = new List([1, 2, 3])
|
|
40
|
+
const collection = new Collection(list, (x: number) => x)
|
|
41
|
+
expect(Object.prototype.toString.call(collection)).toBe(
|
|
42
|
+
'[object Collection]',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('isCollection identifies collection instances correctly', () => {
|
|
47
|
+
const store = createStore({ a: 1 })
|
|
48
|
+
const list = new List([1, 2, 3])
|
|
49
|
+
const collection = new Collection(list, (x: number) => x)
|
|
50
|
+
|
|
51
|
+
expect(isCollection(collection)).toBe(true)
|
|
52
|
+
expect(isCollection(list)).toBe(false)
|
|
53
|
+
expect(isCollection(store)).toBe(false)
|
|
54
|
+
expect(isCollection({})).toBe(false)
|
|
55
|
+
expect(isCollection(null)).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('get() returns the complete collection value', () => {
|
|
59
|
+
const numbers = new List([1, 2, 3])
|
|
60
|
+
const doubled = new Collection(
|
|
61
|
+
numbers,
|
|
62
|
+
(value: number) => value * 2,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const result = doubled.get()
|
|
66
|
+
expect(result).toEqual([2, 4, 6])
|
|
67
|
+
expect(Array.isArray(result)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('length property and sizing', () => {
|
|
72
|
+
test('length property works for collections', () => {
|
|
73
|
+
const numbers = new List([1, 2, 3, 4, 5])
|
|
74
|
+
const collection = new Collection(numbers, (x: number) => x * 2)
|
|
75
|
+
expect(collection.length).toBe(5)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('length is reactive and updates with changes', () => {
|
|
79
|
+
const items = new List([1, 2])
|
|
80
|
+
const collection = new Collection(items, (x: number) => x * 2)
|
|
81
|
+
|
|
82
|
+
expect(collection.length).toBe(2)
|
|
83
|
+
items.add(3)
|
|
84
|
+
expect(collection.length).toBe(3)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('index-based access', () => {
|
|
89
|
+
test('properties can be accessed via computed signals', () => {
|
|
90
|
+
const items = new List([10, 20, 30])
|
|
91
|
+
const doubled = new Collection(items, (x: number) => x * 2)
|
|
92
|
+
|
|
93
|
+
expect(doubled.at(0)?.get()).toBe(20)
|
|
94
|
+
expect(doubled.at(1)?.get()).toBe(40)
|
|
95
|
+
expect(doubled.at(2)?.get()).toBe(60)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('returns undefined for non-existent properties', () => {
|
|
99
|
+
const items = new List([1, 2])
|
|
100
|
+
const collection = new Collection(items, (x: number) => x)
|
|
101
|
+
expect(collection[5]).toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('supports numeric key access', () => {
|
|
105
|
+
const numbers = new List([1, 2, 3])
|
|
106
|
+
const collection = new Collection(numbers, (x: number) => x * 2)
|
|
107
|
+
expect(collection.at(1)?.get()).toBe(4)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('key-based access methods', () => {
|
|
112
|
+
test('byKey() returns computed signal for existing keys', () => {
|
|
113
|
+
const numbers = new List([1, 2, 3])
|
|
114
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
115
|
+
|
|
116
|
+
const key0 = numbers.keyAt(0)
|
|
117
|
+
const key1 = numbers.keyAt(1)
|
|
118
|
+
|
|
119
|
+
expect(key0).toBeDefined()
|
|
120
|
+
expect(key1).toBeDefined()
|
|
121
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
122
|
+
expect(doubled.byKey(key0!)).toBeDefined()
|
|
123
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
124
|
+
expect(doubled.byKey(key1!)).toBeDefined()
|
|
125
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
126
|
+
expect(doubled.byKey(key0!)?.get()).toBe(2)
|
|
127
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
128
|
+
expect(doubled.byKey(key1!)?.get()).toBe(4)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('keyAt() and indexOfKey() work correctly', () => {
|
|
132
|
+
const numbers = new List([5, 10, 15])
|
|
133
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
134
|
+
|
|
135
|
+
const key0 = doubled.keyAt(0)
|
|
136
|
+
const key1 = doubled.keyAt(1)
|
|
137
|
+
|
|
138
|
+
expect(key0).toBeDefined()
|
|
139
|
+
expect(key1).toBeDefined()
|
|
140
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
141
|
+
expect(doubled.indexOfKey(key0!)).toBe(0)
|
|
142
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
143
|
+
expect(doubled.indexOfKey(key1!)).toBe(1)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('reactivity', () => {
|
|
148
|
+
test('collection-level get() is reactive', () => {
|
|
149
|
+
const numbers = new List([1, 2, 3])
|
|
150
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
151
|
+
|
|
152
|
+
let lastArray: number[] = []
|
|
153
|
+
createEffect(() => {
|
|
154
|
+
lastArray = doubled.get()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(lastArray).toEqual([2, 4, 6])
|
|
158
|
+
numbers.add(4)
|
|
159
|
+
expect(lastArray).toEqual([2, 4, 6, 8])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('individual signal reactivity works', () => {
|
|
163
|
+
const items = new List([{ count: 1 }, { count: 2 }])
|
|
164
|
+
const doubled = new Collection(
|
|
165
|
+
items,
|
|
166
|
+
(item: { count: number }) => ({ count: item.count * 2 }),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
let lastItem: { count: number } | undefined
|
|
170
|
+
let itemEffectRuns = 0
|
|
171
|
+
createEffect(() => {
|
|
172
|
+
lastItem = doubled.at(0)?.get()
|
|
173
|
+
itemEffectRuns++
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(lastItem).toEqual({ count: 2 })
|
|
177
|
+
expect(itemEffectRuns).toBe(1)
|
|
178
|
+
|
|
179
|
+
items.at(0)?.set({ count: 5 })
|
|
180
|
+
expect(lastItem).toEqual({ count: 10 })
|
|
181
|
+
// Effect runs twice: once initially, once for change
|
|
182
|
+
expect(itemEffectRuns).toEqual(2)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('updates are reactive', () => {
|
|
186
|
+
const numbers = new List([1, 2, 3])
|
|
187
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
188
|
+
|
|
189
|
+
let lastArray: number[] = []
|
|
190
|
+
let arrayEffectRuns = 0
|
|
191
|
+
createEffect(() => {
|
|
192
|
+
lastArray = doubled.get()
|
|
193
|
+
arrayEffectRuns++
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(lastArray).toEqual([2, 4, 6])
|
|
197
|
+
expect(arrayEffectRuns).toBe(1)
|
|
198
|
+
|
|
199
|
+
numbers.at(1)?.set(10)
|
|
200
|
+
expect(lastArray).toEqual([2, 20, 6])
|
|
201
|
+
expect(arrayEffectRuns).toBe(2)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('iteration and spreading', () => {
|
|
206
|
+
test('supports for...of iteration', () => {
|
|
207
|
+
const numbers = new List([1, 2, 3])
|
|
208
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
209
|
+
const signals = [...doubled]
|
|
210
|
+
|
|
211
|
+
expect(signals).toHaveLength(3)
|
|
212
|
+
expect(signals[0].get()).toBe(2)
|
|
213
|
+
expect(signals[1].get()).toBe(4)
|
|
214
|
+
expect(signals[2].get()).toBe(6)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('Symbol.isConcatSpreadable is true', () => {
|
|
218
|
+
const numbers = new List([1, 2, 3])
|
|
219
|
+
const collection = new Collection(numbers, (x: number) => x)
|
|
220
|
+
expect(collection[Symbol.isConcatSpreadable]).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('change notifications', () => {
|
|
225
|
+
test('emits add notifications', () => {
|
|
226
|
+
const numbers = new List([1, 2])
|
|
227
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
228
|
+
|
|
229
|
+
let arrayAddNotification: readonly string[] = []
|
|
230
|
+
doubled.on('add', keys => {
|
|
231
|
+
arrayAddNotification = keys
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
numbers.add(3)
|
|
235
|
+
expect(arrayAddNotification).toHaveLength(1)
|
|
236
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
237
|
+
expect(doubled.byKey(arrayAddNotification[0]!)?.get()).toBe(6)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('emits remove notifications when items are removed', () => {
|
|
241
|
+
const items = new List([1, 2, 3])
|
|
242
|
+
const doubled = new Collection(items, (x: number) => x * 2)
|
|
243
|
+
|
|
244
|
+
let arrayRemoveNotification: readonly string[] = []
|
|
245
|
+
doubled.on('remove', keys => {
|
|
246
|
+
arrayRemoveNotification = keys
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
items.remove(1)
|
|
250
|
+
expect(arrayRemoveNotification).toHaveLength(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('emits sort notifications when source is sorted', () => {
|
|
254
|
+
const numbers = new List([3, 1, 2])
|
|
255
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
256
|
+
|
|
257
|
+
let sortNotification: readonly string[] = []
|
|
258
|
+
doubled.on('sort', newOrder => {
|
|
259
|
+
sortNotification = newOrder
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
numbers.sort((a, b) => a - b)
|
|
263
|
+
expect(sortNotification).toHaveLength(3)
|
|
264
|
+
expect(doubled.get()).toEqual([2, 4, 6])
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('edge cases', () => {
|
|
269
|
+
test('handles empty collections correctly', () => {
|
|
270
|
+
const empty = new List<number>([])
|
|
271
|
+
const collection = new Collection(empty, (x: number) => x * 2)
|
|
272
|
+
expect(collection.length).toBe(0)
|
|
273
|
+
expect(collection.get()).toEqual([])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('handles UNSET values', () => {
|
|
277
|
+
const list = new List([1, 2, 3])
|
|
278
|
+
const processed = new Collection(list, (x: number) =>
|
|
279
|
+
x > 2 ? x : UNSET,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
// UNSET values should be filtered out
|
|
283
|
+
expect(processed.get()).toEqual([3])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('handles primitive values', () => {
|
|
287
|
+
const list = new List(['hello', 'world'])
|
|
288
|
+
const lengths = new Collection(list, (str: string) => ({
|
|
289
|
+
length: str.length,
|
|
290
|
+
}))
|
|
291
|
+
|
|
292
|
+
expect(lengths.at(0)?.get()).toEqual({ length: 5 })
|
|
293
|
+
expect(lengths.at(1)?.get()).toEqual({ length: 5 })
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('deriveCollection() method', () => {
|
|
298
|
+
describe('synchronous transformations', () => {
|
|
299
|
+
test('transforms collection values with sync callback', () => {
|
|
300
|
+
const numbers = new List([1, 2, 3])
|
|
301
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
302
|
+
const quadrupled = doubled.deriveCollection(
|
|
303
|
+
(x: number) => x * 2,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(quadrupled.length).toBe(3)
|
|
307
|
+
expect(quadrupled.at(0)?.get()).toBe(4)
|
|
308
|
+
expect(quadrupled.at(1)?.get()).toBe(8)
|
|
309
|
+
expect(quadrupled.at(2)?.get()).toBe(12)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('transforms object values with sync callback', () => {
|
|
313
|
+
const users = new List([
|
|
314
|
+
{ name: 'Alice', age: 25 },
|
|
315
|
+
{ name: 'Bob', age: 30 },
|
|
316
|
+
])
|
|
317
|
+
const basicInfo = new Collection(
|
|
318
|
+
users,
|
|
319
|
+
(user: { name: string; age: number }) => ({
|
|
320
|
+
displayName: user.name.toUpperCase(),
|
|
321
|
+
isAdult: user.age >= 18,
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
324
|
+
const detailedInfo = basicInfo.deriveCollection(
|
|
325
|
+
(info: { displayName: string; isAdult: boolean }) => ({
|
|
326
|
+
...info,
|
|
327
|
+
category: info.isAdult ? 'adult' : 'minor',
|
|
328
|
+
}),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
expect(detailedInfo.at(0)?.get()).toEqual({
|
|
332
|
+
displayName: 'ALICE',
|
|
333
|
+
isAdult: true,
|
|
334
|
+
category: 'adult',
|
|
335
|
+
})
|
|
336
|
+
expect(detailedInfo.at(1)?.get()).toEqual({
|
|
337
|
+
displayName: 'BOB',
|
|
338
|
+
isAdult: true,
|
|
339
|
+
category: 'adult',
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('transforms string values to different types', () => {
|
|
344
|
+
const words = new List(['hello', 'world', 'test'])
|
|
345
|
+
const wordInfo = new Collection(words, (word: string) => ({
|
|
346
|
+
word,
|
|
347
|
+
length: word.length,
|
|
348
|
+
}))
|
|
349
|
+
const analysis = wordInfo.deriveCollection(
|
|
350
|
+
(info: { word: string; length: number }) => ({
|
|
351
|
+
...info,
|
|
352
|
+
isLong: info.length > 4,
|
|
353
|
+
}),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
expect(analysis.at(0)?.get().word).toBe('hello')
|
|
357
|
+
expect(analysis.at(0)?.get().length).toBe(5)
|
|
358
|
+
expect(analysis.at(0)?.get().isLong).toBe(true)
|
|
359
|
+
expect(analysis.at(1)?.get().word).toBe('world')
|
|
360
|
+
expect(analysis.at(1)?.get().length).toBe(5)
|
|
361
|
+
expect(analysis.at(1)?.get().isLong).toBe(true)
|
|
362
|
+
expect(analysis.at(2)?.get().word).toBe('test')
|
|
363
|
+
expect(analysis.at(2)?.get().length).toBe(4)
|
|
364
|
+
expect(analysis.at(2)?.get().isLong).toBe(false)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('derived collection reactivity with sync transformations', () => {
|
|
368
|
+
const numbers = new List([1, 2, 3])
|
|
369
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
370
|
+
const quadrupled = doubled.deriveCollection(
|
|
371
|
+
(x: number) => x * 2,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
let collectionValue: number[] = []
|
|
375
|
+
let effectRuns = 0
|
|
376
|
+
createEffect(() => {
|
|
377
|
+
collectionValue = quadrupled.get()
|
|
378
|
+
effectRuns++
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
expect(collectionValue).toEqual([4, 8, 12])
|
|
382
|
+
expect(effectRuns).toBe(1)
|
|
383
|
+
|
|
384
|
+
numbers.add(4)
|
|
385
|
+
expect(collectionValue).toEqual([4, 8, 12, 16])
|
|
386
|
+
expect(effectRuns).toBe(2)
|
|
387
|
+
|
|
388
|
+
numbers.at(1)?.set(5)
|
|
389
|
+
expect(collectionValue).toEqual([4, 20, 12, 16])
|
|
390
|
+
expect(effectRuns).toBe(3)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('derived collection responds to source removal', () => {
|
|
394
|
+
const numbers = new List([1, 2, 3, 4])
|
|
395
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
396
|
+
const quadrupled = doubled.deriveCollection(
|
|
397
|
+
(x: number) => x * 2,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
expect(quadrupled.get()).toEqual([4, 8, 12, 16])
|
|
401
|
+
|
|
402
|
+
numbers.remove(1)
|
|
403
|
+
expect(quadrupled.get()).toEqual([4, 12, 16])
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe('asynchronous transformations', () => {
|
|
408
|
+
test('transforms values with async callback', async () => {
|
|
409
|
+
const numbers = new List([1, 2, 3])
|
|
410
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
411
|
+
|
|
412
|
+
const asyncQuadrupled = doubled.deriveCollection(
|
|
413
|
+
async (x: number, abort: AbortSignal) => {
|
|
414
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
415
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
416
|
+
return x * 2
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
const initialLength = asyncQuadrupled.length
|
|
421
|
+
expect(initialLength).toBe(3)
|
|
422
|
+
|
|
423
|
+
// Initially, async computations return UNSET
|
|
424
|
+
expect(asyncQuadrupled.at(0)).toBeDefined()
|
|
425
|
+
expect(asyncQuadrupled.at(1)).toBeDefined()
|
|
426
|
+
expect(asyncQuadrupled.at(2)).toBeDefined()
|
|
427
|
+
|
|
428
|
+
// Use effects to test async reactivity
|
|
429
|
+
const results: number[] = []
|
|
430
|
+
let effectRuns = 0
|
|
431
|
+
|
|
432
|
+
createEffect(() => {
|
|
433
|
+
const values = asyncQuadrupled.get()
|
|
434
|
+
results.push(...values)
|
|
435
|
+
effectRuns++
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
// Wait for async computations to complete
|
|
439
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
440
|
+
|
|
441
|
+
// Should have received the computed values
|
|
442
|
+
expect(results.slice(-3)).toEqual([4, 8, 12])
|
|
443
|
+
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('async derived collection with object transformation', async () => {
|
|
447
|
+
const users = new List([
|
|
448
|
+
{ id: 1, name: 'Alice' },
|
|
449
|
+
{ id: 2, name: 'Bob' },
|
|
450
|
+
])
|
|
451
|
+
const basicInfo = new Collection(
|
|
452
|
+
users,
|
|
453
|
+
(user: { id: number; name: string }) => ({
|
|
454
|
+
userId: user.id,
|
|
455
|
+
displayName: user.name.toUpperCase(),
|
|
456
|
+
}),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
const enrichedUsers = basicInfo.deriveCollection(
|
|
460
|
+
async (
|
|
461
|
+
info: { userId: number; displayName: string },
|
|
462
|
+
abort: AbortSignal,
|
|
463
|
+
) => {
|
|
464
|
+
// Simulate async enrichment
|
|
465
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
466
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
...info,
|
|
470
|
+
slug: info.displayName
|
|
471
|
+
.toLowerCase()
|
|
472
|
+
.replace(/\s+/g, '-'),
|
|
473
|
+
timestamp: Date.now(),
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
// Use effect to test async behavior
|
|
479
|
+
let enrichedResults: Array<{
|
|
480
|
+
userId: number
|
|
481
|
+
displayName: string
|
|
482
|
+
slug: string
|
|
483
|
+
timestamp: number
|
|
484
|
+
}> = []
|
|
485
|
+
|
|
486
|
+
createEffect(() => {
|
|
487
|
+
enrichedResults = enrichedUsers.get()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// Wait for async computations to complete
|
|
491
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
492
|
+
|
|
493
|
+
expect(enrichedResults).toHaveLength(2)
|
|
494
|
+
|
|
495
|
+
const [result1, result2] = enrichedResults
|
|
496
|
+
|
|
497
|
+
expect(result1?.userId).toBe(1)
|
|
498
|
+
expect(result1?.displayName).toBe('ALICE')
|
|
499
|
+
expect(result1?.slug).toBe('alice')
|
|
500
|
+
expect(typeof result1?.timestamp).toBe('number')
|
|
501
|
+
|
|
502
|
+
expect(result2?.userId).toBe(2)
|
|
503
|
+
expect(result2?.displayName).toBe('BOB')
|
|
504
|
+
expect(result2?.slug).toBe('bob')
|
|
505
|
+
expect(typeof result2?.timestamp).toBe('number')
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
test('async derived collection reactivity', async () => {
|
|
509
|
+
const numbers = new List([1, 2, 3])
|
|
510
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
511
|
+
const asyncQuadrupled = doubled.deriveCollection(
|
|
512
|
+
async (x: number, abort: AbortSignal) => {
|
|
513
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
514
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
515
|
+
return x * 2
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
const effectValues: number[][] = []
|
|
520
|
+
createEffect(() => {
|
|
521
|
+
// Access all values to trigger reactive behavior
|
|
522
|
+
const currentValue = asyncQuadrupled.get()
|
|
523
|
+
effectValues.push(currentValue)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
// Wait for initial effect
|
|
527
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
528
|
+
|
|
529
|
+
// Initial empty array (async values not resolved yet)
|
|
530
|
+
expect(effectValues[0]).toEqual([])
|
|
531
|
+
|
|
532
|
+
// Trigger individual computations
|
|
533
|
+
asyncQuadrupled.at(0)?.get()
|
|
534
|
+
asyncQuadrupled.at(1)?.get()
|
|
535
|
+
asyncQuadrupled.at(2)?.get()
|
|
536
|
+
|
|
537
|
+
// Wait for effects to process
|
|
538
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
539
|
+
|
|
540
|
+
// Should have the computed values now
|
|
541
|
+
const lastValue = effectValues[effectValues.length - 1]
|
|
542
|
+
expect(lastValue).toEqual([4, 8, 12])
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('handles AbortSignal cancellation', async () => {
|
|
546
|
+
const numbers = new List([1, 2, 3])
|
|
547
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
548
|
+
let abortCalled = false
|
|
549
|
+
|
|
550
|
+
const slowCollection = doubled.deriveCollection(
|
|
551
|
+
async (x: number, abort: AbortSignal) => {
|
|
552
|
+
abort.addEventListener('abort', () => {
|
|
553
|
+
abortCalled = true
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// Long delay to allow cancellation
|
|
557
|
+
const timeout = new Promise(resolve =>
|
|
558
|
+
setTimeout(resolve, 100),
|
|
559
|
+
)
|
|
560
|
+
await timeout
|
|
561
|
+
|
|
562
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
563
|
+
return x * 2
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
// Start computation
|
|
568
|
+
const _awaited = slowCollection.at(0)?.get()
|
|
569
|
+
|
|
570
|
+
// Change source to trigger cancellation
|
|
571
|
+
numbers.at(0)?.set(10)
|
|
572
|
+
|
|
573
|
+
// Wait for potential abort
|
|
574
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
575
|
+
|
|
576
|
+
expect(abortCalled).toBe(true)
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
describe('derived collection chaining', () => {
|
|
581
|
+
test('chains multiple sync derivations', () => {
|
|
582
|
+
const numbers = new List([1, 2, 3])
|
|
583
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
584
|
+
const quadrupled = doubled.deriveCollection(
|
|
585
|
+
(x: number) => x * 2,
|
|
586
|
+
)
|
|
587
|
+
const octupled = quadrupled.deriveCollection(
|
|
588
|
+
(x: number) => x * 2,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
expect(octupled.at(0)?.get()).toBe(8)
|
|
592
|
+
expect(octupled.at(1)?.get()).toBe(16)
|
|
593
|
+
expect(octupled.at(2)?.get()).toBe(24)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test('chains sync and async derivations', async () => {
|
|
597
|
+
const numbers = new List([1, 2, 3])
|
|
598
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
599
|
+
const quadrupled = doubled.deriveCollection(
|
|
600
|
+
(x: number) => x * 2,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
const asyncOctupled = quadrupled.deriveCollection(
|
|
604
|
+
async (x: number, abort: AbortSignal) => {
|
|
605
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
606
|
+
if (abort.aborted) throw new Error('Aborted')
|
|
607
|
+
return x * 2
|
|
608
|
+
},
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
// Use effect to test chained async behavior
|
|
612
|
+
let chainedResults: number[] = []
|
|
613
|
+
|
|
614
|
+
createEffect(() => {
|
|
615
|
+
chainedResults = asyncOctupled.get()
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// Wait for async computations to complete
|
|
619
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
620
|
+
|
|
621
|
+
expect(chainedResults).toEqual([8, 16, 24])
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
describe('derived collection access methods', () => {
|
|
626
|
+
test('provides index-based access to computed signals', () => {
|
|
627
|
+
const numbers = new List([1, 2, 3])
|
|
628
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
629
|
+
const quadrupled = doubled.deriveCollection(
|
|
630
|
+
(x: number) => x * 2,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
expect(quadrupled.at(0)?.get()).toBe(4)
|
|
634
|
+
expect(quadrupled.at(1)?.get()).toBe(8)
|
|
635
|
+
expect(quadrupled.at(2)?.get()).toBe(12)
|
|
636
|
+
expect(quadrupled.at(10)).toBeUndefined()
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('supports key-based access', () => {
|
|
640
|
+
const numbers = new List([1, 2, 3])
|
|
641
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
642
|
+
const quadrupled = doubled.deriveCollection(
|
|
643
|
+
(x: number) => x * 2,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
const key0 = quadrupled.keyAt(0)
|
|
647
|
+
const key1 = quadrupled.keyAt(1)
|
|
648
|
+
|
|
649
|
+
expect(key0).toBeDefined()
|
|
650
|
+
expect(key1).toBeDefined()
|
|
651
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
652
|
+
expect(quadrupled.byKey(key0!)).toBeDefined()
|
|
653
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
654
|
+
expect(quadrupled.byKey(key1!)).toBeDefined()
|
|
655
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
656
|
+
expect(quadrupled.byKey(key0!)?.get()).toBe(4)
|
|
657
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
658
|
+
expect(quadrupled.byKey(key1!)?.get()).toBe(8)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('supports iteration', () => {
|
|
662
|
+
const numbers = new List([1, 2, 3])
|
|
663
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
664
|
+
const quadrupled = doubled.deriveCollection(
|
|
665
|
+
(x: number) => x * 2,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
const signals = [...quadrupled]
|
|
669
|
+
expect(signals).toHaveLength(3)
|
|
670
|
+
expect(signals[0].get()).toBe(4)
|
|
671
|
+
expect(signals[1].get()).toBe(8)
|
|
672
|
+
expect(signals[2].get()).toBe(12)
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
describe('derived collection event handling', () => {
|
|
677
|
+
test('emits add events when source adds items', () => {
|
|
678
|
+
const numbers = new List([1, 2])
|
|
679
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
680
|
+
const quadrupled = doubled.deriveCollection(
|
|
681
|
+
(x: number) => x * 2,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
let addedKeys: readonly string[] = []
|
|
685
|
+
quadrupled.on('add', keys => {
|
|
686
|
+
addedKeys = keys
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
numbers.add(3)
|
|
690
|
+
expect(addedKeys).toHaveLength(1)
|
|
691
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
692
|
+
expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
test('emits remove events when source removes items', () => {
|
|
696
|
+
const numbers = new List([1, 2, 3])
|
|
697
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
698
|
+
const quadrupled = doubled.deriveCollection(
|
|
699
|
+
(x: number) => x * 2,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
let removedKeys: readonly string[] = []
|
|
703
|
+
quadrupled.on('remove', keys => {
|
|
704
|
+
removedKeys = keys
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
numbers.remove(1)
|
|
708
|
+
expect(removedKeys).toHaveLength(1)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
test('emits sort events when source is sorted', () => {
|
|
712
|
+
const numbers = new List([3, 1, 2])
|
|
713
|
+
const doubled = new Collection(numbers, (x: number) => x * 2)
|
|
714
|
+
const quadrupled = doubled.deriveCollection(
|
|
715
|
+
(x: number) => x * 2,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
let sortedKeys: readonly string[] = []
|
|
719
|
+
quadrupled.on('sort', newOrder => {
|
|
720
|
+
sortedKeys = newOrder
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
numbers.sort((a, b) => a - b)
|
|
724
|
+
expect(sortedKeys).toHaveLength(3)
|
|
725
|
+
expect(quadrupled.get()).toEqual([4, 8, 12])
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
describe('edge cases', () => {
|
|
730
|
+
test('handles empty collection derivation', () => {
|
|
731
|
+
const empty = new List<number>([])
|
|
732
|
+
const emptyCollection = new Collection(
|
|
733
|
+
empty,
|
|
734
|
+
(x: number) => x * 2,
|
|
735
|
+
)
|
|
736
|
+
const derived = emptyCollection.deriveCollection(
|
|
737
|
+
(x: number) => x * 2,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
expect(derived.length).toBe(0)
|
|
741
|
+
expect(derived.get()).toEqual([])
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
test('handles UNSET values in transformation', () => {
|
|
745
|
+
const list = new List([1, 2, 3])
|
|
746
|
+
const filtered = new Collection(list, (x: number) =>
|
|
747
|
+
x > 1 ? { value: x } : UNSET,
|
|
748
|
+
)
|
|
749
|
+
const doubled = filtered.deriveCollection(
|
|
750
|
+
(x: { value: number }) => ({ value: x.value * 2 }),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
test('handles complex object transformations', () => {
|
|
757
|
+
const items = new List([
|
|
758
|
+
{ id: 1, data: { value: 10, active: true } },
|
|
759
|
+
{ id: 2, data: { value: 20, active: false } },
|
|
760
|
+
])
|
|
761
|
+
|
|
762
|
+
const processed = new Collection(
|
|
763
|
+
items,
|
|
764
|
+
(item: {
|
|
765
|
+
id: number
|
|
766
|
+
data: { value: number; active: boolean }
|
|
767
|
+
}) => ({
|
|
768
|
+
itemId: item.id,
|
|
769
|
+
processedValue: item.data.value * 2,
|
|
770
|
+
status: item.data.active ? 'active' : 'inactive',
|
|
771
|
+
}),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
const enhanced = processed.deriveCollection(
|
|
775
|
+
(item: {
|
|
776
|
+
itemId: number
|
|
777
|
+
processedValue: number
|
|
778
|
+
status: string
|
|
779
|
+
}) => ({
|
|
780
|
+
...item,
|
|
781
|
+
category: item.processedValue > 15 ? 'high' : 'low',
|
|
782
|
+
}),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
expect(enhanced.at(0)?.get().itemId).toBe(1)
|
|
786
|
+
expect(enhanced.at(0)?.get().processedValue).toBe(20)
|
|
787
|
+
expect(enhanced.at(0)?.get().status).toBe('active')
|
|
788
|
+
expect(enhanced.at(0)?.get().category).toBe('high')
|
|
789
|
+
expect(enhanced.at(1)?.get().itemId).toBe(2)
|
|
790
|
+
expect(enhanced.at(1)?.get().processedValue).toBe(40)
|
|
791
|
+
expect(enhanced.at(1)?.get().status).toBe('inactive')
|
|
792
|
+
expect(enhanced.at(1)?.get().category).toBe('high')
|
|
793
|
+
})
|
|
794
|
+
})
|
|
795
|
+
})
|
|
796
|
+
})
|