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