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