@zeix/cause-effect 0.16.0 → 0.16.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/README.md +1 -1
- package/index.dev.js +167 -162
- package/index.js +1 -1
- package/index.ts +3 -2
- package/package.json +1 -1
- package/src/computed.ts +15 -16
- package/src/diff.ts +24 -21
- package/src/state.ts +34 -45
- package/src/store.ts +170 -221
- package/src/util.ts +2 -6
- package/test/batch.test.ts +1 -1
- package/test/benchmark.test.ts +1 -1
- package/test/computed.test.ts +1 -1
- package/test/effect.test.ts +1 -1
- package/test/match.test.ts +1 -1
- package/test/resolve.test.ts +1 -1
- package/test/signal.test.ts +2 -2
- package/test/state.test.ts +1 -1
- package/test/store.test.ts +859 -1502
- package/types/index.d.ts +3 -3
- package/types/src/diff.d.ts +7 -7
- package/types/src/store.d.ts +6 -7
package/test/store.test.ts
CHANGED
|
@@ -11,158 +11,274 @@ import {
|
|
|
11
11
|
|
|
12
12
|
describe('store', () => {
|
|
13
13
|
describe('creation and basic operations', () => {
|
|
14
|
-
test('creates
|
|
14
|
+
test('creates stores with initial values', () => {
|
|
15
|
+
// Record store
|
|
15
16
|
const user = createStore({
|
|
16
17
|
name: 'Hannah',
|
|
17
18
|
email: 'hannah@example.com',
|
|
18
19
|
})
|
|
19
|
-
|
|
20
20
|
expect(user.name.get()).toBe('Hannah')
|
|
21
21
|
expect(user.email.get()).toBe('hannah@example.com')
|
|
22
|
+
|
|
23
|
+
// Array store
|
|
24
|
+
const numbers = createStore([1, 2, 3])
|
|
25
|
+
expect(numbers[0].get()).toBe(1)
|
|
26
|
+
expect(numbers[1].get()).toBe(2)
|
|
27
|
+
expect(numbers[2].get()).toBe(3)
|
|
22
28
|
})
|
|
23
29
|
|
|
24
30
|
test('has Symbol.toStringTag of Store', () => {
|
|
25
|
-
const
|
|
26
|
-
|
|
31
|
+
const recordStore = createStore({ a: 1 })
|
|
32
|
+
const arrayStore = createStore([1, 2])
|
|
33
|
+
|
|
34
|
+
expect(recordStore[Symbol.toStringTag]).toBe('Store')
|
|
35
|
+
expect(arrayStore[Symbol.toStringTag]).toBe('Store')
|
|
27
36
|
})
|
|
28
37
|
|
|
29
38
|
test('isStore identifies store instances correctly', () => {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
expect(isStore(
|
|
36
|
-
expect(isStore(
|
|
39
|
+
const recordStore = createStore({ a: 1 })
|
|
40
|
+
const arrayStore = createStore([1])
|
|
41
|
+
const state = createState(1)
|
|
42
|
+
const computed = createComputed(() => 1)
|
|
43
|
+
|
|
44
|
+
expect(isStore(recordStore)).toBe(true)
|
|
45
|
+
expect(isStore(arrayStore)).toBe(true)
|
|
46
|
+
expect(isStore(state)).toBe(false)
|
|
47
|
+
expect(isStore(computed)).toBe(false)
|
|
37
48
|
expect(isStore({})).toBe(false)
|
|
38
49
|
expect(isStore(null)).toBe(false)
|
|
39
50
|
})
|
|
40
51
|
|
|
41
52
|
test('get() returns the complete store value', () => {
|
|
53
|
+
// Record store
|
|
42
54
|
const user = createStore({
|
|
43
55
|
name: 'Hannah',
|
|
44
56
|
email: 'hannah@example.com',
|
|
45
57
|
})
|
|
46
|
-
|
|
47
58
|
expect(user.get()).toEqual({
|
|
48
59
|
name: 'Hannah',
|
|
49
60
|
email: 'hannah@example.com',
|
|
50
61
|
})
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
{ name: '
|
|
60
|
-
{ name: 'Bob', tags: ['friends'] },
|
|
63
|
+
// Array store
|
|
64
|
+
const numbers = createStore([1, 2, 3])
|
|
65
|
+
expect(numbers.get()).toEqual([1, 2, 3])
|
|
66
|
+
|
|
67
|
+
// Nested structures
|
|
68
|
+
const participants = createStore([
|
|
69
|
+
{ name: 'Alice', tags: ['admin'] },
|
|
70
|
+
{ name: 'Bob', tags: ['user'] },
|
|
61
71
|
])
|
|
72
|
+
expect(participants[0].name.get()).toBe('Alice')
|
|
73
|
+
expect(participants[0].tags.get()).toEqual(['admin'])
|
|
74
|
+
expect(participants[1].name.get()).toBe('Bob')
|
|
75
|
+
expect(participants[1].tags.get()).toEqual(['user'])
|
|
62
76
|
})
|
|
63
77
|
})
|
|
64
78
|
|
|
65
|
-
describe('
|
|
66
|
-
test('
|
|
67
|
-
|
|
79
|
+
describe('length property and sizing', () => {
|
|
80
|
+
test('length property works for both store types', () => {
|
|
81
|
+
// Record store
|
|
82
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
83
|
+
expect(user.length).toBe(2)
|
|
84
|
+
expect(typeof user.length).toBe('number')
|
|
68
85
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
expect(
|
|
86
|
+
// Array store
|
|
87
|
+
const numbers = createStore([1, 2, 3])
|
|
88
|
+
expect(numbers.length).toBe(3)
|
|
89
|
+
expect(typeof numbers.length).toBe('number')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('length is reactive and updates with changes', () => {
|
|
93
|
+
// Record store
|
|
94
|
+
const user = createStore<{ name: string; age?: number }>({
|
|
95
|
+
name: 'John',
|
|
96
|
+
})
|
|
97
|
+
expect(user.length).toBe(1)
|
|
98
|
+
user.add('age', 25)
|
|
99
|
+
expect(user.length).toBe(2)
|
|
100
|
+
user.remove('age')
|
|
101
|
+
expect(user.length).toBe(1)
|
|
72
102
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
// Array store
|
|
104
|
+
const items = createStore([1, 2])
|
|
105
|
+
expect(items.length).toBe(2)
|
|
106
|
+
items.add(3)
|
|
107
|
+
expect(items.length).toBe(3)
|
|
108
|
+
items.remove(1)
|
|
109
|
+
expect(items.length).toBe(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
76
112
|
|
|
113
|
+
describe('proxy data access and modification', () => {
|
|
114
|
+
test('properties can be accessed and modified via signals', () => {
|
|
115
|
+
// Record store
|
|
116
|
+
const user = createStore({ name: 'Alice', age: 30 })
|
|
77
117
|
expect(user.name.get()).toBe('Alice')
|
|
78
118
|
expect(user.age.get()).toBe(30)
|
|
119
|
+
user.name.set('Alicia')
|
|
120
|
+
user.age.set(31)
|
|
121
|
+
expect(user.name.get()).toBe('Alicia')
|
|
122
|
+
expect(user.age.get()).toBe(31)
|
|
123
|
+
|
|
124
|
+
// Array store
|
|
125
|
+
const items = createStore(['a', 'b'])
|
|
126
|
+
expect(items[0].get()).toBe('a')
|
|
127
|
+
expect(items[1].get()).toBe('b')
|
|
128
|
+
items[0].set('alpha')
|
|
129
|
+
items[1].set('beta')
|
|
130
|
+
expect(items[0].get()).toBe('alpha')
|
|
131
|
+
expect(items[1].get()).toBe('beta')
|
|
79
132
|
})
|
|
80
133
|
|
|
81
134
|
test('returns undefined for non-existent properties', () => {
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
// Record store
|
|
136
|
+
const user = createStore({ name: 'Alice' })
|
|
84
137
|
// @ts-expect-error accessing non-existent property
|
|
85
|
-
expect(user.
|
|
138
|
+
expect(user.nonexistent).toBeUndefined()
|
|
139
|
+
|
|
140
|
+
// Array store
|
|
141
|
+
const items = createStore(['a'])
|
|
142
|
+
expect(items[5]).toBeUndefined()
|
|
86
143
|
})
|
|
87
144
|
|
|
88
|
-
test('supports numeric key access', () => {
|
|
89
|
-
|
|
145
|
+
test('supports numeric key access for both store types', () => {
|
|
146
|
+
// Record store with numeric keys
|
|
147
|
+
const items = createStore({ 0: 'zero', 1: 'one' })
|
|
148
|
+
expect(items[0].get()).toBe('zero')
|
|
149
|
+
expect(items[1].get()).toBe('one')
|
|
90
150
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
151
|
+
// Array store with numeric keys
|
|
152
|
+
const numbers = createStore([10, 20])
|
|
153
|
+
expect(numbers[0].get()).toBe(10)
|
|
154
|
+
expect(numbers[1].get()).toBe(20)
|
|
95
155
|
})
|
|
156
|
+
})
|
|
96
157
|
|
|
97
|
-
|
|
158
|
+
describe('add() and remove() methods', () => {
|
|
159
|
+
test('add() method behavior differs between store types', () => {
|
|
160
|
+
// Record store - requires key parameter
|
|
98
161
|
const user = createStore<{ name: string; email?: string }>({
|
|
99
|
-
name: '
|
|
162
|
+
name: 'John',
|
|
100
163
|
})
|
|
164
|
+
user.add('email', 'john@example.com')
|
|
165
|
+
expect(user.email?.get()).toBe('john@example.com')
|
|
166
|
+
expect(user.length).toBe(2)
|
|
101
167
|
|
|
102
|
-
|
|
168
|
+
// Array store - single parameter adds to end
|
|
169
|
+
const fruits = createStore(['apple', 'banana'])
|
|
170
|
+
fruits.add('cherry')
|
|
171
|
+
expect(fruits[2].get()).toBe('cherry')
|
|
172
|
+
expect(fruits.length).toBe(3)
|
|
173
|
+
expect(fruits.get()).toEqual(['apple', 'banana', 'cherry'])
|
|
174
|
+
})
|
|
103
175
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
176
|
+
test('remove() method behavior differs between store types', () => {
|
|
177
|
+
// Record store - removes by key
|
|
178
|
+
const user = createStore({
|
|
179
|
+
name: 'John',
|
|
180
|
+
email: 'john@example.com',
|
|
108
181
|
})
|
|
182
|
+
user.remove('email')
|
|
183
|
+
expect(user.email).toBeUndefined()
|
|
184
|
+
expect(user.name.get()).toBe('John')
|
|
185
|
+
expect(user.length).toBe(1)
|
|
186
|
+
|
|
187
|
+
// Array store - removes by index
|
|
188
|
+
const items = createStore(['a', 'b', 'c'])
|
|
189
|
+
items.remove(1) // Remove 'b'
|
|
190
|
+
expect(items.get()).toEqual(['a', 'c'])
|
|
191
|
+
expect(items.length).toBe(2)
|
|
109
192
|
})
|
|
110
193
|
|
|
111
|
-
test('
|
|
194
|
+
test('add method prevents null values for both store types', () => {
|
|
195
|
+
// Record store
|
|
112
196
|
const user = createStore<{ name: string; email?: string }>({
|
|
113
|
-
name: '
|
|
114
|
-
email: 'hannah@example.com',
|
|
197
|
+
name: 'John',
|
|
115
198
|
})
|
|
199
|
+
// @ts-expect-error testing null values
|
|
200
|
+
expect(() => user.add('email', null)).toThrow()
|
|
116
201
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
202
|
+
// Array store
|
|
203
|
+
const items = createStore([1])
|
|
204
|
+
// @ts-expect-error testing null values
|
|
205
|
+
expect(() => items.add(null)).toThrow()
|
|
206
|
+
})
|
|
120
207
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
name: '
|
|
208
|
+
test('add method prevents overwriting existing properties in record stores', () => {
|
|
209
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
210
|
+
name: 'John',
|
|
211
|
+
email: 'john@example.com',
|
|
124
212
|
})
|
|
213
|
+
const originalSize = user.length
|
|
214
|
+
expect(() => user.add('name', 'Jane')).toThrow()
|
|
215
|
+
expect(user.length).toBe(originalSize)
|
|
216
|
+
expect(user.name.get()).toBe('John')
|
|
125
217
|
})
|
|
126
218
|
|
|
127
|
-
test('
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
219
|
+
test('remove method handles non-existent properties gracefully', () => {
|
|
220
|
+
// Record store
|
|
221
|
+
const user = createStore<{ name: string }>({ name: 'John' })
|
|
222
|
+
const originalSize = user.length
|
|
223
|
+
// @ts-expect-error deliberate removal of non-existent property
|
|
224
|
+
user.remove('nonexistent')
|
|
225
|
+
expect(user.length).toBe(originalSize)
|
|
131
226
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
'Nullish signal values are not allowed in store for key "tags"',
|
|
137
|
-
)
|
|
227
|
+
// Array store - out of bounds throws
|
|
228
|
+
const items = createStore([1, 2])
|
|
229
|
+
expect(() => items.remove(5)).toThrow()
|
|
230
|
+
expect(() => items.remove(-5)).toThrow()
|
|
138
231
|
})
|
|
139
232
|
})
|
|
140
233
|
|
|
141
234
|
describe('nested stores', () => {
|
|
142
|
-
test('creates nested stores for object properties', () => {
|
|
235
|
+
test('creates nested stores for object properties in both store types', () => {
|
|
236
|
+
// Record store
|
|
143
237
|
const user = createStore({
|
|
144
|
-
name: '
|
|
238
|
+
name: 'Alice',
|
|
145
239
|
preferences: {
|
|
146
240
|
theme: 'dark',
|
|
147
241
|
notifications: true,
|
|
148
242
|
},
|
|
149
243
|
})
|
|
150
|
-
|
|
151
244
|
expect(isStore(user.preferences)).toBe(true)
|
|
152
|
-
expect(user.preferences.theme
|
|
153
|
-
expect(user.preferences.notifications
|
|
245
|
+
expect(user.preferences.theme.get()).toBe('dark')
|
|
246
|
+
expect(user.preferences.notifications.get()).toBe(true)
|
|
247
|
+
|
|
248
|
+
// Array store with nested objects
|
|
249
|
+
const users = createStore([
|
|
250
|
+
{ name: 'Alice', active: true },
|
|
251
|
+
{ name: 'Bob', active: false },
|
|
252
|
+
])
|
|
253
|
+
expect(isStore(users[0])).toBe(true)
|
|
254
|
+
expect(users[0].name.get()).toBe('Alice')
|
|
255
|
+
expect(users[1].active.get()).toBe(false)
|
|
154
256
|
})
|
|
155
257
|
|
|
156
258
|
test('nested properties are reactive', () => {
|
|
259
|
+
// Record store
|
|
157
260
|
const user = createStore({
|
|
158
261
|
preferences: {
|
|
159
|
-
theme: '
|
|
262
|
+
theme: 'light',
|
|
160
263
|
},
|
|
161
264
|
})
|
|
265
|
+
let lastTheme = ''
|
|
266
|
+
createEffect(() => {
|
|
267
|
+
lastTheme = user.preferences.theme.get()
|
|
268
|
+
})
|
|
269
|
+
expect(lastTheme).toBe('light')
|
|
270
|
+
user.preferences.theme.set('dark')
|
|
271
|
+
expect(lastTheme).toBe('dark')
|
|
162
272
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
273
|
+
// Array store
|
|
274
|
+
const configs = createStore([{ mode: 'development' }])
|
|
275
|
+
let lastMode = ''
|
|
276
|
+
createEffect(() => {
|
|
277
|
+
lastMode = configs[0].mode.get()
|
|
278
|
+
})
|
|
279
|
+
expect(lastMode).toBe('development')
|
|
280
|
+
configs[0].mode.set('production')
|
|
281
|
+
expect(lastMode).toBe('production')
|
|
166
282
|
})
|
|
167
283
|
|
|
168
284
|
test('deeply nested stores work correctly', () => {
|
|
@@ -170,234 +286,203 @@ describe('store', () => {
|
|
|
170
286
|
ui: {
|
|
171
287
|
theme: {
|
|
172
288
|
colors: {
|
|
173
|
-
primary: 'blue',
|
|
289
|
+
primary: '#blue',
|
|
174
290
|
},
|
|
175
291
|
},
|
|
176
292
|
},
|
|
177
293
|
})
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
config.ui.theme.colors.primary.
|
|
181
|
-
expect(config.ui.theme.colors.primary.get()).toBe('red')
|
|
294
|
+
expect(config.ui.theme.colors.primary.get()).toBe('#blue')
|
|
295
|
+
config.ui.theme.colors.primary.set('#red')
|
|
296
|
+
expect(config.ui.theme.colors.primary.get()).toBe('#red')
|
|
182
297
|
})
|
|
183
298
|
})
|
|
184
299
|
|
|
185
300
|
describe('set() and update() methods', () => {
|
|
186
|
-
test('set() replaces entire store value', () => {
|
|
301
|
+
test('set() replaces entire store value for both store types', () => {
|
|
302
|
+
// Record store
|
|
187
303
|
const user = createStore({
|
|
188
|
-
name: '
|
|
189
|
-
email: '
|
|
304
|
+
name: 'John',
|
|
305
|
+
email: 'john@example.com',
|
|
190
306
|
})
|
|
307
|
+
user.set({ name: 'Jane', email: 'jane@example.com' })
|
|
308
|
+
expect(user.name.get()).toBe('Jane')
|
|
309
|
+
expect(user.email.get()).toBe('jane@example.com')
|
|
191
310
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
})
|
|
311
|
+
// Array store
|
|
312
|
+
const numbers = createStore([1, 2, 3])
|
|
313
|
+
numbers.set([4, 5])
|
|
314
|
+
expect(numbers.get()).toEqual([4, 5])
|
|
315
|
+
expect(numbers.length).toBe(2)
|
|
198
316
|
})
|
|
199
317
|
|
|
200
|
-
test('update() modifies store using function', () => {
|
|
201
|
-
|
|
318
|
+
test('update() modifies store using function for both store types', () => {
|
|
319
|
+
// Record store
|
|
320
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
321
|
+
user.update(u => ({ ...u, age: u.age + 1 }))
|
|
322
|
+
expect(user.name.get()).toBe('John')
|
|
323
|
+
expect(user.age.get()).toBe(26)
|
|
202
324
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
age: 26,
|
|
208
|
-
})
|
|
325
|
+
// Array store
|
|
326
|
+
const numbers = createStore([1, 2, 3])
|
|
327
|
+
numbers.update(arr => arr.map(n => n * 2))
|
|
328
|
+
expect(numbers.get()).toEqual([2, 4, 6])
|
|
209
329
|
})
|
|
210
330
|
})
|
|
211
331
|
|
|
212
|
-
describe('
|
|
213
|
-
test('supports for...of iteration', () => {
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expect(entries).
|
|
222
|
-
|
|
332
|
+
describe('iteration protocol', () => {
|
|
333
|
+
test('supports for...of iteration with different behaviors', () => {
|
|
334
|
+
// Record store - yields [key, signal] pairs
|
|
335
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
336
|
+
const entries = [...user]
|
|
337
|
+
expect(entries).toHaveLength(2)
|
|
338
|
+
expect(entries[0][0]).toBe('name')
|
|
339
|
+
expect(entries[0][1].get()).toBe('John')
|
|
340
|
+
expect(entries[1][0]).toBe('age')
|
|
341
|
+
expect(entries[1][1].get()).toBe(25)
|
|
342
|
+
|
|
343
|
+
// Array store - yields signals only
|
|
344
|
+
const numbers = createStore([10, 20, 30])
|
|
345
|
+
const signals = [...numbers]
|
|
346
|
+
expect(signals).toHaveLength(3)
|
|
347
|
+
expect(signals[0].get()).toBe(10)
|
|
348
|
+
expect(signals[1].get()).toBe(20)
|
|
349
|
+
expect(signals[2].get()).toBe(30)
|
|
223
350
|
})
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
describe('change tracking', () => {
|
|
227
|
-
test('tracks size changes', () => {
|
|
228
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
229
|
-
name: 'Hannah',
|
|
230
|
-
})
|
|
231
351
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
expect(
|
|
352
|
+
test('Symbol.isConcatSpreadable behavior differs between store types', () => {
|
|
353
|
+
// Array store - spreadable
|
|
354
|
+
const numbers = createStore([1, 2, 3])
|
|
355
|
+
expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
|
|
236
356
|
|
|
237
|
-
|
|
238
|
-
|
|
357
|
+
// Record store - not spreadable
|
|
358
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
359
|
+
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
239
360
|
})
|
|
240
361
|
|
|
241
|
-
test('
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
// Wait for the async initial event
|
|
250
|
-
await new Promise(resolve => setTimeout(resolve, 10))
|
|
362
|
+
test('array stores maintain numeric key ordering', () => {
|
|
363
|
+
const items = createStore(['first', 'second', 'third'])
|
|
364
|
+
const keys = Object.keys(items).filter(
|
|
365
|
+
k => !Number.isNaN(Number(k)),
|
|
366
|
+
)
|
|
367
|
+
expect(keys).toEqual(['0', '1', '2'])
|
|
251
368
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
369
|
+
const signals = [...items]
|
|
370
|
+
expect(signals.map(s => s.get())).toEqual([
|
|
371
|
+
'first',
|
|
372
|
+
'second',
|
|
373
|
+
'third',
|
|
374
|
+
])
|
|
255
375
|
})
|
|
376
|
+
})
|
|
256
377
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
let addNotification: Record<string, string> | null = null
|
|
378
|
+
describe('change tracking and notifications', () => {
|
|
379
|
+
test('emits add notifications for both store types', () => {
|
|
380
|
+
// Record store - initial creation
|
|
381
|
+
let addNotification: Record<string, unknown>
|
|
382
|
+
const user = createStore({ name: 'John' })
|
|
263
383
|
user.on('add', change => {
|
|
264
384
|
addNotification = change
|
|
265
385
|
})
|
|
266
386
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
expect(addNotification!).toEqual({
|
|
272
|
-
email: 'hannah@example.com',
|
|
273
|
-
})
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
test('emits a change notification for property changes', () => {
|
|
277
|
-
const user = createStore({ name: 'Hannah' })
|
|
387
|
+
// Wait for initial add event
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
expect(addNotification.name).toBe('John')
|
|
390
|
+
}, 0)
|
|
278
391
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
392
|
+
// Record store - new property
|
|
393
|
+
const userWithEmail = createStore<{ name: string; email?: string }>(
|
|
394
|
+
{ name: 'John' },
|
|
395
|
+
)
|
|
396
|
+
let newAddNotification: Record<string, unknown> = {}
|
|
397
|
+
userWithEmail.on('add', change => {
|
|
398
|
+
newAddNotification = change
|
|
282
399
|
})
|
|
400
|
+
userWithEmail.add('email', 'john@example.com')
|
|
401
|
+
expect(newAddNotification.email).toBe('john@example.com')
|
|
283
402
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
name: 'Alice',
|
|
403
|
+
// Array store
|
|
404
|
+
const numbers = createStore([1, 2])
|
|
405
|
+
let arrayAddNotification = {}
|
|
406
|
+
numbers.on('add', change => {
|
|
407
|
+
arrayAddNotification = change
|
|
290
408
|
})
|
|
409
|
+
numbers.add(3)
|
|
410
|
+
expect(arrayAddNotification[2]).toBe(3)
|
|
291
411
|
})
|
|
292
412
|
|
|
293
|
-
test('emits
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
let changeNotification: Record<string,
|
|
413
|
+
test('emits change notifications when properties are modified', () => {
|
|
414
|
+
// Record store
|
|
415
|
+
const user = createStore({ name: 'John' })
|
|
416
|
+
let changeNotification: Record<string, unknown> = {}
|
|
297
417
|
user.on('change', change => {
|
|
298
418
|
changeNotification = change
|
|
299
419
|
})
|
|
420
|
+
user.name.set('Jane')
|
|
421
|
+
expect(changeNotification.name).toBe('Jane')
|
|
300
422
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
name: 'Bob',
|
|
423
|
+
// Array store
|
|
424
|
+
const items = createStore(['a', 'b'])
|
|
425
|
+
let arrayChangeNotification = {}
|
|
426
|
+
items.on('change', change => {
|
|
427
|
+
arrayChangeNotification = change
|
|
307
428
|
})
|
|
429
|
+
items[0].set('alpha')
|
|
430
|
+
expect(arrayChangeNotification[0]).toBe('alpha')
|
|
308
431
|
})
|
|
309
432
|
|
|
310
|
-
test('emits
|
|
433
|
+
test('emits change notifications for nested property changes', () => {
|
|
434
|
+
// Record store
|
|
311
435
|
const user = createStore({
|
|
312
|
-
name: '
|
|
436
|
+
name: 'John',
|
|
313
437
|
preferences: {
|
|
314
|
-
theme: '
|
|
438
|
+
theme: 'light',
|
|
315
439
|
notifications: true,
|
|
316
440
|
},
|
|
317
441
|
})
|
|
318
|
-
|
|
319
442
|
let changeNotification: Record<string, unknown> = {}
|
|
320
443
|
user.on('change', change => {
|
|
321
444
|
changeNotification = change
|
|
322
445
|
})
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
expect(changeNotification).toBeTruthy()
|
|
328
|
-
// Should notify about the direct child "preferences" that contains the changed nested property
|
|
329
|
-
expect(changeNotification).toEqual({
|
|
330
|
-
preferences: {
|
|
331
|
-
theme: 'light',
|
|
332
|
-
notifications: true,
|
|
333
|
-
},
|
|
334
|
-
})
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
test('emits a change notification for deeply nested property changes', () => {
|
|
338
|
-
const config = createStore({
|
|
339
|
-
ui: {
|
|
340
|
-
theme: {
|
|
341
|
-
colors: {
|
|
342
|
-
primary: 'blue',
|
|
343
|
-
secondary: 'green',
|
|
344
|
-
},
|
|
345
|
-
mode: 'dark',
|
|
346
|
-
},
|
|
347
|
-
},
|
|
446
|
+
user.preferences.theme.set('dark')
|
|
447
|
+
expect(changeNotification.preferences).toEqual({
|
|
448
|
+
theme: 'dark',
|
|
449
|
+
notifications: true,
|
|
348
450
|
})
|
|
349
451
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
452
|
+
// Array store with nested objects
|
|
453
|
+
const users = createStore([{ name: 'Alice', role: 'admin' }])
|
|
454
|
+
let arrayChangeNotification: Record<number, unknown> = []
|
|
455
|
+
users.on('change', change => {
|
|
456
|
+
arrayChangeNotification = change
|
|
353
457
|
})
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
expect(changeNotification).toBeTruthy()
|
|
359
|
-
// Should notify about the direct child "ui" that contains the changed nested structure
|
|
360
|
-
expect(changeNotification).toEqual({
|
|
361
|
-
ui: {
|
|
362
|
-
theme: {
|
|
363
|
-
colors: {
|
|
364
|
-
primary: 'red',
|
|
365
|
-
secondary: 'green',
|
|
366
|
-
},
|
|
367
|
-
mode: 'dark',
|
|
368
|
-
},
|
|
369
|
-
},
|
|
458
|
+
users[0].name.set('Alicia')
|
|
459
|
+
expect(arrayChangeNotification[0]).toEqual({
|
|
460
|
+
name: 'Alicia',
|
|
461
|
+
role: 'admin',
|
|
370
462
|
})
|
|
371
463
|
})
|
|
372
464
|
|
|
373
|
-
test('emits
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
notifications: boolean
|
|
379
|
-
}
|
|
380
|
-
}>({
|
|
381
|
-
name: 'Hannah',
|
|
382
|
-
preferences: {
|
|
383
|
-
theme: 'dark',
|
|
384
|
-
notifications: true,
|
|
385
|
-
},
|
|
465
|
+
test('emits remove notifications when properties are removed', () => {
|
|
466
|
+
// Record store
|
|
467
|
+
const user = createStore({
|
|
468
|
+
name: 'John',
|
|
469
|
+
email: 'john@example.com',
|
|
386
470
|
})
|
|
387
|
-
|
|
388
471
|
let removeNotification: Record<string, unknown> = {}
|
|
389
|
-
user.on('remove',
|
|
390
|
-
removeNotification =
|
|
472
|
+
user.on('remove', change => {
|
|
473
|
+
removeNotification = change
|
|
391
474
|
})
|
|
475
|
+
user.remove('email')
|
|
476
|
+
expect(removeNotification.email).toBe(UNSET)
|
|
392
477
|
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
expect(removeNotification).toEqual({
|
|
399
|
-
preferences: expect.anything(), // The actual structure doesn't matter, just that it was removed
|
|
478
|
+
// Array store
|
|
479
|
+
const items = createStore(['a', 'b', 'c'])
|
|
480
|
+
let arrayRemoveNotification: Record<number, unknown> = []
|
|
481
|
+
items.on('remove', change => {
|
|
482
|
+
arrayRemoveNotification = change
|
|
400
483
|
})
|
|
484
|
+
items.remove(1)
|
|
485
|
+
expect(arrayRemoveNotification[2]).toBe(UNSET) // Last item gets removed in compaction
|
|
401
486
|
})
|
|
402
487
|
|
|
403
488
|
test('set() correctly handles mixed changes, additions, and removals', () => {
|
|
@@ -413,407 +498,265 @@ describe('store', () => {
|
|
|
413
498
|
name: 'Hannah',
|
|
414
499
|
email: 'hannah@example.com',
|
|
415
500
|
preferences: {
|
|
416
|
-
theme: '
|
|
501
|
+
theme: 'light', // will change
|
|
417
502
|
},
|
|
418
503
|
})
|
|
419
504
|
|
|
420
|
-
let changeNotification: Record<string, unknown>
|
|
421
|
-
let addNotification: Record<string, unknown>
|
|
422
|
-
let removeNotification: Record<string, unknown>
|
|
423
|
-
|
|
505
|
+
let changeNotification: Record<string, unknown> | undefined
|
|
506
|
+
let addNotification: Record<string, unknown> | undefined
|
|
507
|
+
let removeNotification: Record<string, unknown> | undefined
|
|
424
508
|
user.on('change', change => {
|
|
425
509
|
changeNotification = change
|
|
426
510
|
})
|
|
427
|
-
user.on('add',
|
|
428
|
-
addNotification =
|
|
511
|
+
user.on('add', change => {
|
|
512
|
+
addNotification = change
|
|
429
513
|
})
|
|
430
|
-
user.on('remove',
|
|
431
|
-
removeNotification =
|
|
514
|
+
user.on('remove', change => {
|
|
515
|
+
removeNotification = change
|
|
432
516
|
})
|
|
433
517
|
|
|
434
|
-
// Perform a set() that changes name, removes email, adds age, and keeps preferences
|
|
435
518
|
user.set({
|
|
436
|
-
name: '
|
|
519
|
+
name: 'Jane', // changed
|
|
437
520
|
preferences: {
|
|
438
|
-
theme: 'dark', //
|
|
521
|
+
theme: 'dark', // changed
|
|
439
522
|
},
|
|
440
523
|
age: 30, // added
|
|
441
|
-
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
// Should emit change notification for changed properties
|
|
445
|
-
expect(changeNotification).toEqual({
|
|
446
|
-
name: 'Alice',
|
|
447
|
-
})
|
|
448
|
-
|
|
449
|
-
// Should emit add notification for new properties
|
|
450
|
-
expect(addNotification).toEqual({
|
|
451
|
-
age: 30,
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
// Should emit remove notification for removed properties
|
|
455
|
-
expect(removeNotification).toEqual({
|
|
456
|
-
email: expect.anything(),
|
|
457
|
-
})
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
test('set() with only removals emits only remove notifications', () => {
|
|
461
|
-
const user = createStore<{
|
|
462
|
-
name: string
|
|
463
|
-
email?: string
|
|
464
|
-
age?: number
|
|
465
|
-
}>({
|
|
466
|
-
name: 'Hannah',
|
|
467
|
-
email: 'hannah@example.com',
|
|
468
|
-
age: 25,
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
let changeNotification: Record<string, unknown> | null = null
|
|
472
|
-
let removeNotification: Record<string, unknown> = {}
|
|
473
|
-
|
|
474
|
-
user.on('change', change => {
|
|
475
|
-
changeNotification = change
|
|
476
|
-
})
|
|
477
|
-
user.on('remove', remove => {
|
|
478
|
-
removeNotification = remove
|
|
479
|
-
})
|
|
480
|
-
|
|
481
|
-
// Set to a subset that only removes properties (no changes)
|
|
482
|
-
user.set({ name: 'Hannah' }) // same name, removes email and age
|
|
483
|
-
|
|
484
|
-
// Should NOT emit change notification since name didn't change
|
|
485
|
-
expect(changeNotification).toBe(null)
|
|
486
|
-
|
|
487
|
-
// Should emit remove notification for removed properties
|
|
488
|
-
expect(removeNotification).toEqual({
|
|
489
|
-
email: expect.anything(),
|
|
490
|
-
age: expect.anything(),
|
|
491
|
-
})
|
|
492
|
-
})
|
|
493
|
-
|
|
494
|
-
test('emits a remove notification for removed properties', () => {
|
|
495
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
496
|
-
name: 'Hannah',
|
|
497
|
-
email: 'hannah@example.com',
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
let removeNotification: Record<string, string> | null = null
|
|
501
|
-
user.on('remove', change => {
|
|
502
|
-
removeNotification = change
|
|
503
|
-
})
|
|
504
|
-
|
|
505
|
-
user.remove('email')
|
|
506
|
-
|
|
507
|
-
expect(removeNotification).toBeTruthy()
|
|
508
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
509
|
-
expect(removeNotification!.email).toBe(UNSET)
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
test('emits an add notification when using add method', () => {
|
|
513
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
514
|
-
name: 'Hannah',
|
|
515
|
-
})
|
|
524
|
+
} as { name: string; preferences: { theme: string }; age: number })
|
|
516
525
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
user.add('email', 'hannah@example.com')
|
|
523
|
-
|
|
524
|
-
expect(addNotification).toBeTruthy()
|
|
525
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
526
|
-
expect(addNotification!).toEqual({
|
|
527
|
-
email: 'hannah@example.com',
|
|
528
|
-
})
|
|
526
|
+
expect(changeNotification?.preferences).toEqual({ theme: 'dark' })
|
|
527
|
+
expect(addNotification?.age).toBe(30)
|
|
528
|
+
expect(removeNotification?.email).toBe(UNSET)
|
|
529
529
|
})
|
|
530
530
|
|
|
531
|
-
test('can
|
|
532
|
-
const user = createStore({ name: '
|
|
533
|
-
|
|
531
|
+
test('notification listeners can be removed', () => {
|
|
532
|
+
const user = createStore({ name: 'John' })
|
|
534
533
|
let notificationCount = 0
|
|
535
534
|
const listener = () => {
|
|
536
535
|
notificationCount++
|
|
537
536
|
}
|
|
538
|
-
|
|
539
537
|
const off = user.on('change', listener)
|
|
540
|
-
user.name.set('
|
|
538
|
+
user.name.set('Jane')
|
|
541
539
|
expect(notificationCount).toBe(1)
|
|
542
|
-
|
|
543
540
|
off()
|
|
544
|
-
user.name.set('
|
|
541
|
+
user.name.set('Jack')
|
|
545
542
|
expect(notificationCount).toBe(1) // Should not increment
|
|
546
543
|
})
|
|
547
|
-
|
|
548
|
-
test('supports multiple notification listeners for the same type', () => {
|
|
549
|
-
const user = createStore({ name: 'Hannah' })
|
|
550
|
-
|
|
551
|
-
let listener1Called = false
|
|
552
|
-
let listener2Called = false
|
|
553
|
-
|
|
554
|
-
user.on('change', () => {
|
|
555
|
-
listener1Called = true
|
|
556
|
-
})
|
|
557
|
-
|
|
558
|
-
user.on('change', () => {
|
|
559
|
-
listener2Called = true
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
user.name.set('Alice')
|
|
563
|
-
|
|
564
|
-
expect(listener1Called).toBe(true)
|
|
565
|
-
expect(listener2Called).toBe(true)
|
|
566
|
-
})
|
|
567
544
|
})
|
|
568
545
|
|
|
569
546
|
describe('reactivity', () => {
|
|
570
|
-
test('store-level get() is reactive', () => {
|
|
547
|
+
test('store-level get() is reactive for both store types', () => {
|
|
548
|
+
// Record store
|
|
571
549
|
const user = createStore({
|
|
572
|
-
name: '
|
|
573
|
-
email: '
|
|
550
|
+
name: 'John',
|
|
551
|
+
email: 'john@example.com',
|
|
574
552
|
})
|
|
575
553
|
let lastValue = { name: '', email: '' }
|
|
576
|
-
|
|
577
554
|
createEffect(() => {
|
|
578
555
|
lastValue = user.get()
|
|
579
556
|
})
|
|
580
|
-
|
|
581
|
-
user.name.set('Alice')
|
|
582
|
-
|
|
583
557
|
expect(lastValue).toEqual({
|
|
584
|
-
name: '
|
|
585
|
-
email: '
|
|
558
|
+
name: 'John',
|
|
559
|
+
email: 'john@example.com',
|
|
560
|
+
})
|
|
561
|
+
user.name.set('Jane')
|
|
562
|
+
expect(lastValue.name).toBe('Jane')
|
|
563
|
+
expect(lastValue.email).toBe('john@example.com')
|
|
564
|
+
|
|
565
|
+
// Array store
|
|
566
|
+
const numbers = createStore([1, 2, 3])
|
|
567
|
+
let lastArray: number[] = []
|
|
568
|
+
createEffect(() => {
|
|
569
|
+
lastArray = numbers.get()
|
|
586
570
|
})
|
|
571
|
+
expect(lastArray).toEqual([1, 2, 3])
|
|
572
|
+
numbers[0].set(10)
|
|
573
|
+
expect(lastArray).toEqual([10, 2, 3])
|
|
587
574
|
})
|
|
588
575
|
|
|
589
|
-
test('individual signal reactivity works', () => {
|
|
576
|
+
test('individual signal reactivity works for both store types', () => {
|
|
577
|
+
// Record store
|
|
590
578
|
const user = createStore({
|
|
591
|
-
name: '
|
|
592
|
-
email: '
|
|
579
|
+
name: 'John',
|
|
580
|
+
email: 'john@example.com',
|
|
593
581
|
})
|
|
594
582
|
let lastName = ''
|
|
595
583
|
let nameEffectRuns = 0
|
|
596
|
-
|
|
597
|
-
// Get signal for name property directly
|
|
598
|
-
const nameSignal = user.name
|
|
599
|
-
|
|
600
584
|
createEffect(() => {
|
|
601
|
-
lastName = nameSignal.get()
|
|
602
585
|
nameEffectRuns++
|
|
586
|
+
lastName = user.name.get()
|
|
587
|
+
})
|
|
588
|
+
expect(lastName).toBe('John')
|
|
589
|
+
expect(nameEffectRuns).toBe(1)
|
|
590
|
+
user.name.set('Jane')
|
|
591
|
+
expect(lastName).toBe('Jane')
|
|
592
|
+
expect(nameEffectRuns).toBe(2)
|
|
593
|
+
// Changing email should not trigger name effect
|
|
594
|
+
user.email.set('jane@example.com')
|
|
595
|
+
expect(nameEffectRuns).toBe(2)
|
|
596
|
+
|
|
597
|
+
// Array store
|
|
598
|
+
const items = createStore(['a', 'b'])
|
|
599
|
+
let lastItem = ''
|
|
600
|
+
let itemEffectRuns = 0
|
|
601
|
+
createEffect(() => {
|
|
602
|
+
itemEffectRuns++
|
|
603
|
+
lastItem = items[0].get()
|
|
603
604
|
})
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
expect(
|
|
608
|
-
expect(
|
|
605
|
+
expect(lastItem).toBe('a')
|
|
606
|
+
expect(itemEffectRuns).toBe(1)
|
|
607
|
+
items[0].set('alpha')
|
|
608
|
+
expect(lastItem).toBe('alpha')
|
|
609
|
+
expect(itemEffectRuns).toBe(2)
|
|
610
|
+
// Changing other item should not trigger effect
|
|
611
|
+
items[1].set('beta')
|
|
612
|
+
expect(itemEffectRuns).toBe(2)
|
|
609
613
|
})
|
|
610
614
|
|
|
611
615
|
test('nested store changes propagate to parent', () => {
|
|
612
616
|
const user = createStore({
|
|
613
617
|
preferences: {
|
|
614
|
-
theme: '
|
|
618
|
+
theme: 'light',
|
|
615
619
|
},
|
|
616
620
|
})
|
|
617
621
|
let effectRuns = 0
|
|
618
|
-
|
|
619
622
|
createEffect(() => {
|
|
620
|
-
user.get() // Watch entire store
|
|
621
623
|
effectRuns++
|
|
624
|
+
user.get() // Subscribe to entire store
|
|
622
625
|
})
|
|
623
|
-
|
|
624
|
-
user.preferences.theme.set('
|
|
625
|
-
expect(effectRuns).toBe(2)
|
|
626
|
+
expect(effectRuns).toBe(1)
|
|
627
|
+
user.preferences.theme.set('dark')
|
|
628
|
+
expect(effectRuns).toBe(2)
|
|
626
629
|
})
|
|
627
630
|
|
|
628
|
-
test('updates are reactive', () => {
|
|
631
|
+
test('updates are reactive for both store types', () => {
|
|
632
|
+
// Record store
|
|
629
633
|
const user = createStore<{ name: string; email?: string }>({
|
|
630
|
-
name: '
|
|
634
|
+
name: 'John',
|
|
631
635
|
})
|
|
632
|
-
let lastValue = {}
|
|
636
|
+
let lastValue: Record<string, unknown> = {}
|
|
633
637
|
let effectRuns = 0
|
|
634
|
-
|
|
635
638
|
createEffect(() => {
|
|
636
|
-
lastValue = user.get()
|
|
637
639
|
effectRuns++
|
|
640
|
+
lastValue = user.get()
|
|
638
641
|
})
|
|
642
|
+
expect(effectRuns).toBe(1)
|
|
643
|
+
user.update(u => ({ ...u, email: 'john@example.com' }))
|
|
644
|
+
expect(effectRuns).toBe(2)
|
|
645
|
+
expect(lastValue.name).toBe('John')
|
|
646
|
+
expect(lastValue.email).toBe('john@example.com')
|
|
639
647
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
648
|
+
// Array store
|
|
649
|
+
const numbers = createStore([1, 2])
|
|
650
|
+
let lastArray: number[] = []
|
|
651
|
+
let arrayEffectRuns = 0
|
|
652
|
+
createEffect(() => {
|
|
653
|
+
arrayEffectRuns++
|
|
654
|
+
lastArray = numbers.get()
|
|
644
655
|
})
|
|
645
|
-
expect(
|
|
656
|
+
expect(arrayEffectRuns).toBe(1)
|
|
657
|
+
numbers.update(arr => [...arr, 3])
|
|
658
|
+
expect(arrayEffectRuns).toBe(2)
|
|
659
|
+
expect(lastArray).toEqual([1, 2, 3])
|
|
646
660
|
})
|
|
647
661
|
|
|
648
|
-
test('remove method is reactive', () => {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
662
|
+
test('remove method is reactive for both store types', () => {
|
|
663
|
+
// Record store
|
|
664
|
+
const user = createStore<{
|
|
665
|
+
name: string
|
|
666
|
+
email?: string
|
|
667
|
+
}>({
|
|
668
|
+
name: 'John',
|
|
669
|
+
email: 'john@example.com',
|
|
652
670
|
})
|
|
653
|
-
let lastValue = {}
|
|
671
|
+
let lastValue: Record<string, unknown> = {}
|
|
654
672
|
let effectRuns = 0
|
|
655
|
-
|
|
656
673
|
createEffect(() => {
|
|
657
|
-
lastValue = user.get()
|
|
658
674
|
effectRuns++
|
|
675
|
+
lastValue = user.get()
|
|
659
676
|
})
|
|
660
|
-
|
|
661
677
|
expect(effectRuns).toBe(1)
|
|
662
|
-
|
|
663
678
|
user.remove('email')
|
|
664
|
-
expect(lastValue).toEqual({
|
|
665
|
-
name: 'Hannah',
|
|
666
|
-
})
|
|
667
679
|
expect(effectRuns).toBe(2)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
test('add method does not overwrite existing properties', () => {
|
|
671
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
672
|
-
name: 'Hannah',
|
|
673
|
-
email: 'original@example.com',
|
|
674
|
-
})
|
|
675
|
-
|
|
676
|
-
const originalSize = user.size.get()
|
|
677
|
-
|
|
678
|
-
expect(() => {
|
|
679
|
-
user.add('email', 'new@example.com')
|
|
680
|
-
}).toThrow(
|
|
681
|
-
'Could not add store key "email" with value "new@example.com" because it already exists',
|
|
682
|
-
)
|
|
680
|
+
expect(lastValue.name).toBe('John')
|
|
681
|
+
expect(lastValue.email).toBeUndefined()
|
|
683
682
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
683
|
+
// Array store
|
|
684
|
+
const items = createStore(['a', 'b', 'c'])
|
|
685
|
+
let lastArray: string[] = []
|
|
686
|
+
let arrayEffectRuns = 0
|
|
687
|
+
createEffect(() => {
|
|
688
|
+
arrayEffectRuns++
|
|
689
|
+
lastArray = items.get()
|
|
691
690
|
})
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
expect(
|
|
691
|
+
expect(arrayEffectRuns).toBe(1)
|
|
692
|
+
items.remove(1)
|
|
693
|
+
// Array removal causes multiple reactivity updates due to compaction
|
|
694
|
+
expect(arrayEffectRuns).toBeGreaterThanOrEqual(2)
|
|
695
|
+
expect(lastArray).toEqual(['a', 'c'])
|
|
697
696
|
})
|
|
698
697
|
})
|
|
699
698
|
|
|
700
699
|
describe('computed integration', () => {
|
|
701
|
-
test('works with computed signals', () => {
|
|
702
|
-
|
|
703
|
-
|
|
700
|
+
test('works with computed signals for both store types', () => {
|
|
701
|
+
// Record store
|
|
702
|
+
const user = createStore({ firstName: 'John', lastName: 'Doe' })
|
|
704
703
|
const fullName = createComputed(() => {
|
|
705
704
|
return `${user.firstName.get()} ${user.lastName.get()}`
|
|
706
705
|
})
|
|
706
|
+
expect(fullName.get()).toBe('John Doe')
|
|
707
|
+
user.firstName.set('Jane')
|
|
708
|
+
expect(fullName.get()).toBe('Jane Doe')
|
|
707
709
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
710
|
+
// Array store
|
|
711
|
+
const numbers = createStore([1, 2, 3])
|
|
712
|
+
const sum = createComputed(() => {
|
|
713
|
+
return numbers.get().reduce((acc, n) => acc + n, 0)
|
|
714
|
+
})
|
|
715
|
+
expect(sum.get()).toBe(6)
|
|
716
|
+
numbers[0].set(10)
|
|
717
|
+
expect(sum.get()).toBe(15)
|
|
712
718
|
})
|
|
713
719
|
|
|
714
720
|
test('computed reacts to nested store changes', () => {
|
|
715
721
|
const config = createStore({
|
|
716
722
|
ui: {
|
|
717
|
-
theme: '
|
|
723
|
+
theme: 'light',
|
|
718
724
|
},
|
|
719
725
|
})
|
|
720
|
-
|
|
721
726
|
const themeDisplay = createComputed(() => {
|
|
722
727
|
return `Theme: ${config.ui.theme.get()}`
|
|
723
728
|
})
|
|
724
|
-
|
|
725
|
-
expect(themeDisplay.get()).toBe('Theme: dark')
|
|
726
|
-
|
|
727
|
-
config.ui.theme.set('light')
|
|
728
729
|
expect(themeDisplay.get()).toBe('Theme: light')
|
|
730
|
+
config.ui.theme.set('dark')
|
|
731
|
+
expect(themeDisplay.get()).toBe('Theme: dark')
|
|
729
732
|
})
|
|
730
|
-
})
|
|
731
|
-
|
|
732
|
-
describe('array-derived stores with computed sum', () => {
|
|
733
|
-
test('computes sum correctly and updates when items are added, removed, or changed', () => {
|
|
734
|
-
// Create a store with an array of numbers
|
|
735
|
-
const numbers = createStore([1, 2, 3, 4, 5])
|
|
736
|
-
|
|
737
|
-
// Create a computed that calculates the sum by accessing the array via .get()
|
|
738
|
-
// This ensures reactivity to both value changes and structural changes
|
|
739
|
-
const sum = createComputed(() => {
|
|
740
|
-
const array = numbers.get()
|
|
741
|
-
if (!Array.isArray(array)) return 0
|
|
742
|
-
return array.reduce((acc, num) => acc + num, 0)
|
|
743
|
-
})
|
|
744
|
-
|
|
745
|
-
// Initial sum should be 15 (1+2+3+4+5)
|
|
746
|
-
expect(sum.get()).toBe(15)
|
|
747
|
-
expect(numbers.size.get()).toBe(5)
|
|
748
|
-
|
|
749
|
-
// Test adding items
|
|
750
|
-
numbers.add(6) // Add 6 at index 5
|
|
751
|
-
expect(sum.get()).toBe(21) // 15 + 6 = 21
|
|
752
|
-
expect(numbers.size.get()).toBe(6)
|
|
753
|
-
|
|
754
|
-
numbers.add(7) // Add 7 at index 6
|
|
755
|
-
expect(sum.get()).toBe(28) // 21 + 7 = 28
|
|
756
|
-
expect(numbers.size.get()).toBe(7)
|
|
757
|
-
|
|
758
|
-
// Test changing a single value
|
|
759
|
-
numbers[2].set(10) // Change index 2 from 3 to 10
|
|
760
|
-
expect(sum.get()).toBe(35) // 28 - 3 + 10 = 35
|
|
761
|
-
|
|
762
|
-
// Test another value change
|
|
763
|
-
numbers[0].set(5) // Change index 0 from 1 to 5
|
|
764
|
-
expect(sum.get()).toBe(39) // 35 - 1 + 5 = 39
|
|
765
|
-
|
|
766
|
-
// Test removing items
|
|
767
|
-
numbers.remove(6) // Remove index 6 (value 7)
|
|
768
|
-
expect(sum.get()).toBe(32) // 39 - 7 = 32
|
|
769
|
-
expect(numbers.size.get()).toBe(6)
|
|
770
|
-
|
|
771
|
-
numbers.remove(0) // Remove index 0 (value 5)
|
|
772
|
-
expect(sum.get()).toBe(27) // 32 - 5 = 27
|
|
773
|
-
expect(numbers.size.get()).toBe(5)
|
|
774
|
-
|
|
775
|
-
// Verify the final array structure using .get()
|
|
776
|
-
const finalArray = numbers.get()
|
|
777
|
-
expect(Array.isArray(finalArray)).toBe(true)
|
|
778
|
-
expect(finalArray).toEqual([2, 10, 4, 5, 6])
|
|
779
|
-
})
|
|
780
|
-
|
|
781
|
-
test('handles empty array and single element operations', () => {
|
|
782
|
-
// Start with empty array
|
|
783
|
-
const numbers = createStore<number[]>([])
|
|
784
733
|
|
|
734
|
+
test('computed with array stores handles additions and removals', () => {
|
|
735
|
+
const numbers = createStore([1, 2, 3])
|
|
785
736
|
const sum = createComputed(() => {
|
|
786
737
|
const array = numbers.get()
|
|
787
|
-
|
|
788
|
-
return array.reduce((acc, num) => acc + num, 0)
|
|
738
|
+
return array.reduce((acc, n) => acc + n, 0)
|
|
789
739
|
})
|
|
790
740
|
|
|
791
|
-
|
|
792
|
-
expect(sum.get()).toBe(0)
|
|
793
|
-
expect(numbers.size.get()).toBe(0)
|
|
794
|
-
|
|
795
|
-
// Add first element
|
|
796
|
-
numbers.add(42)
|
|
797
|
-
expect(sum.get()).toBe(42)
|
|
798
|
-
expect(numbers.size.get()).toBe(1)
|
|
741
|
+
expect(sum.get()).toBe(6)
|
|
799
742
|
|
|
800
|
-
//
|
|
801
|
-
numbers
|
|
802
|
-
expect(sum.get()).toBe(
|
|
743
|
+
// Add a number
|
|
744
|
+
numbers.add(4)
|
|
745
|
+
expect(sum.get()).toBe(10)
|
|
803
746
|
|
|
804
|
-
// Remove
|
|
747
|
+
// Remove a number
|
|
805
748
|
numbers.remove(0)
|
|
806
|
-
|
|
807
|
-
expect(
|
|
749
|
+
const finalArray = numbers.get()
|
|
750
|
+
expect(finalArray).toEqual([2, 3, 4])
|
|
751
|
+
expect(sum.get()).toBe(9)
|
|
808
752
|
})
|
|
809
753
|
|
|
810
|
-
test('computed sum using store iteration with
|
|
811
|
-
const numbers = createStore([
|
|
754
|
+
test('computed sum using store iteration with length tracking', () => {
|
|
755
|
+
const numbers = createStore([1, 2, 3])
|
|
812
756
|
|
|
813
|
-
// Use iteration but also track size to ensure reactivity to additions/removals
|
|
814
757
|
const sum = createComputed(() => {
|
|
815
|
-
// Access
|
|
816
|
-
numbers.
|
|
758
|
+
// Access length to ensure reactivity
|
|
759
|
+
const _length = numbers.length
|
|
817
760
|
let total = 0
|
|
818
761
|
for (const signal of numbers) {
|
|
819
762
|
total += signal.get()
|
|
@@ -821,1086 +764,522 @@ describe('store', () => {
|
|
|
821
764
|
return total
|
|
822
765
|
})
|
|
823
766
|
|
|
824
|
-
expect(sum.get()).toBe(
|
|
825
|
-
|
|
826
|
-
// Add more numbers
|
|
827
|
-
numbers.add(40)
|
|
828
|
-
expect(sum.get()).toBe(100)
|
|
767
|
+
expect(sum.get()).toBe(6)
|
|
829
768
|
|
|
830
|
-
//
|
|
831
|
-
numbers
|
|
832
|
-
expect(sum.get()).toBe(
|
|
769
|
+
// Add item
|
|
770
|
+
numbers.add(4)
|
|
771
|
+
expect(sum.get()).toBe(10)
|
|
833
772
|
|
|
834
|
-
// Remove
|
|
835
|
-
numbers.remove(
|
|
836
|
-
expect(sum.get()).toBe(
|
|
773
|
+
// Remove item
|
|
774
|
+
numbers.remove(1)
|
|
775
|
+
expect(sum.get()).toBe(8) // 1 + 3 + 4 (middle item removed)
|
|
837
776
|
})
|
|
777
|
+
})
|
|
838
778
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
779
|
+
describe('sort() method', () => {
|
|
780
|
+
test('sorts array stores with different compare functions', () => {
|
|
781
|
+
// Numeric sort
|
|
782
|
+
const numbers = createStore([3, 1, 4, 1, 5])
|
|
783
|
+
const _oldSignals = [
|
|
784
|
+
numbers[0],
|
|
785
|
+
numbers[1],
|
|
786
|
+
numbers[2],
|
|
787
|
+
numbers[3],
|
|
788
|
+
numbers[4],
|
|
789
|
+
]
|
|
842
790
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
// Access size to subscribe to structural changes
|
|
846
|
-
numbers.size.get()
|
|
847
|
-
let total = 0
|
|
848
|
-
for (const signal of numbers) {
|
|
849
|
-
total += signal.get()
|
|
850
|
-
}
|
|
851
|
-
return total
|
|
852
|
-
})
|
|
791
|
+
numbers.sort((a, b) => a - b)
|
|
792
|
+
expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
|
|
853
793
|
|
|
854
|
-
//
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
return array.reduce((acc, num) => acc + num, 0)
|
|
859
|
-
})
|
|
794
|
+
// Verify signals moved correctly
|
|
795
|
+
expect(numbers[0]).toBe(_oldSignals[1]) // First '1'
|
|
796
|
+
expect(numbers[1]).toBe(_oldSignals[3]) // Second '1'
|
|
797
|
+
expect(numbers[2]).toBe(_oldSignals[0]) // '3'
|
|
860
798
|
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
expect(
|
|
865
|
-
|
|
866
|
-
// Remove items - arrays should compact (not create sparse holes)
|
|
867
|
-
numbers.remove(1) // Remove 20, array becomes [10, 30, 40, 50]
|
|
868
|
-
expect(numbers.size.get()).toBe(4)
|
|
869
|
-
expect(numbers.get()).toEqual([10, 30, 40, 50])
|
|
870
|
-
expect(sumWithIteration.get()).toBe(130) // 10 + 30 + 40 + 50
|
|
871
|
-
expect(sumWithGet.get()).toBe(130)
|
|
872
|
-
|
|
873
|
-
numbers.remove(2) // Remove 40, array becomes [10, 30, 50]
|
|
874
|
-
expect(numbers.size.get()).toBe(3)
|
|
875
|
-
expect(numbers.get()).toEqual([10, 30, 50])
|
|
876
|
-
expect(sumWithIteration.get()).toBe(90) // 10 + 30 + 50
|
|
877
|
-
expect(sumWithGet.get()).toBe(90)
|
|
878
|
-
|
|
879
|
-
// Set a new array of same size (3 elements)
|
|
880
|
-
numbers.set([100, 200, 300])
|
|
881
|
-
expect(numbers.size.get()).toBe(3)
|
|
882
|
-
expect(numbers.get()).toEqual([100, 200, 300])
|
|
883
|
-
|
|
884
|
-
// Both approaches work correctly with compacted arrays
|
|
885
|
-
expect(sumWithGet.get()).toBe(600) // 100 + 200 + 300
|
|
886
|
-
expect(sumWithIteration.get()).toBe(600) // Both work correctly
|
|
799
|
+
// String sort
|
|
800
|
+
const names = createStore(['Charlie', 'Alice', 'Bob'])
|
|
801
|
+
names.sort()
|
|
802
|
+
expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
|
|
887
803
|
})
|
|
888
804
|
|
|
889
|
-
test('
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
// Verify the sparse structure
|
|
897
|
-
expect(numbers.get()).toEqual([10, 30])
|
|
898
|
-
expect(numbers.size.get()).toBe(2)
|
|
805
|
+
test('sorts record stores by value', () => {
|
|
806
|
+
const users = createStore({
|
|
807
|
+
user1: { name: 'Charlie', age: 25 },
|
|
808
|
+
user2: { name: 'Alice', age: 30 },
|
|
809
|
+
user3: { name: 'Bob', age: 35 },
|
|
810
|
+
})
|
|
899
811
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
numbers.set([100, 200])
|
|
812
|
+
const _oldSignals = {
|
|
813
|
+
user1: users.user1,
|
|
814
|
+
user2: users.user2,
|
|
815
|
+
user3: users.user3,
|
|
816
|
+
}
|
|
906
817
|
|
|
907
|
-
|
|
908
|
-
const result = numbers.get()
|
|
818
|
+
users.sort((a, b) => a.name.localeCompare(b.name))
|
|
909
819
|
|
|
910
|
-
//
|
|
911
|
-
|
|
820
|
+
// After sorting by name: Alice, Bob, Charlie
|
|
821
|
+
// The keys should be reordered based on the sort
|
|
822
|
+
const sortedEntries = [...users]
|
|
823
|
+
expect(sortedEntries[0][1].name.get()).toBe('Alice')
|
|
824
|
+
expect(sortedEntries[1][1].name.get()).toBe('Bob')
|
|
825
|
+
expect(sortedEntries[2][1].name.get()).toBe('Charlie')
|
|
912
826
|
})
|
|
913
|
-
})
|
|
914
827
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
828
|
+
test('emits sort notification with new order', () => {
|
|
829
|
+
const numbers = createStore([3, 1, 2])
|
|
830
|
+
let sortNotification: string[] = []
|
|
831
|
+
numbers.on('sort', change => {
|
|
832
|
+
sortNotification = change
|
|
833
|
+
})
|
|
918
834
|
|
|
919
|
-
|
|
920
|
-
expect(
|
|
921
|
-
expect(data.items['0'].get()).toBe(1)
|
|
922
|
-
expect(data.items['1'].get()).toBe(2)
|
|
923
|
-
expect(data.items['2'].get()).toBe(3)
|
|
835
|
+
numbers.sort((a, b) => a - b)
|
|
836
|
+
expect(sortNotification).toEqual(['1', '2', '0']) // Original indices in new order
|
|
924
837
|
})
|
|
925
838
|
|
|
926
|
-
test('
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
839
|
+
test('sort is reactive - watchers are notified', () => {
|
|
840
|
+
const numbers = createStore([3, 1, 2])
|
|
841
|
+
let effectCount = 0
|
|
842
|
+
let lastValue: number[] = []
|
|
843
|
+
|
|
844
|
+
createEffect(() => {
|
|
845
|
+
effectCount++
|
|
846
|
+
lastValue = numbers.get()
|
|
934
847
|
})
|
|
935
848
|
|
|
936
|
-
|
|
937
|
-
expect(
|
|
938
|
-
expect(isStore(todoApp.users)).toBe(true)
|
|
939
|
-
expect(isStore(todoApp.numbers)).toBe(true)
|
|
849
|
+
expect(effectCount).toBe(1)
|
|
850
|
+
expect(lastValue).toEqual([3, 1, 2])
|
|
940
851
|
|
|
941
|
-
|
|
942
|
-
expect(
|
|
943
|
-
expect(
|
|
944
|
-
|
|
852
|
+
numbers.sort((a, b) => a - b)
|
|
853
|
+
expect(effectCount).toBe(2)
|
|
854
|
+
expect(lastValue).toEqual([1, 2, 3])
|
|
855
|
+
})
|
|
945
856
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
857
|
+
test('nested signals remain reactive after sorting', () => {
|
|
858
|
+
const items = createStore([
|
|
859
|
+
{ name: 'Charlie', score: 85 },
|
|
860
|
+
{ name: 'Alice', score: 95 },
|
|
861
|
+
{ name: 'Bob', score: 75 },
|
|
862
|
+
])
|
|
949
863
|
|
|
950
|
-
|
|
951
|
-
expect(isStore(todoApp.users[0])).toBe(true)
|
|
952
|
-
expect(isStore(todoApp.users[1])).toBe(true)
|
|
864
|
+
items.sort((a, b) => a.score - b.score)
|
|
953
865
|
|
|
954
|
-
//
|
|
955
|
-
expect(
|
|
956
|
-
expect(
|
|
957
|
-
expect(
|
|
958
|
-
expect(todoApp.users[1].active.get()).toBe(false)
|
|
866
|
+
// Verify order: Bob(75), Charlie(85), Alice(95)
|
|
867
|
+
expect(items[0].name.get()).toBe('Bob')
|
|
868
|
+
expect(items[1].name.get()).toBe('Charlie')
|
|
869
|
+
expect(items[2].name.get()).toBe('Alice')
|
|
959
870
|
|
|
960
|
-
//
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
expect(todoApp.users[0].name.get()).toBe('Alice Smith')
|
|
964
|
-
expect(todoApp.users[0].active.get()).toBe(false)
|
|
965
|
-
|
|
966
|
-
// Number array elements should be State<number>
|
|
967
|
-
expect(todoApp.numbers[0].get()).toBe(1)
|
|
968
|
-
expect(todoApp.numbers[4].get()).toBe(5)
|
|
969
|
-
|
|
970
|
-
// Should be able to modify number elements
|
|
971
|
-
todoApp.numbers[0].set(10)
|
|
972
|
-
todoApp.numbers[4].set(50)
|
|
973
|
-
expect(todoApp.numbers[0].get()).toBe(10)
|
|
974
|
-
expect(todoApp.numbers[4].get()).toBe(50)
|
|
975
|
-
|
|
976
|
-
// Store-level access should reflect all changes
|
|
977
|
-
const currentState = todoApp.get()
|
|
978
|
-
expect(currentState.todos[0]).toBe('Buy groceries')
|
|
979
|
-
expect(currentState.users[0].name).toBe('Alice Smith')
|
|
980
|
-
expect(currentState.users[0].active).toBe(false)
|
|
981
|
-
expect(currentState.numbers[0]).toBe(10)
|
|
982
|
-
expect(currentState.numbers[4]).toBe(50)
|
|
871
|
+
// Verify signals are still reactive
|
|
872
|
+
items[0].score.set(100)
|
|
873
|
+
expect(items[0].score.get()).toBe(100)
|
|
983
874
|
})
|
|
984
875
|
|
|
985
|
-
test('handles
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
expect(
|
|
989
|
-
data.value.set('some string')
|
|
990
|
-
expect(data.value.get()).toBe('some string')
|
|
876
|
+
test('default sort handles numbers as strings like Array.prototype.sort()', () => {
|
|
877
|
+
const numbers = createStore([10, 2, 1])
|
|
878
|
+
numbers.sort()
|
|
879
|
+
expect(numbers.get()).toEqual([1, 10, 2]) // String comparison: "1" < "10" < "2"
|
|
991
880
|
})
|
|
992
881
|
|
|
993
|
-
test('
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
})
|
|
999
|
-
|
|
1000
|
-
expect(data.str.get()).toBe('hello')
|
|
1001
|
-
expect(data.num.get()).toBe(42)
|
|
1002
|
-
expect(data.bool.get()).toBe(true)
|
|
882
|
+
test('multiple sorts work correctly', () => {
|
|
883
|
+
const numbers = createStore([3, 1, 2])
|
|
884
|
+
numbers.sort((a, b) => a - b) // [1, 2, 3]
|
|
885
|
+
numbers.sort((a, b) => b - a) // [3, 2, 1]
|
|
886
|
+
expect(numbers.get()).toEqual([3, 2, 1])
|
|
1003
887
|
})
|
|
1004
888
|
})
|
|
1005
889
|
|
|
1006
|
-
describe('proxy behavior', () => {
|
|
1007
|
-
test('Object.keys returns property keys', () => {
|
|
890
|
+
describe('proxy behavior and enumeration', () => {
|
|
891
|
+
test('Object.keys returns property keys for both store types', () => {
|
|
892
|
+
// Record store
|
|
1008
893
|
const user = createStore({
|
|
1009
|
-
name: '
|
|
1010
|
-
email: '
|
|
894
|
+
name: 'John',
|
|
895
|
+
email: 'john@example.com',
|
|
1011
896
|
})
|
|
897
|
+
const userKeys = Object.keys(user)
|
|
898
|
+
expect(userKeys.sort()).toEqual(['email', 'name'])
|
|
1012
899
|
|
|
1013
|
-
|
|
900
|
+
// Array store
|
|
901
|
+
const numbers = createStore([1, 2, 3])
|
|
902
|
+
const numberKeys = Object.keys(numbers).filter(
|
|
903
|
+
k => !Number.isNaN(Number(k)),
|
|
904
|
+
)
|
|
905
|
+
expect(numberKeys).toEqual(['0', '1', '2'])
|
|
1014
906
|
})
|
|
1015
907
|
|
|
1016
|
-
test('property enumeration works', () => {
|
|
908
|
+
test('property enumeration works for both store types', () => {
|
|
909
|
+
// Record store
|
|
1017
910
|
const user = createStore({
|
|
1018
|
-
name: '
|
|
1019
|
-
email: '
|
|
911
|
+
name: 'John',
|
|
912
|
+
email: 'john@example.com',
|
|
1020
913
|
})
|
|
1021
|
-
const
|
|
1022
|
-
|
|
914
|
+
const userKeys: string[] = []
|
|
1023
915
|
for (const key in user) {
|
|
1024
|
-
|
|
916
|
+
userKeys.push(key)
|
|
1025
917
|
}
|
|
918
|
+
expect(userKeys.sort()).toEqual(['email', 'name'])
|
|
1026
919
|
|
|
1027
|
-
|
|
920
|
+
// Array store
|
|
921
|
+
const numbers = createStore([10, 20])
|
|
922
|
+
const numberKeys: string[] = []
|
|
923
|
+
for (const key in numbers) {
|
|
924
|
+
if (!Number.isNaN(Number(key))) numberKeys.push(key)
|
|
925
|
+
}
|
|
926
|
+
expect(numberKeys).toEqual(['0', '1'])
|
|
1028
927
|
})
|
|
1029
928
|
|
|
1030
|
-
test('in operator works', () => {
|
|
1031
|
-
|
|
1032
|
-
|
|
929
|
+
test('in operator works for both store types', () => {
|
|
930
|
+
// Record store
|
|
931
|
+
const user = createStore({ name: 'John' })
|
|
1033
932
|
expect('name' in user).toBe(true)
|
|
1034
933
|
expect('email' in user).toBe(false)
|
|
1035
|
-
|
|
934
|
+
expect('length' in user).toBe(true)
|
|
1036
935
|
|
|
1037
|
-
|
|
1038
|
-
const
|
|
936
|
+
// Array store
|
|
937
|
+
const numbers = createStore([1, 2])
|
|
938
|
+
expect(0 in numbers).toBe(true)
|
|
939
|
+
expect(2 in numbers).toBe(false)
|
|
940
|
+
expect('length' in numbers).toBe(true)
|
|
941
|
+
})
|
|
1039
942
|
|
|
1040
|
-
|
|
1041
|
-
|
|
943
|
+
test('Object.getOwnPropertyDescriptor works for both store types', () => {
|
|
944
|
+
// Record store
|
|
945
|
+
const user = createStore({ name: 'John' })
|
|
946
|
+
const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
|
|
947
|
+
expect(nameDescriptor).toEqual({
|
|
1042
948
|
enumerable: true,
|
|
1043
949
|
configurable: true,
|
|
1044
950
|
writable: true,
|
|
1045
951
|
value: user.name,
|
|
1046
952
|
})
|
|
1047
|
-
})
|
|
1048
|
-
})
|
|
1049
|
-
|
|
1050
|
-
describe('type conversion via toSignal', () => {
|
|
1051
|
-
test('arrays are converted to stores', () => {
|
|
1052
|
-
const fruits = createStore({ items: ['apple', 'banana', 'cherry'] })
|
|
1053
|
-
|
|
1054
|
-
expect(isStore(fruits.items)).toBe(true)
|
|
1055
|
-
expect(fruits.items['0'].get()).toBe('apple')
|
|
1056
|
-
expect(fruits.items['1'].get()).toBe('banana')
|
|
1057
|
-
expect(fruits.items['2'].get()).toBe('cherry')
|
|
1058
|
-
})
|
|
1059
953
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
954
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(
|
|
955
|
+
user,
|
|
956
|
+
'length',
|
|
957
|
+
)
|
|
958
|
+
expect(lengthDescriptor?.enumerable).toBe(false)
|
|
959
|
+
expect(lengthDescriptor?.configurable).toBe(false)
|
|
960
|
+
|
|
961
|
+
// Array store
|
|
962
|
+
const numbers = createStore([1, 2])
|
|
963
|
+
const indexDescriptor = Object.getOwnPropertyDescriptor(
|
|
964
|
+
numbers,
|
|
965
|
+
'0',
|
|
966
|
+
)
|
|
967
|
+
expect(indexDescriptor).toEqual({
|
|
968
|
+
enumerable: true,
|
|
969
|
+
configurable: true,
|
|
970
|
+
writable: true,
|
|
971
|
+
value: numbers[0],
|
|
1066
972
|
})
|
|
1067
973
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
974
|
+
const arrayLengthDescriptor = Object.getOwnPropertyDescriptor(
|
|
975
|
+
numbers,
|
|
976
|
+
'length',
|
|
977
|
+
)
|
|
978
|
+
expect(arrayLengthDescriptor?.enumerable).toBe(false)
|
|
979
|
+
expect(arrayLengthDescriptor?.configurable).toBe(false)
|
|
1071
980
|
})
|
|
1072
981
|
})
|
|
1073
982
|
|
|
1074
983
|
describe('spread operator behavior', () => {
|
|
1075
|
-
test('spreading
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
984
|
+
test('spreading stores works differently for each type', () => {
|
|
985
|
+
// Record store - spreads individual signals
|
|
986
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
987
|
+
const userSpread = { ...user }
|
|
988
|
+
expect('name' in userSpread).toBe(true)
|
|
989
|
+
expect('age' in userSpread).toBe(true)
|
|
990
|
+
expect(typeof userSpread.name?.get).toBe('function')
|
|
991
|
+
expect(userSpread.name?.get()).toBe('John')
|
|
992
|
+
|
|
993
|
+
// Array store - spreads signals (not [key, value] pairs)
|
|
994
|
+
const numbers = createStore([1, 2, 3])
|
|
995
|
+
const numberSpread = [...numbers]
|
|
996
|
+
expect(numberSpread).toHaveLength(3)
|
|
997
|
+
expect(typeof numberSpread[0].get).toBe('function')
|
|
998
|
+
expect(numberSpread[0].get()).toBe(1)
|
|
999
|
+
})
|
|
1080
1000
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1001
|
+
test('concat works correctly with array stores', () => {
|
|
1002
|
+
const numbers = createStore([2, 3])
|
|
1003
|
+
const prefix = [createState(1)]
|
|
1004
|
+
const suffix = [createState(4)]
|
|
1085
1005
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1006
|
+
const combined = prefix.concat(
|
|
1007
|
+
numbers as unknown as ConcatArray<State<number>>,
|
|
1008
|
+
suffix,
|
|
1009
|
+
)
|
|
1090
1010
|
|
|
1091
|
-
|
|
1092
|
-
expect(
|
|
1093
|
-
expect(
|
|
1094
|
-
expect(
|
|
1011
|
+
expect(combined).toHaveLength(4)
|
|
1012
|
+
expect(combined[0].get()).toBe(1)
|
|
1013
|
+
expect(combined[1].get()).toBe(2) // from store
|
|
1014
|
+
expect(combined[2].get()).toBe(3) // from store
|
|
1015
|
+
expect(combined[3].get()).toBe(4)
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1095
1018
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1019
|
+
describe('UNSET and edge cases', () => {
|
|
1020
|
+
test('handles UNSET values for both store types', () => {
|
|
1021
|
+
// Record store
|
|
1022
|
+
const recordData = createStore({ value: UNSET as string })
|
|
1023
|
+
expect(recordData.value.get()).toBe(UNSET)
|
|
1024
|
+
recordData.value.set('some string')
|
|
1025
|
+
expect(recordData.value.get()).toBe('some string')
|
|
1099
1026
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1027
|
+
// Array store
|
|
1028
|
+
const arrayData = createStore([UNSET as string])
|
|
1029
|
+
expect(arrayData[0].get()).toBe(UNSET)
|
|
1030
|
+
arrayData[0].set('some value')
|
|
1031
|
+
expect(arrayData[0].get()).toBe('some value')
|
|
1102
1032
|
})
|
|
1103
1033
|
|
|
1104
|
-
test('
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1034
|
+
test('handles primitive values in both store types', () => {
|
|
1035
|
+
// Record store
|
|
1036
|
+
const recordData = createStore({
|
|
1037
|
+
str: 'hello',
|
|
1038
|
+
num: 42,
|
|
1039
|
+
bool: true,
|
|
1108
1040
|
})
|
|
1041
|
+
expect(recordData.str.get()).toBe('hello')
|
|
1042
|
+
expect(recordData.num.get()).toBe(42)
|
|
1043
|
+
expect(recordData.bool.get()).toBe(true)
|
|
1109
1044
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
expect(
|
|
1114
|
-
expect(
|
|
1045
|
+
// Array store
|
|
1046
|
+
const arrayData = createStore(['hello', 42, true])
|
|
1047
|
+
expect(arrayData[0].get()).toBe('hello')
|
|
1048
|
+
expect(arrayData[1].get()).toBe(42)
|
|
1049
|
+
expect(arrayData[2].get()).toBe(true)
|
|
1050
|
+
})
|
|
1115
1051
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1052
|
+
test('handles empty stores correctly', () => {
|
|
1053
|
+
// Empty record store
|
|
1054
|
+
const emptyRecord = createStore({})
|
|
1055
|
+
expect(emptyRecord.length).toBe(0)
|
|
1056
|
+
expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
|
|
1057
|
+
expect([...emptyRecord]).toEqual([])
|
|
1119
1058
|
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
expect(
|
|
1059
|
+
// Empty array store
|
|
1060
|
+
const emptyArray = createStore([])
|
|
1061
|
+
expect(emptyArray.length).toBe(0)
|
|
1062
|
+
expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
|
|
1063
|
+
expect([...emptyArray]).toEqual([])
|
|
1123
1064
|
})
|
|
1124
1065
|
})
|
|
1125
1066
|
|
|
1126
|
-
describe('JSON integration', () => {
|
|
1127
|
-
test('seamless
|
|
1128
|
-
//
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
name: string
|
|
1153
|
-
email: string
|
|
1154
|
-
preferences: {
|
|
1155
|
-
theme: string
|
|
1156
|
-
notifications: boolean
|
|
1157
|
-
language: string
|
|
1158
|
-
fontSize?: number
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
settings: {
|
|
1162
|
-
autoSave: boolean
|
|
1163
|
-
timeout: number
|
|
1164
|
-
}
|
|
1165
|
-
tags: string[]
|
|
1166
|
-
lastLogin?: Date
|
|
1167
|
-
}>(apiData)
|
|
1168
|
-
|
|
1169
|
-
// Verify initial data is accessible and reactive
|
|
1170
|
-
expect(userStore.user.name.get()).toBe('John Doe')
|
|
1171
|
-
expect(userStore.user.preferences.theme.get()).toBe('dark')
|
|
1172
|
-
expect(userStore.settings.autoSave.get()).toBe(true)
|
|
1173
|
-
expect(userStore.get().tags).toEqual([
|
|
1174
|
-
'developer',
|
|
1175
|
-
'javascript',
|
|
1176
|
-
'typescript',
|
|
1177
|
-
])
|
|
1178
|
-
|
|
1179
|
-
// Simulate user interactions - update preferences
|
|
1180
|
-
userStore.user.preferences.theme.set('light')
|
|
1181
|
-
userStore.user.preferences.notifications.set(false)
|
|
1182
|
-
|
|
1183
|
-
// Add new preference
|
|
1184
|
-
userStore.user.preferences.add('fontSize', 14)
|
|
1185
|
-
|
|
1186
|
-
// Update settings
|
|
1187
|
-
userStore.settings.timeout.set(10000)
|
|
1188
|
-
|
|
1189
|
-
// Add new top-level property
|
|
1190
|
-
userStore.add('lastLogin', new Date('2024-01-15T10:30:00Z'))
|
|
1191
|
-
|
|
1192
|
-
// Verify changes are reflected
|
|
1193
|
-
expect(userStore.user.preferences.theme.get()).toBe('light')
|
|
1194
|
-
expect(userStore.user.preferences.notifications.get()).toBe(false)
|
|
1195
|
-
expect(userStore.settings.timeout.get()).toBe(10000)
|
|
1196
|
-
|
|
1197
|
-
// Get current state and verify it's JSON-serializable
|
|
1198
|
-
const currentState = userStore.get()
|
|
1199
|
-
expect(currentState.user.preferences.theme).toBe('light')
|
|
1200
|
-
expect(currentState.user.preferences.notifications).toBe(false)
|
|
1201
|
-
expect(currentState.settings.timeout).toBe(10000)
|
|
1202
|
-
expect(currentState.tags).toEqual([
|
|
1203
|
-
'developer',
|
|
1204
|
-
'javascript',
|
|
1205
|
-
'typescript',
|
|
1206
|
-
])
|
|
1067
|
+
describe('JSON integration and serialization', () => {
|
|
1068
|
+
test('seamless JSON integration for both store types', () => {
|
|
1069
|
+
// Record store from JSON
|
|
1070
|
+
const jsonData = {
|
|
1071
|
+
user: { name: 'John', preferences: { theme: 'dark' } },
|
|
1072
|
+
settings: { timeout: 5000 },
|
|
1073
|
+
}
|
|
1074
|
+
const recordStore = createStore(jsonData)
|
|
1075
|
+
expect(recordStore.user.name.get()).toBe('John')
|
|
1076
|
+
expect(recordStore.user.preferences.theme.get()).toBe('dark')
|
|
1077
|
+
|
|
1078
|
+
// Modify and serialize back
|
|
1079
|
+
recordStore.user.name.set('Jane')
|
|
1080
|
+
recordStore.settings.timeout.set(10000)
|
|
1081
|
+
const serialized = JSON.stringify(recordStore.get())
|
|
1082
|
+
const parsed = JSON.parse(serialized)
|
|
1083
|
+
expect(parsed.user.name).toBe('Jane')
|
|
1084
|
+
expect(parsed.settings.timeout).toBe(10000)
|
|
1085
|
+
|
|
1086
|
+
// Array store from JSON
|
|
1087
|
+
const arrayData = [
|
|
1088
|
+
{ id: 1, name: 'Item 1' },
|
|
1089
|
+
{ id: 2, name: 'Item 2' },
|
|
1090
|
+
]
|
|
1091
|
+
const arrayStore = createStore(arrayData)
|
|
1092
|
+
expect(arrayStore[0].name.get()).toBe('Item 1')
|
|
1207
1093
|
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
expect(parsedBack.user.preferences.theme).toBe('light')
|
|
1214
|
-
expect(parsedBack.user.preferences.notifications).toBe(false)
|
|
1215
|
-
expect(parsedBack.user.preferences.fontSize).toBe(14)
|
|
1216
|
-
expect(parsedBack.settings.timeout).toBe(10000)
|
|
1217
|
-
expect(parsedBack.lastLogin).toBe('2024-01-15T10:30:00.000Z')
|
|
1218
|
-
|
|
1219
|
-
// Demonstrate update() for bulk changes
|
|
1220
|
-
userStore.update(data => ({
|
|
1221
|
-
...data,
|
|
1222
|
-
user: {
|
|
1223
|
-
...data.user,
|
|
1224
|
-
email: 'john.doe@newcompany.com',
|
|
1225
|
-
preferences: {
|
|
1226
|
-
...data.user.preferences,
|
|
1227
|
-
theme: 'auto',
|
|
1228
|
-
language: 'fr',
|
|
1229
|
-
},
|
|
1230
|
-
},
|
|
1231
|
-
settings: {
|
|
1232
|
-
...data.settings,
|
|
1233
|
-
autoSave: false,
|
|
1234
|
-
},
|
|
1235
|
-
}))
|
|
1236
|
-
|
|
1237
|
-
// Verify bulk update worked
|
|
1238
|
-
expect(userStore.user.email.get()).toBe('john.doe@newcompany.com')
|
|
1239
|
-
expect(userStore.user.preferences.theme.get()).toBe('auto')
|
|
1240
|
-
expect(userStore.user.preferences.language.get()).toBe('fr')
|
|
1241
|
-
expect(userStore.settings.autoSave.get()).toBe(false)
|
|
1242
|
-
|
|
1243
|
-
// Final JSON serialization for sending to server
|
|
1244
|
-
const finalPayload = JSON.stringify(userStore.get())
|
|
1245
|
-
expect(typeof finalPayload).toBe('string')
|
|
1246
|
-
expect(finalPayload).toContain('john.doe@newcompany.com')
|
|
1247
|
-
expect(finalPayload).toContain('"theme":"auto"')
|
|
1094
|
+
// Modify and serialize
|
|
1095
|
+
arrayStore[0].name.set('Updated Item')
|
|
1096
|
+
const arraySerialized = JSON.stringify(arrayStore.get())
|
|
1097
|
+
const arrayParsed = JSON.parse(arraySerialized)
|
|
1098
|
+
expect(arrayParsed[0].name).toBe('Updated Item')
|
|
1248
1099
|
})
|
|
1249
1100
|
|
|
1250
|
-
test('handles complex nested structures
|
|
1251
|
-
|
|
1252
|
-
"dashboard": {
|
|
1253
|
-
"widgets": [
|
|
1254
|
-
{"id": 1, "type": "chart", "config": {"color": "blue"}},
|
|
1255
|
-
{"id": 2, "type": "table", "config": {"rows": 10}}
|
|
1256
|
-
],
|
|
1257
|
-
"layout": {
|
|
1258
|
-
"columns": 3,
|
|
1259
|
-
"responsive": true
|
|
1260
|
-
}
|
|
1261
|
-
},
|
|
1262
|
-
"metadata": {
|
|
1263
|
-
"version": "1.0.0",
|
|
1264
|
-
"created": "2024-01-01T00:00:00Z",
|
|
1265
|
-
"tags": null
|
|
1266
|
-
}
|
|
1267
|
-
}`
|
|
1268
|
-
|
|
1269
|
-
const data = JSON.parse(complexJson)
|
|
1270
|
-
|
|
1271
|
-
// Test that null values in initial JSON are filtered out (treated as UNSET)
|
|
1272
|
-
const dashboardStore = createStore<{
|
|
1101
|
+
test('handles complex nested structures from JSON', () => {
|
|
1102
|
+
type Dashboard = {
|
|
1273
1103
|
dashboard: {
|
|
1274
|
-
widgets: {
|
|
1104
|
+
widgets: Array<{
|
|
1275
1105
|
id: number
|
|
1276
1106
|
type: string
|
|
1277
|
-
config:
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
metadata: {
|
|
1285
|
-
version: string
|
|
1286
|
-
created: string
|
|
1287
|
-
tags?: string[]
|
|
1107
|
+
config: {
|
|
1108
|
+
color?: string
|
|
1109
|
+
rows?: number
|
|
1110
|
+
}
|
|
1111
|
+
}>
|
|
1288
1112
|
}
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// Access nested array elements
|
|
1292
|
-
expect(dashboardStore.dashboard.widgets.get()).toHaveLength(2)
|
|
1293
|
-
expect(dashboardStore.dashboard.widgets[0].type.get()).toBe('chart')
|
|
1294
|
-
expect(dashboardStore.dashboard.widgets[1].config.rows.get()).toBe(
|
|
1295
|
-
10,
|
|
1296
|
-
)
|
|
1297
|
-
|
|
1298
|
-
// Update array element
|
|
1299
|
-
dashboardStore.set({
|
|
1300
|
-
...dashboardStore.get(),
|
|
1113
|
+
}
|
|
1114
|
+
const complexData = {
|
|
1301
1115
|
dashboard: {
|
|
1302
|
-
...dashboardStore.dashboard.get(),
|
|
1303
1116
|
widgets: [
|
|
1304
|
-
|
|
1305
|
-
{ id:
|
|
1117
|
+
{ id: 1, type: 'chart', config: { color: 'blue' } },
|
|
1118
|
+
{ id: 2, type: 'table', config: { rows: 10 } },
|
|
1306
1119
|
],
|
|
1307
1120
|
},
|
|
1308
|
-
})
|
|
1309
|
-
|
|
1310
|
-
// Verify array update
|
|
1311
|
-
expect(dashboardStore.get().dashboard.widgets).toHaveLength(3)
|
|
1312
|
-
expect(dashboardStore.get().dashboard.widgets[2].type).toBe('graph')
|
|
1313
|
-
|
|
1314
|
-
// Test that individual null additions are still prevented via add()
|
|
1315
|
-
expect(() => {
|
|
1316
|
-
// @ts-expect-error deliberate test case
|
|
1317
|
-
dashboardStore.add('newProp', null)
|
|
1318
|
-
}).toThrow(
|
|
1319
|
-
'Nullish signal values are not allowed in store for key "newProp"',
|
|
1320
|
-
)
|
|
1321
|
-
|
|
1322
|
-
// Test that individual property .set() operations prevent null values
|
|
1323
|
-
expect(() => {
|
|
1324
|
-
dashboardStore.update(data => ({
|
|
1325
|
-
...data,
|
|
1326
|
-
metadata: {
|
|
1327
|
-
...data.metadata,
|
|
1328
|
-
// @ts-expect-error deliberate test case
|
|
1329
|
-
tags: null,
|
|
1330
|
-
},
|
|
1331
|
-
}))
|
|
1332
|
-
}).toThrow(
|
|
1333
|
-
'Nullish signal values are not allowed in store for key "tags"',
|
|
1334
|
-
)
|
|
1335
|
-
|
|
1336
|
-
// Update null to actual value (this should work)
|
|
1337
|
-
dashboardStore.update(data => ({
|
|
1338
|
-
...data,
|
|
1339
|
-
metadata: {
|
|
1340
|
-
...data.metadata,
|
|
1341
|
-
tags: ['production', 'v1'],
|
|
1342
|
-
},
|
|
1343
|
-
}))
|
|
1344
|
-
|
|
1345
|
-
expect(dashboardStore.get().metadata.tags).toEqual([
|
|
1346
|
-
'production',
|
|
1347
|
-
'v1',
|
|
1348
|
-
])
|
|
1349
|
-
|
|
1350
|
-
// Verify JSON round-trip
|
|
1351
|
-
const serialized = JSON.stringify(dashboardStore.get())
|
|
1352
|
-
const reparsed = JSON.parse(serialized)
|
|
1353
|
-
expect(reparsed.dashboard.widgets).toHaveLength(3)
|
|
1354
|
-
expect(reparsed.metadata.tags).toEqual(['production', 'v1'])
|
|
1355
|
-
})
|
|
1356
|
-
|
|
1357
|
-
test('demonstrates real-world form data management', () => {
|
|
1358
|
-
// Simulate form data loaded from API
|
|
1359
|
-
const formData = {
|
|
1360
|
-
profile: {
|
|
1361
|
-
firstName: '',
|
|
1362
|
-
lastName: '',
|
|
1363
|
-
email: '',
|
|
1364
|
-
bio: '',
|
|
1365
|
-
},
|
|
1366
|
-
preferences: {
|
|
1367
|
-
emailNotifications: true,
|
|
1368
|
-
pushNotifications: false,
|
|
1369
|
-
marketing: false,
|
|
1370
|
-
},
|
|
1371
|
-
address: {
|
|
1372
|
-
street: '',
|
|
1373
|
-
city: '',
|
|
1374
|
-
country: 'US',
|
|
1375
|
-
zipCode: '',
|
|
1376
|
-
},
|
|
1377
1121
|
}
|
|
1378
1122
|
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
createdAt?: string
|
|
1383
|
-
firstName: string
|
|
1384
|
-
lastName: string
|
|
1385
|
-
email: string
|
|
1386
|
-
bio: string
|
|
1387
|
-
}
|
|
1388
|
-
preferences: {
|
|
1389
|
-
emailNotifications: boolean
|
|
1390
|
-
pushNotifications: boolean
|
|
1391
|
-
marketing: boolean
|
|
1392
|
-
}
|
|
1393
|
-
address: {
|
|
1394
|
-
street: string
|
|
1395
|
-
city: string
|
|
1396
|
-
country: string
|
|
1397
|
-
zipCode: string
|
|
1398
|
-
}
|
|
1399
|
-
}>(formData)
|
|
1400
|
-
|
|
1401
|
-
// Simulate user filling out form
|
|
1402
|
-
formStore.profile.firstName.set('Jane')
|
|
1403
|
-
formStore.profile.lastName.set('Smith')
|
|
1404
|
-
formStore.profile.email.set('jane.smith@example.com')
|
|
1405
|
-
formStore.profile.bio.set(
|
|
1406
|
-
'Full-stack developer with 5 years experience',
|
|
1407
|
-
)
|
|
1408
|
-
|
|
1409
|
-
// Update address
|
|
1410
|
-
formStore.address.street.set('123 Main St')
|
|
1411
|
-
formStore.address.city.set('San Francisco')
|
|
1412
|
-
formStore.address.zipCode.set('94105')
|
|
1413
|
-
|
|
1414
|
-
// Toggle preferences
|
|
1415
|
-
formStore.preferences.pushNotifications.set(true)
|
|
1416
|
-
formStore.preferences.marketing.set(true)
|
|
1417
|
-
|
|
1418
|
-
// Get form data for submission - ready for JSON.stringify
|
|
1419
|
-
const submissionData = formStore.get()
|
|
1420
|
-
|
|
1421
|
-
expect(submissionData.profile.firstName).toBe('Jane')
|
|
1422
|
-
expect(submissionData.profile.email).toBe('jane.smith@example.com')
|
|
1423
|
-
expect(submissionData.address.city).toBe('San Francisco')
|
|
1424
|
-
expect(submissionData.preferences.pushNotifications).toBe(true)
|
|
1425
|
-
|
|
1426
|
-
// Simulate sending to API
|
|
1427
|
-
const jsonPayload = JSON.stringify(submissionData)
|
|
1428
|
-
expect(jsonPayload).toContain('jane.smith@example.com')
|
|
1429
|
-
expect(jsonPayload).toContain('San Francisco')
|
|
1430
|
-
|
|
1431
|
-
// Simulate receiving updated data back from server
|
|
1432
|
-
const serverResponse = {
|
|
1433
|
-
...submissionData,
|
|
1434
|
-
profile: {
|
|
1435
|
-
...submissionData.profile,
|
|
1436
|
-
id: 456,
|
|
1437
|
-
createdAt: '2024-01-15T12:00:00Z',
|
|
1438
|
-
},
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// Update store with server response
|
|
1442
|
-
formStore.set(serverResponse)
|
|
1443
|
-
|
|
1444
|
-
// Verify server data is integrated
|
|
1445
|
-
expect(formStore.profile.id?.get()).toBe(456)
|
|
1446
|
-
expect(formStore.profile.createdAt?.get()).toBe(
|
|
1447
|
-
'2024-01-15T12:00:00Z',
|
|
1448
|
-
)
|
|
1449
|
-
expect(formStore.get().profile.firstName).toBe('Jane') // Original data preserved
|
|
1450
|
-
})
|
|
1451
|
-
|
|
1452
|
-
describe('Symbol.isConcatSpreadable and polymorphic behavior', () => {
|
|
1453
|
-
test('array-like stores have Symbol.isConcatSpreadable true and length property', () => {
|
|
1454
|
-
const numbers = createStore([1, 2, 3])
|
|
1455
|
-
|
|
1456
|
-
// Should be concat spreadable
|
|
1457
|
-
expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
|
|
1458
|
-
|
|
1459
|
-
// Should have length property
|
|
1460
|
-
expect(numbers.length).toBe(3)
|
|
1461
|
-
expect(typeof numbers.length).toBe('number')
|
|
1462
|
-
|
|
1463
|
-
// Add an item and verify length updates
|
|
1464
|
-
numbers.add(4)
|
|
1465
|
-
expect(numbers.length).toBe(4)
|
|
1466
|
-
})
|
|
1467
|
-
|
|
1468
|
-
test('object-like stores have Symbol.isConcatSpreadable false and no length property', () => {
|
|
1469
|
-
const user = createStore({ name: 'John', age: 25 })
|
|
1470
|
-
|
|
1471
|
-
// Should not be concat spreadable
|
|
1472
|
-
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
1473
|
-
|
|
1474
|
-
// Should not have length property
|
|
1475
|
-
// @ts-expect-error deliberately accessing non-existent length property
|
|
1476
|
-
expect(user.length).toBeUndefined()
|
|
1477
|
-
expect('length' in user).toBe(false)
|
|
1478
|
-
})
|
|
1479
|
-
|
|
1480
|
-
test('array-like stores iterate over signals only', () => {
|
|
1481
|
-
const numbers = createStore([10, 20, 30])
|
|
1482
|
-
const signals = [...numbers]
|
|
1483
|
-
|
|
1484
|
-
// Should yield signals, not [key, signal] pairs
|
|
1485
|
-
expect(signals).toHaveLength(3)
|
|
1486
|
-
expect(signals[0].get()).toBe(10)
|
|
1487
|
-
expect(signals[1].get()).toBe(20)
|
|
1488
|
-
expect(signals[2].get()).toBe(30)
|
|
1489
|
-
|
|
1490
|
-
// Verify they are signal objects
|
|
1491
|
-
signals.forEach(signal => {
|
|
1492
|
-
expect(typeof signal.get).toBe('function')
|
|
1493
|
-
})
|
|
1494
|
-
})
|
|
1495
|
-
|
|
1496
|
-
test('object-like stores iterate over [key, signal] pairs', () => {
|
|
1497
|
-
const user = createStore({ name: 'Alice', age: 30 })
|
|
1498
|
-
const entries = [...user]
|
|
1499
|
-
|
|
1500
|
-
// Should yield [key, signal] pairs
|
|
1501
|
-
expect(entries).toHaveLength(2)
|
|
1502
|
-
|
|
1503
|
-
// Find the name entry
|
|
1504
|
-
const nameEntry = entries.find(([key]) => key === 'name')
|
|
1505
|
-
expect(nameEntry).toBeDefined()
|
|
1506
|
-
expect(nameEntry?.[0]).toBe('name')
|
|
1507
|
-
expect(nameEntry?.[1].get()).toBe('Alice')
|
|
1508
|
-
|
|
1509
|
-
// Find the age entry
|
|
1510
|
-
const ageEntry = entries.find(([key]) => key === 'age')
|
|
1511
|
-
expect(ageEntry).toBeDefined()
|
|
1512
|
-
expect(ageEntry?.[0]).toBe('age')
|
|
1513
|
-
expect(ageEntry?.[1].get()).toBe(30)
|
|
1514
|
-
})
|
|
1515
|
-
|
|
1516
|
-
test('array-like stores support single-parameter add() method', () => {
|
|
1517
|
-
const fruits = createStore(['apple', 'banana'])
|
|
1518
|
-
|
|
1519
|
-
// Should add to end without specifying key
|
|
1520
|
-
fruits.add('cherry')
|
|
1521
|
-
|
|
1522
|
-
const result = fruits.get()
|
|
1523
|
-
expect(result).toEqual(['apple', 'banana', 'cherry'])
|
|
1524
|
-
expect(fruits.length).toBe(3)
|
|
1525
|
-
})
|
|
1526
|
-
|
|
1527
|
-
test('object-like stores require key parameter for add() method', () => {
|
|
1528
|
-
const config = createStore<{
|
|
1529
|
-
debug: boolean
|
|
1530
|
-
timeout?: number
|
|
1531
|
-
}>({
|
|
1532
|
-
debug: true,
|
|
1533
|
-
})
|
|
1534
|
-
|
|
1535
|
-
// Should require both key and value
|
|
1536
|
-
config.add('timeout', 5000)
|
|
1537
|
-
|
|
1538
|
-
expect(config.get()).toEqual({ debug: true, timeout: 5000 })
|
|
1539
|
-
})
|
|
1540
|
-
|
|
1541
|
-
test('concat works correctly with array-like stores', () => {
|
|
1542
|
-
const numbers = createStore([2, 3])
|
|
1543
|
-
const prefix = [createState(1)]
|
|
1544
|
-
const suffix = [createState(4), createState(5)]
|
|
1545
|
-
|
|
1546
|
-
// Should spread signals when concat-ed
|
|
1547
|
-
const combined = prefix.concat(
|
|
1548
|
-
numbers as unknown as ConcatArray<State<number>>,
|
|
1549
|
-
suffix,
|
|
1550
|
-
)
|
|
1551
|
-
|
|
1552
|
-
expect(combined).toHaveLength(5)
|
|
1553
|
-
expect(combined[0].get()).toBe(1)
|
|
1554
|
-
expect(combined[1].get()).toBe(2) // from store
|
|
1555
|
-
expect(combined[2].get()).toBe(3) // from store
|
|
1556
|
-
expect(combined[3].get()).toBe(4)
|
|
1557
|
-
expect(combined[4].get()).toBe(5)
|
|
1558
|
-
})
|
|
1559
|
-
|
|
1560
|
-
test('spread operator works correctly with array-like stores', () => {
|
|
1561
|
-
const numbers = createStore([10, 20])
|
|
1562
|
-
|
|
1563
|
-
// Should spread signals
|
|
1564
|
-
const spread = [createState(5), ...numbers, createState(30)]
|
|
1565
|
-
|
|
1566
|
-
expect(spread).toHaveLength(4)
|
|
1567
|
-
expect(spread[0].get()).toBe(5)
|
|
1568
|
-
expect(spread[1].get()).toBe(10) // from store
|
|
1569
|
-
expect(spread[2].get()).toBe(20) // from store
|
|
1570
|
-
expect(spread[3].get()).toBe(30)
|
|
1571
|
-
})
|
|
1572
|
-
|
|
1573
|
-
test('array-like stores maintain numeric key ordering', () => {
|
|
1574
|
-
const items = createStore(['first', 'second', 'third'])
|
|
1575
|
-
|
|
1576
|
-
// Get the keys
|
|
1577
|
-
const keys = Object.keys(items)
|
|
1578
|
-
expect(keys).toEqual(['0', '1', '2', 'length'])
|
|
1579
|
-
|
|
1580
|
-
// Iteration should be in order
|
|
1581
|
-
const signals = [...items]
|
|
1582
|
-
expect(signals[0].get()).toBe('first')
|
|
1583
|
-
expect(signals[1].get()).toBe('second')
|
|
1584
|
-
expect(signals[2].get()).toBe('third')
|
|
1585
|
-
})
|
|
1586
|
-
|
|
1587
|
-
test('polymorphic behavior is determined at creation time', () => {
|
|
1588
|
-
// Created as array - stays array-like
|
|
1589
|
-
const arrayStore = createStore([1, 2])
|
|
1590
|
-
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1591
|
-
expect(arrayStore.length).toBe(2)
|
|
1592
|
-
|
|
1593
|
-
// Created as object - stays object-like
|
|
1594
|
-
const objectStore = createStore<{
|
|
1595
|
-
a: number
|
|
1596
|
-
b: number
|
|
1597
|
-
c?: number
|
|
1598
|
-
}>({
|
|
1599
|
-
a: 1,
|
|
1600
|
-
b: 2,
|
|
1601
|
-
})
|
|
1602
|
-
expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1603
|
-
// @ts-expect-error deliberate access to non-existent length property
|
|
1604
|
-
expect(objectStore.length).toBeUndefined()
|
|
1605
|
-
|
|
1606
|
-
// Even after modifications, behavior doesn't change
|
|
1607
|
-
arrayStore.add(3)
|
|
1608
|
-
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1609
|
-
|
|
1610
|
-
objectStore.add('c', 3)
|
|
1611
|
-
expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1612
|
-
})
|
|
1613
|
-
|
|
1614
|
-
test('runtime type detection using typeof length', () => {
|
|
1615
|
-
const arrayStore = createStore([1, 2, 3])
|
|
1616
|
-
const objectStore = createStore({ x: 1, y: 2 })
|
|
1617
|
-
|
|
1618
|
-
// Can distinguish at runtime
|
|
1619
|
-
expect(typeof arrayStore.length === 'number').toBe(true)
|
|
1620
|
-
// @ts-expect-error deliberately accessing non-existent length property
|
|
1621
|
-
expect(typeof objectStore.length === 'number').toBe(false)
|
|
1622
|
-
})
|
|
1623
|
-
|
|
1624
|
-
test('empty stores behave correctly', () => {
|
|
1625
|
-
const emptyArray = createStore([])
|
|
1626
|
-
const emptyObject = createStore({})
|
|
1123
|
+
const store = createStore<Dashboard>(complexData)
|
|
1124
|
+
expect(store.dashboard.widgets[0].type.get()).toBe('chart')
|
|
1125
|
+
expect(store.dashboard.widgets[1].config.rows?.get()).toBe(10)
|
|
1627
1126
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
expect([...emptyArray]).toEqual([])
|
|
1632
|
-
|
|
1633
|
-
// Empty object store
|
|
1634
|
-
expect(emptyObject[Symbol.isConcatSpreadable]).toBe(false)
|
|
1635
|
-
// @ts-expect-error deliberately accessing non-existent length property
|
|
1636
|
-
expect(emptyObject.length).toBeUndefined()
|
|
1637
|
-
expect([...emptyObject]).toEqual([])
|
|
1638
|
-
})
|
|
1639
|
-
})
|
|
1640
|
-
|
|
1641
|
-
test('debug length property issue', () => {
|
|
1642
|
-
const numbers = createStore([1, 2, 3])
|
|
1643
|
-
|
|
1644
|
-
// Test length in computed context
|
|
1645
|
-
const lengthComputed = createComputed(() => numbers.length)
|
|
1646
|
-
numbers.add(4)
|
|
1647
|
-
|
|
1648
|
-
// Test if length property is actually reactive
|
|
1649
|
-
expect(numbers.length).toBe(4)
|
|
1650
|
-
expect(lengthComputed.get()).toBe(4)
|
|
1127
|
+
// Update nested array element
|
|
1128
|
+
store.dashboard.widgets[0].config.color?.set('red')
|
|
1129
|
+
expect(store.get().dashboard.widgets[0].config.color).toBe('red')
|
|
1651
1130
|
})
|
|
1652
1131
|
})
|
|
1653
1132
|
|
|
1654
|
-
describe('
|
|
1655
|
-
test('
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
numbers[1],
|
|
1662
|
-
numbers[2],
|
|
1663
|
-
numbers[3],
|
|
1664
|
-
numbers[4],
|
|
1665
|
-
]
|
|
1666
|
-
|
|
1667
|
-
numbers.sort((a, b) => a - b)
|
|
1668
|
-
|
|
1669
|
-
// Check sorted order
|
|
1670
|
-
expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
|
|
1671
|
-
|
|
1672
|
-
// Verify signal references are preserved (moved, not recreated)
|
|
1673
|
-
expect(numbers[0]).toBe(oldSignals[1]) // first 1 was at index 1
|
|
1674
|
-
expect(numbers[1]).toBe(oldSignals[3]) // second 1 was at index 3
|
|
1675
|
-
expect(numbers[2]).toBe(oldSignals[0]) // 3 was at index 0
|
|
1676
|
-
expect(numbers[3]).toBe(oldSignals[2]) // 4 was at index 2
|
|
1677
|
-
expect(numbers[4]).toBe(oldSignals[4]) // 5 was at index 4
|
|
1678
|
-
})
|
|
1679
|
-
|
|
1680
|
-
test('sorts array-like store with string compareFn', () => {
|
|
1681
|
-
const names = createStore(['Charlie', 'Alice', 'Bob'])
|
|
1682
|
-
|
|
1683
|
-
names.sort((a, b) => a.localeCompare(b))
|
|
1684
|
-
|
|
1685
|
-
expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
|
|
1133
|
+
describe('type conversion and nested stores', () => {
|
|
1134
|
+
test('arrays are converted to stores when nested', () => {
|
|
1135
|
+
const data = createStore({ items: [1, 2, 3] })
|
|
1136
|
+
expect(isStore(data.items)).toBe(true)
|
|
1137
|
+
expect(data.items[0].get()).toBe(1)
|
|
1138
|
+
expect(data.items[1].get()).toBe(2)
|
|
1139
|
+
expect(data.items[2].get()).toBe(3)
|
|
1686
1140
|
})
|
|
1687
1141
|
|
|
1688
|
-
test('
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1142
|
+
test('nested objects become nested stores', () => {
|
|
1143
|
+
const config = createStore({
|
|
1144
|
+
database: {
|
|
1145
|
+
host: 'localhost',
|
|
1146
|
+
port: 5432,
|
|
1147
|
+
},
|
|
1693
1148
|
})
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
user1: users.user1,
|
|
1698
|
-
user2: users.user2,
|
|
1699
|
-
user3: users.user3,
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
// Sort by age
|
|
1703
|
-
users.sort((a, b) => a.age - b.age)
|
|
1704
|
-
|
|
1705
|
-
// Check order via iteration
|
|
1706
|
-
const keys = Array.from(users, ([key]) => key)
|
|
1707
|
-
expect(keys).toEqual(['user3', 'user1', 'user2'])
|
|
1708
|
-
|
|
1709
|
-
// Verify signal references are preserved
|
|
1710
|
-
expect(users.user1).toBe(oldSignals.user1)
|
|
1711
|
-
expect(users.user2).toBe(oldSignals.user2)
|
|
1712
|
-
expect(users.user3).toBe(oldSignals.user3)
|
|
1149
|
+
expect(isStore(config.database)).toBe(true)
|
|
1150
|
+
expect(config.database.host.get()).toBe('localhost')
|
|
1151
|
+
expect(config.database.port.get()).toBe(5432)
|
|
1713
1152
|
})
|
|
1714
1153
|
|
|
1715
|
-
test('
|
|
1716
|
-
const
|
|
1717
|
-
|
|
1154
|
+
test('array store with nested objects has correct type inference', () => {
|
|
1155
|
+
const users = createStore([
|
|
1156
|
+
{ name: 'Alice', active: true },
|
|
1157
|
+
{ name: 'Bob', active: false },
|
|
1158
|
+
])
|
|
1718
1159
|
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1160
|
+
// Object array elements should be Store<T>
|
|
1161
|
+
expect(isStore(users[0])).toBe(true)
|
|
1162
|
+
expect(isStore(users[1])).toBe(true)
|
|
1722
1163
|
|
|
1723
|
-
|
|
1164
|
+
// Should be able to access nested properties
|
|
1165
|
+
expect(users[0].name.get()).toBe('Alice')
|
|
1166
|
+
expect(users[0].active.get()).toBe(true)
|
|
1167
|
+
expect(users[1].name.get()).toBe('Bob')
|
|
1168
|
+
expect(users[1].active.get()).toBe(false)
|
|
1724
1169
|
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
expect(
|
|
1170
|
+
// Should be able to modify nested properties
|
|
1171
|
+
users[0].name.set('Alicia')
|
|
1172
|
+
users[0].active.set(false)
|
|
1173
|
+
expect(users[0].name.get()).toBe('Alicia')
|
|
1174
|
+
expect(users[0].active.get()).toBe(false)
|
|
1729
1175
|
})
|
|
1176
|
+
})
|
|
1730
1177
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
let lastValue: number[] = []
|
|
1178
|
+
describe('advanced array behaviors', () => {
|
|
1179
|
+
test('array compaction with remove operations', () => {
|
|
1180
|
+
const numbers = createStore([10, 20, 30, 40, 50])
|
|
1735
1181
|
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1182
|
+
// Create computed to test both iteration and get() approaches
|
|
1183
|
+
const sumWithGet = createComputed(() => {
|
|
1184
|
+
const array = numbers.get()
|
|
1185
|
+
return array.reduce((acc, num) => acc + num, 0)
|
|
1739
1186
|
})
|
|
1740
1187
|
|
|
1741
|
-
//
|
|
1742
|
-
expect(effectCount).toBe(1)
|
|
1743
|
-
expect(lastValue).toEqual([3, 1, 2])
|
|
1188
|
+
expect(sumWithGet.get()).toBe(150) // 10+20+30+40+50
|
|
1744
1189
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
expect(
|
|
1749
|
-
expect(
|
|
1750
|
-
})
|
|
1751
|
-
|
|
1752
|
-
test('nested signals remain reactive after sorting', () => {
|
|
1753
|
-
const items = createStore([
|
|
1754
|
-
{ name: 'Charlie', score: 85 },
|
|
1755
|
-
{ name: 'Alice', score: 95 },
|
|
1756
|
-
{ name: 'Bob', score: 75 },
|
|
1757
|
-
])
|
|
1758
|
-
|
|
1759
|
-
// Sort by score
|
|
1760
|
-
items.sort((a, b) => b.score - a.score) // descending
|
|
1761
|
-
|
|
1762
|
-
// Verify order
|
|
1763
|
-
expect(items.get().map(item => item.name)).toEqual([
|
|
1764
|
-
'Alice',
|
|
1765
|
-
'Charlie',
|
|
1766
|
-
'Bob',
|
|
1767
|
-
])
|
|
1768
|
-
|
|
1769
|
-
// Modify a nested property
|
|
1770
|
-
items[1].score.set(100) // Charlie's score
|
|
1771
|
-
|
|
1772
|
-
// Verify the change is reflected
|
|
1773
|
-
expect(items.get()[1].score).toBe(100)
|
|
1774
|
-
expect(items[1].name.get()).toBe('Charlie')
|
|
1775
|
-
})
|
|
1776
|
-
|
|
1777
|
-
test('sort with complex nested structures', () => {
|
|
1778
|
-
const posts = createStore([
|
|
1779
|
-
{
|
|
1780
|
-
id: 'post1',
|
|
1781
|
-
title: 'Hello World',
|
|
1782
|
-
meta: { views: 100, likes: 5 },
|
|
1783
|
-
},
|
|
1784
|
-
{
|
|
1785
|
-
id: 'post2',
|
|
1786
|
-
title: 'Getting Started',
|
|
1787
|
-
meta: { views: 50, likes: 10 },
|
|
1788
|
-
},
|
|
1789
|
-
{
|
|
1790
|
-
id: 'post3',
|
|
1791
|
-
title: 'Advanced Topics',
|
|
1792
|
-
meta: { views: 200, likes: 3 },
|
|
1793
|
-
},
|
|
1794
|
-
])
|
|
1795
|
-
|
|
1796
|
-
// Sort by likes (ascending)
|
|
1797
|
-
posts.sort((a, b) => a.meta.likes - b.meta.likes)
|
|
1798
|
-
|
|
1799
|
-
const sortedTitles = posts.get().map(post => post.title)
|
|
1800
|
-
expect(sortedTitles).toEqual([
|
|
1801
|
-
'Advanced Topics',
|
|
1802
|
-
'Hello World',
|
|
1803
|
-
'Getting Started',
|
|
1804
|
-
])
|
|
1190
|
+
// Remove middle element - should compact the array
|
|
1191
|
+
numbers.remove(2) // Remove 30
|
|
1192
|
+
expect(numbers.length).toBe(4)
|
|
1193
|
+
expect(numbers.get()).toEqual([10, 20, 40, 50])
|
|
1194
|
+
expect(sumWithGet.get()).toBe(120) // 10+20+40+50
|
|
1805
1195
|
|
|
1806
|
-
//
|
|
1807
|
-
|
|
1808
|
-
expect(
|
|
1196
|
+
// Remove first element
|
|
1197
|
+
numbers.remove(0) // Remove 10
|
|
1198
|
+
expect(numbers.length).toBe(3)
|
|
1199
|
+
expect(numbers.get()).toEqual([20, 40, 50])
|
|
1200
|
+
expect(sumWithGet.get()).toBe(110) // 20+40+50
|
|
1809
1201
|
})
|
|
1810
1202
|
|
|
1811
|
-
test('
|
|
1812
|
-
const
|
|
1813
|
-
|
|
1814
|
-
expect(arr.length).toBe(4)
|
|
1815
|
-
expect(arr.size.get()).toBe(4)
|
|
1816
|
-
|
|
1817
|
-
arr.sort((a, b) => a - b)
|
|
1818
|
-
|
|
1819
|
-
expect(arr.length).toBe(4)
|
|
1820
|
-
expect(arr.size.get()).toBe(4)
|
|
1821
|
-
expect(arr.get()).toEqual([1, 2, 5, 8])
|
|
1822
|
-
})
|
|
1203
|
+
test('sparse array replacement works correctly', () => {
|
|
1204
|
+
const numbers = createStore([10, 20, 30])
|
|
1823
1205
|
|
|
1824
|
-
|
|
1825
|
-
|
|
1206
|
+
// Remove middle element to create sparse structure internally
|
|
1207
|
+
numbers.remove(1) // Remove 20, now [10, 30] with internal keys ["0", "2"]
|
|
1826
1208
|
|
|
1827
|
-
|
|
1209
|
+
expect(numbers.get()).toEqual([10, 30])
|
|
1210
|
+
expect(numbers.length).toBe(2)
|
|
1828
1211
|
|
|
1829
|
-
//
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
)
|
|
1212
|
+
// Set new array of same length - should work correctly
|
|
1213
|
+
numbers.set([100, 200])
|
|
1214
|
+
expect(numbers.get()).toEqual([100, 200])
|
|
1215
|
+
expect(numbers.length).toBe(2)
|
|
1216
|
+
expect(numbers[0].get()).toBe(100)
|
|
1217
|
+
expect(numbers[1].get()).toBe(200)
|
|
1833
1218
|
})
|
|
1219
|
+
})
|
|
1834
1220
|
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
// In UTF-16 order: "100" < "80" < "9"
|
|
1842
|
-
expect(numbers.get()).toEqual([80, 9, 100].sort())
|
|
1843
|
-
})
|
|
1221
|
+
describe('polymorphic behavior determined at creation', () => {
|
|
1222
|
+
test('store type is determined at creation time and maintained', () => {
|
|
1223
|
+
// Array store stays array-like
|
|
1224
|
+
const arrayStore = createStore([1, 2])
|
|
1225
|
+
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1226
|
+
expect(arrayStore.length).toBe(2)
|
|
1844
1227
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1228
|
+
// Even after modifications, stays array-like
|
|
1229
|
+
arrayStore.add(3)
|
|
1230
|
+
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1231
|
+
expect(arrayStore.length).toBe(3)
|
|
1847
1232
|
|
|
1848
|
-
|
|
1233
|
+
// Record store stays record-like
|
|
1234
|
+
const recordStore = createStore<{
|
|
1235
|
+
a: number
|
|
1236
|
+
b: number
|
|
1237
|
+
c?: number
|
|
1238
|
+
}>({ a: 1, b: 2 })
|
|
1239
|
+
expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1240
|
+
expect(recordStore.length).toBe(2)
|
|
1849
1241
|
|
|
1850
|
-
//
|
|
1851
|
-
|
|
1242
|
+
// Even after modifications, stays record-like
|
|
1243
|
+
recordStore.add('c', 3)
|
|
1244
|
+
expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1245
|
+
expect(recordStore.length).toBe(3)
|
|
1852
1246
|
})
|
|
1853
1247
|
|
|
1854
|
-
test('
|
|
1855
|
-
const
|
|
1248
|
+
test('empty stores maintain their type characteristics', () => {
|
|
1249
|
+
const emptyArray = createStore<string[]>([])
|
|
1250
|
+
const emptyRecord = createStore<{ key?: string }>({})
|
|
1856
1251
|
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
expect(
|
|
1252
|
+
// Empty array behaves like array
|
|
1253
|
+
expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
|
|
1254
|
+
expect(emptyArray.length).toBe(0)
|
|
1860
1255
|
|
|
1861
|
-
//
|
|
1862
|
-
|
|
1863
|
-
expect(
|
|
1864
|
-
})
|
|
1256
|
+
// Empty record behaves like record
|
|
1257
|
+
expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
|
|
1258
|
+
expect(emptyRecord.length).toBe(0)
|
|
1865
1259
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
bob: { age: 20 },
|
|
1870
|
-
charlie: { age: 25 },
|
|
1871
|
-
})
|
|
1260
|
+
// After adding items, they maintain their characteristics
|
|
1261
|
+
emptyArray.add('first')
|
|
1262
|
+
emptyRecord.add('key', 'value')
|
|
1872
1263
|
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
sortNotification = change
|
|
1876
|
-
})
|
|
1877
|
-
|
|
1878
|
-
// Sort by age
|
|
1879
|
-
users.sort((a, b) => b.age - a.age)
|
|
1880
|
-
|
|
1881
|
-
expect(sortNotification).not.toBeNull()
|
|
1882
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1883
|
-
expect(sortNotification!).toEqual(['alice', 'charlie', 'bob'])
|
|
1264
|
+
expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
|
|
1265
|
+
expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
|
|
1884
1266
|
})
|
|
1885
1267
|
})
|
|
1886
1268
|
|
|
1887
1269
|
describe('cross-component communication pattern', () => {
|
|
1888
1270
|
test('event bus with UNSET initialization - type-safe pattern', () => {
|
|
1889
|
-
// Component B (the owner) declares the shape of events it will emit
|
|
1890
1271
|
type EventBusSchema = {
|
|
1891
1272
|
userLogin: { userId: number; timestamp: number }
|
|
1892
1273
|
userLogout: { userId: number }
|
|
1893
1274
|
userUpdate: { userId: number; profile: { name: string } }
|
|
1894
1275
|
}
|
|
1895
1276
|
|
|
1896
|
-
// Initialize the event bus with proper typing
|
|
1897
1277
|
const eventBus = createStore<EventBusSchema>({
|
|
1898
1278
|
userLogin: UNSET,
|
|
1899
1279
|
userLogout: UNSET,
|
|
1900
1280
|
userUpdate: UNSET,
|
|
1901
1281
|
})
|
|
1902
1282
|
|
|
1903
|
-
// Simple type-safe on functions
|
|
1904
1283
|
const on = (
|
|
1905
1284
|
event: keyof EventBusSchema,
|
|
1906
1285
|
callback: (data: EventBusSchema[keyof EventBusSchema]) => void,
|
|
@@ -1910,30 +1289,26 @@ describe('store', () => {
|
|
|
1910
1289
|
if (data !== UNSET) callback(data)
|
|
1911
1290
|
})
|
|
1912
1291
|
|
|
1913
|
-
// Test the pattern with properly typed variables
|
|
1914
1292
|
let receivedLogin: unknown = null
|
|
1915
1293
|
let receivedLogout: unknown = null
|
|
1916
1294
|
let receivedUpdate: unknown = null
|
|
1917
1295
|
|
|
1918
|
-
// Component A listens for events
|
|
1919
1296
|
on('userLogin', data => {
|
|
1920
1297
|
receivedLogin = data
|
|
1921
1298
|
})
|
|
1922
|
-
|
|
1923
1299
|
on('userLogout', data => {
|
|
1924
1300
|
receivedLogout = data
|
|
1925
1301
|
})
|
|
1926
|
-
|
|
1927
1302
|
on('userUpdate', data => {
|
|
1928
1303
|
receivedUpdate = data
|
|
1929
1304
|
})
|
|
1930
1305
|
|
|
1931
|
-
// Initially nothing
|
|
1306
|
+
// Initially nothing received
|
|
1932
1307
|
expect(receivedLogin).toBe(null)
|
|
1933
1308
|
expect(receivedLogout).toBe(null)
|
|
1934
1309
|
expect(receivedUpdate).toBe(null)
|
|
1935
1310
|
|
|
1936
|
-
//
|
|
1311
|
+
// Emit events
|
|
1937
1312
|
const loginData: EventBusSchema['userLogin'] = {
|
|
1938
1313
|
userId: 123,
|
|
1939
1314
|
timestamp: Date.now(),
|
|
@@ -1941,17 +1316,15 @@ describe('store', () => {
|
|
|
1941
1316
|
eventBus.userLogin.set(loginData)
|
|
1942
1317
|
|
|
1943
1318
|
expect(receivedLogin).toEqual(loginData)
|
|
1944
|
-
expect(receivedLogout).toBe(null)
|
|
1945
|
-
expect(receivedUpdate).toBe(null)
|
|
1319
|
+
expect(receivedLogout).toBe(null)
|
|
1320
|
+
expect(receivedUpdate).toBe(null)
|
|
1946
1321
|
|
|
1947
|
-
// Test second event
|
|
1948
1322
|
const logoutData: EventBusSchema['userLogout'] = { userId: 123 }
|
|
1949
1323
|
eventBus.userLogout.set(logoutData)
|
|
1950
1324
|
|
|
1951
1325
|
expect(receivedLogout).toEqual(logoutData)
|
|
1952
|
-
expect(receivedLogin).toEqual(loginData) //
|
|
1326
|
+
expect(receivedLogin).toEqual(loginData) // unchanged
|
|
1953
1327
|
|
|
1954
|
-
// Test third event
|
|
1955
1328
|
const updateData: EventBusSchema['userUpdate'] = {
|
|
1956
1329
|
userId: 456,
|
|
1957
1330
|
profile: { name: 'Alice' },
|
|
@@ -1959,24 +1332,8 @@ describe('store', () => {
|
|
|
1959
1332
|
eventBus.userUpdate.set(updateData)
|
|
1960
1333
|
|
|
1961
1334
|
expect(receivedUpdate).toEqual(updateData)
|
|
1962
|
-
expect(receivedLogin).toEqual(loginData) //
|
|
1963
|
-
expect(receivedLogout).toEqual(logoutData) //
|
|
1964
|
-
|
|
1965
|
-
// Test updating existing event
|
|
1966
|
-
const newLoginData: EventBusSchema['userLogin'] = {
|
|
1967
|
-
userId: 789,
|
|
1968
|
-
timestamp: Date.now(),
|
|
1969
|
-
}
|
|
1970
|
-
eventBus.userLogin.set(newLoginData)
|
|
1971
|
-
|
|
1972
|
-
expect(receivedLogin).toEqual(newLoginData) // Should update
|
|
1973
|
-
expect(receivedLogout).toEqual(logoutData) // Should remain unchanged
|
|
1974
|
-
expect(receivedUpdate).toEqual(updateData) // Should remain unchanged
|
|
1975
|
-
|
|
1976
|
-
// Compile-time type checking prevents errors:
|
|
1977
|
-
// emitUserLogin({ userId: 'invalid' }) // ❌ TypeScript error
|
|
1978
|
-
// emitUserLogin({ userId: 123, extraProp: 'invalid' }) // ❌ TypeScript error
|
|
1979
|
-
// emitUserLogout({ userId: 123, extraProp: 'invalid' }) // ❌ TypeScript error
|
|
1335
|
+
expect(receivedLogin).toEqual(loginData) // unchanged
|
|
1336
|
+
expect(receivedLogout).toEqual(logoutData) // unchanged
|
|
1980
1337
|
})
|
|
1981
1338
|
})
|
|
1982
1339
|
})
|