@zeix/cause-effect 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai-context.md +163 -226
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/.zed/settings.json +3 -0
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +197 -202
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +241 -220
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1326 -1174
- package/index.js +1 -1
- package/index.ts +58 -85
- package/package.json +9 -6
- package/src/errors.ts +118 -70
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -64
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -69
- package/test/benchmark.test.ts +473 -485
- package/test/collection.test.ts +455 -955
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +332 -857
- package/test/memo.test.ts +380 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -271
- package/test/store.test.ts +346 -898
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -19
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +73 -0
- package/types/src/signal.d.ts +43 -28
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -688
- package/archive/collection.ts +0 -310
- package/archive/computed.ts +0 -198
- package/archive/list.ts +0 -544
- package/archive/memo.ts +0 -140
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -357
- package/archive/task.ts +0 -191
- package/src/classes/collection.ts +0 -298
- package/src/classes/composite.ts +0 -171
- package/src/classes/computed.ts +0 -392
- package/src/classes/list.ts +0 -310
- package/src/classes/ref.ts +0 -96
- package/src/classes/state.ts +0 -131
- package/src/classes/store.ts +0 -227
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -96
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -275
- package/test/computed.test.ts +0 -1126
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -381
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -47
- package/types/src/classes/composite.d.ts +0 -15
- package/types/src/classes/computed.d.ts +0 -114
- package/types/src/classes/list.d.ts +0 -41
- package/types/src/classes/ref.d.ts +0 -48
- package/types/src/classes/state.d.ts +0 -61
- package/types/src/classes/store.d.ts +0 -51
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -81
package/test/list.test.ts
CHANGED
|
@@ -1,982 +1,457 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
createEffect,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
isCollection,
|
|
4
|
+
createList,
|
|
5
|
+
createMemo,
|
|
7
6
|
isList,
|
|
8
|
-
|
|
9
|
-
Memo,
|
|
10
|
-
State,
|
|
11
|
-
UNSET,
|
|
7
|
+
isMemo,
|
|
12
8
|
} from '../index.ts'
|
|
13
9
|
|
|
14
|
-
describe('
|
|
15
|
-
describe('
|
|
16
|
-
test('
|
|
17
|
-
const
|
|
18
|
-
expect(
|
|
19
|
-
expect(numbers.at(1)?.get()).toBe(2)
|
|
20
|
-
expect(numbers.at(2)?.get()).toBe(3)
|
|
10
|
+
describe('List', () => {
|
|
11
|
+
describe('createList', () => {
|
|
12
|
+
test('should return initial values from get()', () => {
|
|
13
|
+
const list = createList([1, 2, 3])
|
|
14
|
+
expect(list.get()).toEqual([1, 2, 3])
|
|
21
15
|
})
|
|
22
16
|
|
|
23
|
-
test('
|
|
24
|
-
const list =
|
|
25
|
-
expect(list[Symbol.toStringTag]).toBe('List')
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test('isList identifies list instances correctly', () => {
|
|
29
|
-
const store = createStore({ a: 1 })
|
|
30
|
-
const list = new List([1])
|
|
31
|
-
const state = new State(1)
|
|
32
|
-
const computed = new Memo(() => 1)
|
|
33
|
-
|
|
34
|
-
expect(isList(list)).toBe(true)
|
|
35
|
-
expect(isStore(store)).toBe(true)
|
|
36
|
-
expect(isList(state)).toBe(false)
|
|
37
|
-
expect(isList(computed)).toBe(false)
|
|
38
|
-
expect(isList({})).toBe(false)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('get() returns the complete list value', () => {
|
|
42
|
-
const numbers = new List([1, 2, 3])
|
|
43
|
-
expect(numbers.get()).toEqual([1, 2, 3])
|
|
44
|
-
|
|
45
|
-
// Nested structures
|
|
46
|
-
const participants = new List([
|
|
17
|
+
test('should work with object items', () => {
|
|
18
|
+
const list = createList([
|
|
47
19
|
{ name: 'Alice', tags: ['admin'] },
|
|
48
20
|
{ name: 'Bob', tags: ['user'] },
|
|
49
21
|
])
|
|
50
|
-
expect(
|
|
22
|
+
expect(list.get()).toEqual([
|
|
51
23
|
{ name: 'Alice', tags: ['admin'] },
|
|
52
24
|
{ name: 'Bob', tags: ['user'] },
|
|
53
25
|
])
|
|
54
26
|
})
|
|
55
|
-
})
|
|
56
27
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(
|
|
61
|
-
expect(typeof numbers.length).toBe('number')
|
|
28
|
+
test('should handle empty initial array', () => {
|
|
29
|
+
const list = createList<number>([])
|
|
30
|
+
expect(list.get()).toEqual([])
|
|
31
|
+
expect(list.length).toBe(0)
|
|
62
32
|
})
|
|
63
33
|
|
|
64
|
-
test('
|
|
65
|
-
const
|
|
66
|
-
expect(
|
|
67
|
-
items.add(3)
|
|
68
|
-
expect(items.length).toBe(3)
|
|
69
|
-
items.remove(1)
|
|
70
|
-
expect(items.length).toBe(2)
|
|
34
|
+
test('should have Symbol.toStringTag of "List"', () => {
|
|
35
|
+
const list = createList([1])
|
|
36
|
+
expect(list[Symbol.toStringTag]).toBe('List')
|
|
71
37
|
})
|
|
72
|
-
})
|
|
73
38
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(items.at(0)?.get()).toBe('a')
|
|
78
|
-
expect(items.at(1)?.get()).toBe('b')
|
|
79
|
-
items.at(0)?.set('alpha')
|
|
80
|
-
items.at(1)?.set('beta')
|
|
81
|
-
expect(items.at(0)?.get()).toBe('alpha')
|
|
82
|
-
expect(items.at(1)?.get()).toBe('beta')
|
|
39
|
+
test('should have Symbol.isConcatSpreadable set to true', () => {
|
|
40
|
+
const list = createList([1])
|
|
41
|
+
expect(list[Symbol.isConcatSpreadable]).toBe(true)
|
|
83
42
|
})
|
|
43
|
+
})
|
|
84
44
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(
|
|
45
|
+
describe('isList', () => {
|
|
46
|
+
test('should identify list signals', () => {
|
|
47
|
+
expect(isList(createList([1]))).toBe(true)
|
|
88
48
|
})
|
|
89
|
-
})
|
|
90
49
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
expect(
|
|
50
|
+
test('should return false for non-list values', () => {
|
|
51
|
+
expect(isList(42)).toBe(false)
|
|
52
|
+
expect(isList(null)).toBe(false)
|
|
53
|
+
expect(isList({})).toBe(false)
|
|
54
|
+
expect(isMemo(createList([1]))).toBe(false)
|
|
96
55
|
})
|
|
56
|
+
})
|
|
97
57
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
58
|
+
describe('at', () => {
|
|
59
|
+
test('should return State signal at index', () => {
|
|
60
|
+
const list = createList(['a', 'b', 'c'])
|
|
61
|
+
expect(list.at(0)?.get()).toBe('a')
|
|
62
|
+
expect(list.at(1)?.get()).toBe('b')
|
|
63
|
+
expect(list.at(2)?.get()).toBe('c')
|
|
103
64
|
})
|
|
104
65
|
|
|
105
|
-
test('
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
expect(() => items.add(null)).toThrow()
|
|
66
|
+
test('should return undefined for out-of-bounds index', () => {
|
|
67
|
+
const list = createList(['a'])
|
|
68
|
+
expect(list.at(5)).toBeUndefined()
|
|
109
69
|
})
|
|
110
70
|
|
|
111
|
-
test('
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
expect(
|
|
71
|
+
test('should allow mutation via returned State signal', () => {
|
|
72
|
+
const list = createList(['a', 'b'])
|
|
73
|
+
list.at(0)?.set('alpha')
|
|
74
|
+
expect(list.at(0)?.get()).toBe('alpha')
|
|
115
75
|
})
|
|
116
76
|
})
|
|
117
77
|
|
|
118
|
-
describe('
|
|
119
|
-
test('
|
|
120
|
-
const
|
|
78
|
+
describe('set', () => {
|
|
79
|
+
test('should replace entire array', () => {
|
|
80
|
+
const list = createList([1, 2, 3])
|
|
81
|
+
list.set([4, 5])
|
|
82
|
+
expect(list.get()).toEqual([4, 5])
|
|
83
|
+
expect(list.length).toBe(2)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('should diff and update changed items', () => {
|
|
87
|
+
const list = createList([1, 2, 3])
|
|
88
|
+
const signal0 = list.at(0)
|
|
89
|
+
list.set([10, 2, 3])
|
|
90
|
+
// Same signal reference, updated value
|
|
91
|
+
expect(signal0?.get()).toBe(10)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('should keep stable keys when reordering with content-based keyConfig', () => {
|
|
95
|
+
type Item = { id: string; val: number }
|
|
96
|
+
const list = createList<Item>(
|
|
97
|
+
[
|
|
98
|
+
{ id: 'a', val: 1 },
|
|
99
|
+
{ id: 'b', val: 2 },
|
|
100
|
+
{ id: 'c', val: 3 },
|
|
101
|
+
],
|
|
102
|
+
{ keyConfig: item => item.id },
|
|
103
|
+
)
|
|
121
104
|
|
|
122
|
-
|
|
123
|
-
|
|
105
|
+
// Grab signal references by key before reorder
|
|
106
|
+
const signalA = list.byKey('a')
|
|
107
|
+
const signalB = list.byKey('b')
|
|
108
|
+
const signalC = list.byKey('c')
|
|
124
109
|
|
|
125
|
-
|
|
126
|
-
|
|
110
|
+
// Reverse order
|
|
111
|
+
list.set([
|
|
112
|
+
{ id: 'c', val: 3 },
|
|
113
|
+
{ id: 'b', val: 2 },
|
|
114
|
+
{ id: 'a', val: 1 },
|
|
115
|
+
])
|
|
127
116
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(
|
|
131
|
-
|
|
117
|
+
// Keys should follow items, not positions
|
|
118
|
+
expect(list.byKey('a')?.get()).toEqual({ id: 'a', val: 1 })
|
|
119
|
+
expect(list.byKey('b')?.get()).toEqual({ id: 'b', val: 2 })
|
|
120
|
+
expect(list.byKey('c')?.get()).toEqual({ id: 'c', val: 3 })
|
|
132
121
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(order).toHaveLength(3)
|
|
141
|
-
expect(order).toEqual(['1', '2', '0'])
|
|
122
|
+
// Signal references should be preserved (same State objects)
|
|
123
|
+
expect(list.byKey('a')).toBe(signalA)
|
|
124
|
+
expect(list.byKey('b')).toBe(signalB)
|
|
125
|
+
expect(list.byKey('c')).toBe(signalC)
|
|
126
|
+
|
|
127
|
+
// Key order should match new array order
|
|
128
|
+
expect([...list.keys()]).toEqual(['c', 'b', 'a'])
|
|
142
129
|
})
|
|
143
130
|
|
|
144
|
-
test('
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
let lastValue: number[] = []
|
|
148
|
-
createEffect(() => {
|
|
149
|
-
lastValue = numbers.get()
|
|
150
|
-
effectCount++
|
|
131
|
+
test('should detect duplicates in set() with content-based keyConfig', () => {
|
|
132
|
+
const list = createList([{ id: 'a', val: 1 }], {
|
|
133
|
+
keyConfig: item => item.id,
|
|
151
134
|
})
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
expect(lastValue).toEqual([1, 2, 3])
|
|
135
|
+
expect(() =>
|
|
136
|
+
list.set([
|
|
137
|
+
{ id: 'a', val: 1 },
|
|
138
|
+
{ id: 'a', val: 2 },
|
|
139
|
+
]),
|
|
140
|
+
).toThrow('already exists')
|
|
159
141
|
})
|
|
160
142
|
})
|
|
161
143
|
|
|
162
|
-
describe('
|
|
163
|
-
test('
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
expect(
|
|
167
|
-
expect(numbers.get()).toEqual([1, 4])
|
|
144
|
+
describe('update', () => {
|
|
145
|
+
test('should update via callback', () => {
|
|
146
|
+
const list = createList([1, 2])
|
|
147
|
+
list.update(arr => [...arr, 3])
|
|
148
|
+
expect(list.get()).toEqual([1, 2, 3])
|
|
168
149
|
})
|
|
150
|
+
})
|
|
169
151
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
expect(
|
|
152
|
+
describe('add', () => {
|
|
153
|
+
test('should append item and return key', () => {
|
|
154
|
+
const list = createList(['apple', 'banana'])
|
|
155
|
+
const key = list.add('cherry')
|
|
156
|
+
expect(typeof key).toBe('string')
|
|
157
|
+
expect(list.at(2)?.get()).toBe('cherry')
|
|
158
|
+
expect(list.byKey(key)?.get()).toBe('cherry')
|
|
175
159
|
})
|
|
176
160
|
|
|
177
|
-
test('
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
expect(
|
|
181
|
-
expect(numbers.get()).toEqual([1, 4, 5, 3])
|
|
161
|
+
test('should throw for null value', () => {
|
|
162
|
+
const list = createList([1])
|
|
163
|
+
// @ts-expect-error - Testing invalid input
|
|
164
|
+
expect(() => list.add(null)).toThrow()
|
|
182
165
|
})
|
|
183
166
|
|
|
184
|
-
test('
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
expect(
|
|
167
|
+
test('should throw DuplicateKeyError for duplicate keys', () => {
|
|
168
|
+
const list = createList([{ id: 'a', val: 1 }], {
|
|
169
|
+
keyConfig: item => item.id,
|
|
170
|
+
})
|
|
171
|
+
expect(() => list.add({ id: 'a', val: 2 })).toThrow(
|
|
172
|
+
'already exists',
|
|
173
|
+
)
|
|
189
174
|
})
|
|
190
175
|
})
|
|
191
176
|
|
|
192
|
-
describe('
|
|
193
|
-
test('
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
177
|
+
describe('remove', () => {
|
|
178
|
+
test('should remove by index', () => {
|
|
179
|
+
const list = createList(['a', 'b', 'c'])
|
|
180
|
+
list.remove(1)
|
|
181
|
+
expect(list.get()).toEqual(['a', 'c'])
|
|
182
|
+
expect(list.length).toBe(2)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('should remove by key', () => {
|
|
186
|
+
const list = createList(
|
|
187
|
+
[
|
|
188
|
+
{ id: 'x', val: 1 },
|
|
189
|
+
{ id: 'y', val: 2 },
|
|
190
|
+
],
|
|
191
|
+
{ keyConfig: item => item.id },
|
|
192
|
+
)
|
|
193
|
+
list.remove('x')
|
|
194
|
+
expect(list.get()).toEqual([{ id: 'y', val: 2 }])
|
|
203
195
|
})
|
|
204
196
|
|
|
205
|
-
test('
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
itemEffectRuns++
|
|
212
|
-
})
|
|
197
|
+
test('should handle non-existent index gracefully', () => {
|
|
198
|
+
const list = createList(['a'])
|
|
199
|
+
expect(() => list.remove(5)).not.toThrow()
|
|
200
|
+
expect(list.get()).toEqual(['a'])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
213
203
|
|
|
214
|
-
|
|
215
|
-
|
|
204
|
+
describe('sort', () => {
|
|
205
|
+
test('should sort with default string comparison', () => {
|
|
206
|
+
const list = createList([3, 1, 2])
|
|
207
|
+
list.sort()
|
|
208
|
+
expect(list.get()).toEqual([1, 2, 3])
|
|
209
|
+
})
|
|
216
210
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
test('should sort with custom compare function', () => {
|
|
212
|
+
const list = createList([3, 1, 2])
|
|
213
|
+
list.sort((a, b) => b - a)
|
|
214
|
+
expect(list.get()).toEqual([3, 2, 1])
|
|
220
215
|
})
|
|
221
216
|
|
|
222
|
-
test('
|
|
223
|
-
const
|
|
224
|
-
let
|
|
225
|
-
let
|
|
217
|
+
test('should trigger effects on sort', () => {
|
|
218
|
+
const list = createList([3, 1, 2])
|
|
219
|
+
let effectCount = 0
|
|
220
|
+
let lastValue: number[] = []
|
|
226
221
|
createEffect(() => {
|
|
227
|
-
|
|
228
|
-
|
|
222
|
+
lastValue = list.get()
|
|
223
|
+
effectCount++
|
|
229
224
|
})
|
|
230
225
|
|
|
231
|
-
expect(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
expect(lastArray).toEqual([1, 2, 3])
|
|
236
|
-
expect(arrayEffectRuns).toBe(2)
|
|
226
|
+
expect(effectCount).toBe(1)
|
|
227
|
+
list.sort()
|
|
228
|
+
expect(effectCount).toBe(2)
|
|
229
|
+
expect(lastValue).toEqual([1, 2, 3])
|
|
237
230
|
})
|
|
238
231
|
})
|
|
239
232
|
|
|
240
|
-
describe('
|
|
241
|
-
test('
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
expect(sum.get()).toBe(6)
|
|
248
|
-
numbers.add(4)
|
|
249
|
-
expect(sum.get()).toBe(10)
|
|
233
|
+
describe('splice', () => {
|
|
234
|
+
test('should remove elements', () => {
|
|
235
|
+
const list = createList([1, 2, 3, 4])
|
|
236
|
+
const deleted = list.splice(1, 2)
|
|
237
|
+
expect(deleted).toEqual([2, 3])
|
|
238
|
+
expect(list.get()).toEqual([1, 4])
|
|
250
239
|
})
|
|
251
240
|
|
|
252
|
-
test('
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
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)
|
|
241
|
+
test('should insert elements', () => {
|
|
242
|
+
const list = createList([1, 3])
|
|
243
|
+
const deleted = list.splice(1, 0, 2)
|
|
244
|
+
expect(deleted).toEqual([])
|
|
245
|
+
expect(list.get()).toEqual([1, 2, 3])
|
|
268
246
|
})
|
|
269
247
|
|
|
270
|
-
test('
|
|
271
|
-
const
|
|
248
|
+
test('should replace elements', () => {
|
|
249
|
+
const list = createList([1, 2, 3])
|
|
250
|
+
const deleted = list.splice(1, 1, 4, 5)
|
|
251
|
+
expect(deleted).toEqual([2])
|
|
252
|
+
expect(list.get()).toEqual([1, 4, 5, 3])
|
|
253
|
+
})
|
|
272
254
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
return total
|
|
281
|
-
})
|
|
255
|
+
test('should handle negative start index', () => {
|
|
256
|
+
const list = createList([1, 2, 3])
|
|
257
|
+
const deleted = list.splice(-1, 1, 4)
|
|
258
|
+
expect(deleted).toEqual([3])
|
|
259
|
+
expect(list.get()).toEqual([1, 2, 4])
|
|
260
|
+
})
|
|
282
261
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
262
|
+
test('should throw DuplicateKeyError for duplicate keys on insert', () => {
|
|
263
|
+
const list = createList(
|
|
264
|
+
[
|
|
265
|
+
{ id: 'a', val: 1 },
|
|
266
|
+
{ id: 'b', val: 2 },
|
|
267
|
+
],
|
|
268
|
+
{ keyConfig: item => item.id },
|
|
269
|
+
)
|
|
270
|
+
expect(() => list.splice(1, 0, { id: 'a', val: 3 })).toThrow(
|
|
271
|
+
'already exists',
|
|
272
|
+
)
|
|
286
273
|
})
|
|
287
274
|
})
|
|
288
275
|
|
|
289
|
-
describe('
|
|
290
|
-
test('
|
|
291
|
-
const
|
|
292
|
-
|
|
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)
|
|
276
|
+
describe('length', () => {
|
|
277
|
+
test('should return item count', () => {
|
|
278
|
+
const list = createList([1, 2, 3])
|
|
279
|
+
expect(list.length).toBe(3)
|
|
297
280
|
})
|
|
298
281
|
|
|
299
|
-
test('
|
|
300
|
-
const
|
|
301
|
-
expect(
|
|
282
|
+
test('should update reactively with add and remove', () => {
|
|
283
|
+
const list = createList([1, 2])
|
|
284
|
+
expect(list.length).toBe(2)
|
|
285
|
+
list.add(3)
|
|
286
|
+
expect(list.length).toBe(3)
|
|
287
|
+
list.remove(0)
|
|
288
|
+
expect(list.length).toBe(2)
|
|
302
289
|
})
|
|
303
290
|
})
|
|
304
291
|
|
|
305
|
-
describe('
|
|
306
|
-
test('
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
addedKeys = add
|
|
312
|
-
newArray = numbers.get()
|
|
313
|
-
})
|
|
314
|
-
numbers.add(3)
|
|
315
|
-
expect(addedKeys).toHaveLength(1)
|
|
316
|
-
expect(newArray).toEqual([1, 2, 3])
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
test('triggers HOOK_CHANGE when properties are modified', () => {
|
|
320
|
-
const items = new List([{ value: 10 }])
|
|
321
|
-
let changedKeys: readonly string[] | undefined
|
|
322
|
-
let newArray: { value: number }[] = []
|
|
323
|
-
items.on('change', change => {
|
|
324
|
-
changedKeys = change
|
|
325
|
-
newArray = items.get()
|
|
326
|
-
})
|
|
327
|
-
items.at(0)?.set({ value: 20 })
|
|
328
|
-
expect(changedKeys).toHaveLength(1)
|
|
329
|
-
expect(newArray).toEqual([{ value: 20 }])
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
test('triggers HOOK_REMOVE when items are removed', () => {
|
|
333
|
-
const items = new List([1, 2, 3])
|
|
334
|
-
let removedKeys: readonly string[] | undefined
|
|
335
|
-
let newArray: number[] = []
|
|
336
|
-
items.on('remove', remove => {
|
|
337
|
-
removedKeys = remove
|
|
338
|
-
newArray = items.get()
|
|
339
|
-
})
|
|
340
|
-
items.remove(1)
|
|
341
|
-
expect(removedKeys).toHaveLength(1)
|
|
342
|
-
expect(newArray).toEqual([1, 3])
|
|
292
|
+
describe('Key-based Access', () => {
|
|
293
|
+
test('keyAt should return key at index', () => {
|
|
294
|
+
const list = createList([10, 20, 30])
|
|
295
|
+
const key0 = list.keyAt(0)
|
|
296
|
+
expect(key0).toBeDefined()
|
|
297
|
+
expect(typeof key0).toBe('string')
|
|
343
298
|
})
|
|
344
|
-
})
|
|
345
299
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
expect(
|
|
300
|
+
test('indexOfKey should return index for key', () => {
|
|
301
|
+
const list = createList([10, 20])
|
|
302
|
+
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
|
|
303
|
+
const key = list.keyAt(0)!
|
|
304
|
+
expect(list.indexOfKey(key)).toBe(0)
|
|
351
305
|
})
|
|
352
306
|
|
|
353
|
-
test('
|
|
354
|
-
const list =
|
|
355
|
-
|
|
307
|
+
test('byKey should return State signal for key', () => {
|
|
308
|
+
const list = createList([10, 20])
|
|
309
|
+
// biome-ignore lint/style/noNonNullAssertion: index is within bounds
|
|
310
|
+
const key = list.keyAt(0)!
|
|
311
|
+
expect(list.byKey(key)?.get()).toBe(10)
|
|
356
312
|
})
|
|
357
313
|
|
|
358
|
-
test('
|
|
359
|
-
const list =
|
|
360
|
-
|
|
361
|
-
expect(
|
|
362
|
-
expect(list.
|
|
314
|
+
test('keys should return iterator of all keys', () => {
|
|
315
|
+
const list = createList([10, 20, 30])
|
|
316
|
+
const allKeys = [...list.keys()]
|
|
317
|
+
expect(allKeys).toHaveLength(3)
|
|
318
|
+
expect(list.byKey(allKeys[0])?.get()).toBe(10)
|
|
363
319
|
})
|
|
364
320
|
})
|
|
365
321
|
|
|
366
|
-
describe('
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
})
|
|
322
|
+
describe('options.keyConfig', () => {
|
|
323
|
+
test('should use function to generate keys', () => {
|
|
324
|
+
const list = createList(
|
|
325
|
+
[
|
|
326
|
+
{ id: 'a', value: 1 },
|
|
327
|
+
{ id: 'b', value: 2 },
|
|
328
|
+
],
|
|
329
|
+
{ keyConfig: item => item.id },
|
|
330
|
+
)
|
|
331
|
+
expect(list.byKey('a')?.get()).toEqual({ id: 'a', value: 1 })
|
|
332
|
+
expect(list.byKey('b')?.get()).toEqual({ id: 'b', value: 2 })
|
|
449
333
|
})
|
|
450
334
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
})
|
|
335
|
+
test('should use string prefix for auto-generated keys', () => {
|
|
336
|
+
const list = createList([1, 2, 3], { keyConfig: 'item-' })
|
|
337
|
+
expect(list.keyAt(0)).toBe('item-0')
|
|
338
|
+
expect(list.keyAt(1)).toBe('item-1')
|
|
339
|
+
expect(list.keyAt(2)).toBe('item-2')
|
|
580
340
|
})
|
|
341
|
+
})
|
|
581
342
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
})
|
|
343
|
+
describe('Iteration', () => {
|
|
344
|
+
test('should support for...of via Symbol.iterator', () => {
|
|
345
|
+
const list = createList([10, 20, 30])
|
|
346
|
+
const signals = [...list]
|
|
347
|
+
expect(signals).toHaveLength(3)
|
|
348
|
+
expect(signals[0].get()).toBe(10)
|
|
349
|
+
expect(signals[1].get()).toBe(20)
|
|
350
|
+
expect(signals[2].get()).toBe(30)
|
|
619
351
|
})
|
|
352
|
+
})
|
|
620
353
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
}
|
|
354
|
+
describe('Reactivity', () => {
|
|
355
|
+
test('get() should trigger effects on structural changes', () => {
|
|
356
|
+
const list = createList([1, 2, 3])
|
|
357
|
+
let lastArray: number[] = []
|
|
358
|
+
createEffect(() => {
|
|
359
|
+
lastArray = list.get()
|
|
650
360
|
})
|
|
651
361
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
})
|
|
362
|
+
expect(lastArray).toEqual([1, 2, 3])
|
|
363
|
+
list.add(4)
|
|
364
|
+
expect(lastArray).toEqual([1, 2, 3, 4])
|
|
664
365
|
})
|
|
665
366
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
let addedKeys: readonly string[] | undefined
|
|
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[] | undefined
|
|
689
|
-
doubled.on('remove', keys => {
|
|
690
|
-
removedKeys = keys
|
|
691
|
-
})
|
|
692
|
-
|
|
693
|
-
numbers.remove(1)
|
|
694
|
-
expect(removedKeys).toHaveLength(1)
|
|
367
|
+
test('individual item signals should trigger effects', () => {
|
|
368
|
+
const list = createList([{ count: 5 }])
|
|
369
|
+
let lastCount = 0
|
|
370
|
+
let effectCount = 0
|
|
371
|
+
createEffect(() => {
|
|
372
|
+
lastCount = list.at(0)?.get().count ?? 0
|
|
373
|
+
effectCount++
|
|
695
374
|
})
|
|
696
375
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
const doubled = numbers.deriveCollection(
|
|
700
|
-
(value: number) => value * 2,
|
|
701
|
-
)
|
|
702
|
-
|
|
703
|
-
let sortedKeys: readonly string[] | undefined
|
|
704
|
-
doubled.on('sort', keys => {
|
|
705
|
-
sortedKeys = keys
|
|
706
|
-
})
|
|
376
|
+
expect(lastCount).toBe(5)
|
|
377
|
+
expect(effectCount).toBe(1)
|
|
707
378
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
})
|
|
379
|
+
list.at(0)?.set({ count: 10 })
|
|
380
|
+
expect(lastCount).toBe(10)
|
|
381
|
+
expect(effectCount).toBe(2)
|
|
712
382
|
})
|
|
713
383
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
})
|
|
384
|
+
test('computed signals should react to list changes', () => {
|
|
385
|
+
const list = createList([1, 2, 3])
|
|
386
|
+
const sum = createMemo(() =>
|
|
387
|
+
list.get().reduce((acc, n) => acc + n, 0),
|
|
388
|
+
)
|
|
734
389
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
})
|
|
390
|
+
expect(sum.get()).toBe(6)
|
|
391
|
+
list.add(4)
|
|
392
|
+
expect(sum.get()).toBe(10)
|
|
393
|
+
list.remove(0)
|
|
394
|
+
expect(sum.get()).toBe(9)
|
|
752
395
|
})
|
|
753
396
|
})
|
|
754
397
|
|
|
755
|
-
describe('
|
|
756
|
-
test('
|
|
757
|
-
|
|
758
|
-
let
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
398
|
+
describe('options.watched', () => {
|
|
399
|
+
test('should call watched on first subscriber and cleanup on last unsubscribe', () => {
|
|
400
|
+
let watchedCalled = false
|
|
401
|
+
let unwatchedCalled = false
|
|
402
|
+
const list = createList([10, 20], {
|
|
403
|
+
watched: () => {
|
|
404
|
+
watchedCalled = true
|
|
405
|
+
return () => {
|
|
406
|
+
unwatchedCalled = true
|
|
407
|
+
}
|
|
408
|
+
},
|
|
767
409
|
})
|
|
768
410
|
|
|
769
|
-
expect(
|
|
411
|
+
expect(watchedCalled).toBe(false)
|
|
770
412
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const cleanup = createEffect(() => {
|
|
774
|
-
effectValue = numbers.get()
|
|
413
|
+
const dispose = createEffect(() => {
|
|
414
|
+
list.get()
|
|
775
415
|
})
|
|
776
416
|
|
|
777
|
-
expect(
|
|
778
|
-
expect(
|
|
779
|
-
expect(listUnwatchCalled).toBe(false)
|
|
417
|
+
expect(watchedCalled).toBe(true)
|
|
418
|
+
expect(unwatchedCalled).toBe(false)
|
|
780
419
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
expect(listUnwatchCalled).toBe(true)
|
|
420
|
+
dispose()
|
|
421
|
+
expect(unwatchedCalled).toBe(true)
|
|
784
422
|
})
|
|
785
423
|
|
|
786
|
-
test('
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const firstItemSignal = items.at(0)!
|
|
794
|
-
firstItemSignal.on('watch', () => {
|
|
795
|
-
firstItemHookCalled = true
|
|
796
|
-
return () => {
|
|
797
|
-
firstItemUnwatchCalled = true
|
|
798
|
-
}
|
|
424
|
+
test('should activate on length access', () => {
|
|
425
|
+
let watchedCalled = false
|
|
426
|
+
const list = createList([1, 2], {
|
|
427
|
+
watched: () => {
|
|
428
|
+
watchedCalled = true
|
|
429
|
+
return () => {}
|
|
430
|
+
},
|
|
799
431
|
})
|
|
800
432
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
// Access first item via signal.get() - this should trigger the State signal's HOOK_WATCH
|
|
804
|
-
let effectValue: string | undefined
|
|
805
|
-
const cleanup = createEffect(() => {
|
|
806
|
-
effectValue = firstItemSignal.get()
|
|
433
|
+
const dispose = createEffect(() => {
|
|
434
|
+
void list.length
|
|
807
435
|
})
|
|
808
436
|
|
|
809
|
-
expect(
|
|
810
|
-
|
|
811
|
-
expect(firstItemUnwatchCalled).toBe(false)
|
|
812
|
-
|
|
813
|
-
// Cleanup effect - should trigger State signal's unwatch
|
|
814
|
-
cleanup()
|
|
815
|
-
expect(firstItemUnwatchCalled).toBe(true)
|
|
437
|
+
expect(watchedCalled).toBe(true)
|
|
438
|
+
dispose()
|
|
816
439
|
})
|
|
440
|
+
})
|
|
817
441
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
const firstItemSignal = items.at(0)!
|
|
825
|
-
firstItemSignal.on('watch', () => {
|
|
826
|
-
return () => {
|
|
827
|
-
firstItemUnwatchCalled = true
|
|
828
|
-
}
|
|
829
|
-
})
|
|
830
|
-
|
|
831
|
-
let effectValue: string | undefined
|
|
832
|
-
const cleanup = createEffect(() => {
|
|
833
|
-
effectValue = firstItemSignal.get()
|
|
834
|
-
})
|
|
835
|
-
|
|
836
|
-
expect(effectValue).toBe('first')
|
|
837
|
-
expect(firstItemUnwatchCalled).toBe(false)
|
|
838
|
-
|
|
839
|
-
// Remove the first item (index 0) - the State signal still exists but the list changed
|
|
840
|
-
items.remove(0)
|
|
841
|
-
|
|
842
|
-
// The State signal should still work (it's not automatically cleaned up)
|
|
843
|
-
expect(effectValue).toBe('first') // State signal retains its value
|
|
844
|
-
expect(firstItemUnwatchCalled).toBe(false) // Unwatch only happens when effect cleanup
|
|
845
|
-
|
|
846
|
-
// Cleanup the effect - this should call the State signal's unwatch
|
|
847
|
-
cleanup()
|
|
848
|
-
expect(firstItemUnwatchCalled).toBe(true)
|
|
849
|
-
})
|
|
850
|
-
|
|
851
|
-
test('new items added to list get independent State signals with their own hooks', () => {
|
|
852
|
-
const numbers = new List<number>([])
|
|
853
|
-
|
|
854
|
-
// Start with empty list - create effect that tries to access first item
|
|
855
|
-
let effectValue: number | undefined
|
|
856
|
-
const cleanup = createEffect(() => {
|
|
857
|
-
const firstItem = numbers.at(0)
|
|
858
|
-
effectValue = firstItem?.get()
|
|
859
|
-
})
|
|
860
|
-
|
|
861
|
-
// No items yet
|
|
862
|
-
expect(effectValue).toBe(undefined)
|
|
863
|
-
|
|
864
|
-
// Add first item
|
|
865
|
-
const key = numbers.add(42)
|
|
866
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
867
|
-
const newItemSignal = numbers.byKey(key)!
|
|
868
|
-
|
|
869
|
-
let newItemHookCalled = false
|
|
870
|
-
let newItemUnwatchCalled = false
|
|
871
|
-
newItemSignal.on('watch', () => {
|
|
872
|
-
newItemHookCalled = true
|
|
873
|
-
return () => {
|
|
874
|
-
newItemUnwatchCalled = true
|
|
875
|
-
}
|
|
876
|
-
})
|
|
877
|
-
|
|
878
|
-
// Create new effect to access the new item
|
|
879
|
-
let newEffectValue: number | undefined
|
|
880
|
-
const newCleanup = createEffect(() => {
|
|
881
|
-
newEffectValue = newItemSignal.get()
|
|
882
|
-
})
|
|
883
|
-
|
|
884
|
-
expect(newItemHookCalled).toBe(true)
|
|
885
|
-
expect(newEffectValue).toBe(42)
|
|
886
|
-
expect(newItemUnwatchCalled).toBe(false)
|
|
887
|
-
|
|
888
|
-
// Cleanup should trigger unwatch
|
|
889
|
-
newCleanup()
|
|
890
|
-
expect(newItemUnwatchCalled).toBe(true)
|
|
891
|
-
|
|
892
|
-
cleanup()
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
test('List length access triggers List HOOK_WATCH', () => {
|
|
896
|
-
const numbers = new List([1, 2, 3])
|
|
897
|
-
let listHookWatchCalled = false
|
|
898
|
-
let listUnwatchCalled = false
|
|
899
|
-
|
|
900
|
-
numbers.on('watch', () => {
|
|
901
|
-
listHookWatchCalled = true
|
|
902
|
-
return () => {
|
|
903
|
-
listUnwatchCalled = true
|
|
904
|
-
}
|
|
905
|
-
})
|
|
906
|
-
|
|
907
|
-
// Access via list.length - this should trigger list's HOOK_WATCH
|
|
908
|
-
let effectValue: number = 0
|
|
909
|
-
const cleanup = createEffect(() => {
|
|
910
|
-
effectValue = numbers.length
|
|
911
|
-
})
|
|
912
|
-
|
|
913
|
-
expect(listHookWatchCalled).toBe(true)
|
|
914
|
-
expect(effectValue).toBe(3)
|
|
915
|
-
expect(listUnwatchCalled).toBe(false)
|
|
916
|
-
|
|
917
|
-
cleanup()
|
|
918
|
-
expect(listUnwatchCalled).toBe(true)
|
|
442
|
+
describe('Input Validation', () => {
|
|
443
|
+
test('should throw for non-array initial value', () => {
|
|
444
|
+
expect(() => {
|
|
445
|
+
// @ts-expect-error - Testing invalid input
|
|
446
|
+
createList('not an array')
|
|
447
|
+
}).toThrow()
|
|
919
448
|
})
|
|
920
449
|
|
|
921
|
-
test('
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
// Set up List's HOOK_WATCH (this is triggered by list-level access like get() or length)
|
|
927
|
-
list.on('watch', () => {
|
|
928
|
-
listHookWatchCallCount++
|
|
929
|
-
return () => {
|
|
930
|
-
listUnwatchCallCount++
|
|
931
|
-
}
|
|
932
|
-
})
|
|
933
|
-
|
|
934
|
-
// Scenario 1: The list's HOOK_WATCH is called when an effect accesses the first item
|
|
935
|
-
// Note: list.at(0).get() accesses the State signal, not the list itself
|
|
936
|
-
// But if we access list.get() or list.length, it triggers the list's HOOK_WATCH
|
|
937
|
-
let effectValue: number | undefined
|
|
938
|
-
const cleanup1 = createEffect(() => {
|
|
939
|
-
// Access list first to trigger list HOOK_WATCH
|
|
940
|
-
const length = list.length
|
|
941
|
-
if (length > 0) {
|
|
942
|
-
effectValue = list.at(0)?.get()
|
|
943
|
-
} else {
|
|
944
|
-
effectValue = undefined
|
|
945
|
-
}
|
|
946
|
-
})
|
|
947
|
-
|
|
948
|
-
expect(listHookWatchCallCount).toBe(1) // List HOOK_WATCH called due to list.length access
|
|
949
|
-
expect(effectValue).toBe(42)
|
|
950
|
-
|
|
951
|
-
// Scenario 2: The list's unwatch callback is called when the only item with active subscription gets removed
|
|
952
|
-
list.remove(0)
|
|
953
|
-
// The effect should re-run due to list.length change and effectValue should now be undefined
|
|
954
|
-
expect(effectValue).toBe(undefined)
|
|
955
|
-
|
|
956
|
-
// The list unwatch is not called yet because the effect is still active (watching an empty list)
|
|
957
|
-
expect(listUnwatchCallCount).toBe(0)
|
|
958
|
-
|
|
959
|
-
// Clean up the first effect
|
|
960
|
-
cleanup1()
|
|
961
|
-
expect(listUnwatchCallCount).toBe(1) // Now unwatch is called
|
|
962
|
-
|
|
963
|
-
// Scenario 3: The list's HOOK_WATCH is restarted after a new item has been added that gets accessed by an effect
|
|
964
|
-
list.add(100)
|
|
965
|
-
|
|
966
|
-
const cleanup2 = createEffect(() => {
|
|
967
|
-
const length = list.length
|
|
968
|
-
if (length > 0) {
|
|
969
|
-
effectValue = list.at(0)?.get()
|
|
970
|
-
} else {
|
|
971
|
-
effectValue = undefined
|
|
972
|
-
}
|
|
973
|
-
})
|
|
974
|
-
|
|
975
|
-
expect(listHookWatchCallCount).toBe(2) // List HOOK_WATCH called again
|
|
976
|
-
expect(effectValue).toBe(100)
|
|
977
|
-
|
|
978
|
-
cleanup2()
|
|
979
|
-
expect(listUnwatchCallCount).toBe(2) // Second unwatch called
|
|
450
|
+
test('should throw for null initial value', () => {
|
|
451
|
+
expect(() => {
|
|
452
|
+
// @ts-expect-error - Testing invalid input
|
|
453
|
+
createList(null)
|
|
454
|
+
}).toThrow()
|
|
980
455
|
})
|
|
981
456
|
})
|
|
982
457
|
})
|