@zeix/cause-effect 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -27
- package/index.d.ts +31 -6
- package/index.dev.js +381 -46
- package/index.js +1 -1
- package/index.ts +23 -4
- package/package.json +2 -2
- package/src/computed.ts +15 -6
- package/src/diff.ts +136 -0
- package/src/effect.ts +58 -50
- package/src/match.ts +57 -0
- package/src/resolve.ts +58 -0
- package/src/signal.ts +46 -14
- package/src/state.ts +4 -3
- package/src/store.ts +325 -0
- package/src/util.ts +56 -4
- package/test/batch.test.ts +23 -19
- package/test/benchmark.test.ts +8 -8
- package/test/computed.test.ts +15 -11
- package/test/diff.test.ts +638 -0
- package/test/effect.test.ts +656 -48
- package/test/match.test.ts +378 -0
- package/test/resolve.test.ts +156 -0
- package/test/store.test.ts +719 -0
- package/tsconfig.json +9 -10
- package/types/index.d.ts +15 -0
- package/types/src/diff.d.ts +27 -0
- package/types/src/effect.d.ts +16 -0
- package/types/src/match.d.ts +21 -0
- package/types/src/resolve.d.ts +29 -0
- package/types/src/signal.d.ts +40 -0
- package/{src → types/src}/state.d.ts +1 -1
- package/types/src/store.d.ts +57 -0
- package/types/src/util.d.ts +15 -0
- package/types/test-new-effect.d.ts +1 -0
- package/src/effect.d.ts +0 -17
- package/src/signal.d.ts +0 -26
- package/src/util.d.ts +0 -7
- /package/{src → types/src}/computed.d.ts +0 -0
- /package/{src → types/src}/scheduler.d.ts +0 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
effect,
|
|
5
|
+
isStore,
|
|
6
|
+
type StoreAddEvent,
|
|
7
|
+
type StoreChangeEvent,
|
|
8
|
+
type StoreRemoveEvent,
|
|
9
|
+
state,
|
|
10
|
+
store,
|
|
11
|
+
UNSET,
|
|
12
|
+
} from '..'
|
|
13
|
+
|
|
14
|
+
describe('store', () => {
|
|
15
|
+
describe('creation and basic operations', () => {
|
|
16
|
+
test('creates a store with initial values', () => {
|
|
17
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
18
|
+
|
|
19
|
+
expect(user.name.get()).toBe('Hannah')
|
|
20
|
+
expect(user.email.get()).toBe('hannah@example.com')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('has Symbol.toStringTag of Store', () => {
|
|
24
|
+
const s = store({ a: 1 })
|
|
25
|
+
expect(s[Symbol.toStringTag]).toBe('Store')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('isStore identifies store instances correctly', () => {
|
|
29
|
+
const s = store({ a: 1 })
|
|
30
|
+
const st = state(1)
|
|
31
|
+
const c = computed(() => 1)
|
|
32
|
+
|
|
33
|
+
expect(isStore(s)).toBe(true)
|
|
34
|
+
expect(isStore(st)).toBe(false)
|
|
35
|
+
expect(isStore(c)).toBe(false)
|
|
36
|
+
expect(isStore({})).toBe(false)
|
|
37
|
+
expect(isStore(null)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('get() returns the complete store value', () => {
|
|
41
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
42
|
+
|
|
43
|
+
expect(user.get()).toEqual({
|
|
44
|
+
name: 'Hannah',
|
|
45
|
+
email: 'hannah@example.com',
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('proxy data access and modification', () => {
|
|
51
|
+
test('properties can be accessed and modified via signals', () => {
|
|
52
|
+
const user = store({ name: 'Hannah', age: 25 })
|
|
53
|
+
|
|
54
|
+
// Get signals from store proxy
|
|
55
|
+
expect(user.name.get()).toBe('Hannah')
|
|
56
|
+
expect(user.age.get()).toBe(25)
|
|
57
|
+
|
|
58
|
+
// Set values via signals
|
|
59
|
+
user.name.set('Alice')
|
|
60
|
+
user.age.set(30)
|
|
61
|
+
|
|
62
|
+
expect(user.name.get()).toBe('Alice')
|
|
63
|
+
expect(user.age.get()).toBe(30)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('returns undefined for non-existent properties', () => {
|
|
67
|
+
const user = store({ name: 'Hannah' })
|
|
68
|
+
|
|
69
|
+
// @ts-expect-error accessing non-existent property
|
|
70
|
+
expect(user.nonExistent).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('supports numeric key access', () => {
|
|
74
|
+
const items = store({ '0': 'first', '1': 'second' })
|
|
75
|
+
|
|
76
|
+
expect(items[0].get()).toBe('first')
|
|
77
|
+
expect(items['0'].get()).toBe('first')
|
|
78
|
+
expect(items[1].get()).toBe('second')
|
|
79
|
+
expect(items['1'].get()).toBe('second')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('can add new properties via add method', () => {
|
|
83
|
+
const user = store<{ name: string; email?: string }>({
|
|
84
|
+
name: 'Hannah',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
user.add('email', 'hannah@example.com')
|
|
88
|
+
|
|
89
|
+
expect(user.email?.get()).toBe('hannah@example.com')
|
|
90
|
+
expect(user.get()).toEqual({
|
|
91
|
+
name: 'Hannah',
|
|
92
|
+
email: 'hannah@example.com',
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('can remove existing properties via remove method', () => {
|
|
97
|
+
const user = store<{ name: string; email?: string }>({
|
|
98
|
+
name: 'Hannah',
|
|
99
|
+
email: 'hannah@example.com',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(user.email?.get()).toBe('hannah@example.com')
|
|
103
|
+
|
|
104
|
+
user.remove('email')
|
|
105
|
+
|
|
106
|
+
expect(user.email).toBeUndefined()
|
|
107
|
+
expect(user.get()).toEqual({
|
|
108
|
+
name: 'Hannah',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('nested stores', () => {
|
|
114
|
+
test('creates nested stores for object properties', () => {
|
|
115
|
+
const user = store({
|
|
116
|
+
name: 'Hannah',
|
|
117
|
+
preferences: {
|
|
118
|
+
theme: 'dark',
|
|
119
|
+
notifications: true,
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(isStore(user.preferences)).toBe(true)
|
|
124
|
+
expect(user.preferences.theme?.get()).toBe('dark')
|
|
125
|
+
expect(user.preferences.notifications?.get()).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('nested properties are reactive', () => {
|
|
129
|
+
const user = store({
|
|
130
|
+
preferences: {
|
|
131
|
+
theme: 'dark',
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
user.preferences.theme.set('light')
|
|
136
|
+
expect(user.preferences.theme.get()).toBe('light')
|
|
137
|
+
expect(user.get().preferences.theme).toBe('light')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('deeply nested stores work correctly', () => {
|
|
141
|
+
const config = store({
|
|
142
|
+
ui: {
|
|
143
|
+
theme: {
|
|
144
|
+
colors: {
|
|
145
|
+
primary: 'blue',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(config.ui.theme.colors.primary.get()).toBe('blue')
|
|
152
|
+
config.ui.theme.colors.primary.set('red')
|
|
153
|
+
expect(config.ui.theme.colors.primary.get()).toBe('red')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('set() and update() methods', () => {
|
|
158
|
+
test('set() replaces entire store value', () => {
|
|
159
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
160
|
+
|
|
161
|
+
user.set({ name: 'Alice', email: 'alice@example.com' })
|
|
162
|
+
|
|
163
|
+
expect(user.get()).toEqual({
|
|
164
|
+
name: 'Alice',
|
|
165
|
+
email: 'alice@example.com',
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('update() modifies store using function', () => {
|
|
170
|
+
const user = store({ name: 'Hannah', age: 25 })
|
|
171
|
+
|
|
172
|
+
user.update(prev => ({ ...prev, age: prev.age + 1 }))
|
|
173
|
+
|
|
174
|
+
expect(user.get()).toEqual({
|
|
175
|
+
name: 'Hannah',
|
|
176
|
+
age: 26,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('iterator protocol', () => {
|
|
182
|
+
test('supports for...of iteration', () => {
|
|
183
|
+
const user = store({ name: 'Hannah', age: 25 })
|
|
184
|
+
const entries: Array<[string, unknown & {}]> = []
|
|
185
|
+
|
|
186
|
+
for (const [key, signal] of user) {
|
|
187
|
+
entries.push([key, signal.get()])
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
expect(entries).toContainEqual(['name', 'Hannah'])
|
|
191
|
+
expect(entries).toContainEqual(['age', 25])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('change tracking', () => {
|
|
196
|
+
test('tracks size changes', () => {
|
|
197
|
+
const user = store<{ name: string; email?: string }>({
|
|
198
|
+
name: 'Hannah',
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
expect(user.size.get()).toBe(1)
|
|
202
|
+
|
|
203
|
+
user.add('email', 'hannah@example.com')
|
|
204
|
+
expect(user.size.get()).toBe(2)
|
|
205
|
+
|
|
206
|
+
user.remove('email')
|
|
207
|
+
expect(user.size.get()).toBe(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('dispatches store-add event on initial creation', async () => {
|
|
211
|
+
let addEvent: StoreAddEvent<{ name: string }> | null = null
|
|
212
|
+
const user = store({ name: 'Hannah' })
|
|
213
|
+
|
|
214
|
+
user.addEventListener('store-add', event => {
|
|
215
|
+
addEvent = event
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Wait for the async initial event
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
220
|
+
|
|
221
|
+
expect(addEvent).toBeTruthy()
|
|
222
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
223
|
+
expect(addEvent!.detail).toEqual({ name: 'Hannah' })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('dispatches store-add event for new properties', () => {
|
|
227
|
+
const user = store<{ name: string; email?: string }>({
|
|
228
|
+
name: 'Hannah',
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
let addEvent: StoreAddEvent<{
|
|
232
|
+
name: string
|
|
233
|
+
email?: string
|
|
234
|
+
}> | null = null
|
|
235
|
+
user.addEventListener('store-add', event => {
|
|
236
|
+
addEvent = event
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
user.update(v => ({ ...v, email: 'hannah@example.com' }))
|
|
240
|
+
|
|
241
|
+
expect(addEvent).toBeTruthy()
|
|
242
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
243
|
+
expect(addEvent!.detail).toEqual({
|
|
244
|
+
email: 'hannah@example.com',
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('dispatches store-change event for property changes', () => {
|
|
249
|
+
const user = store({ name: 'Hannah' })
|
|
250
|
+
|
|
251
|
+
let changeEvent: StoreChangeEvent<{ name: string }> | null = null
|
|
252
|
+
user.addEventListener('store-change', event => {
|
|
253
|
+
changeEvent = event
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
user.set({ name: 'Alice' })
|
|
257
|
+
|
|
258
|
+
expect(changeEvent).toBeTruthy()
|
|
259
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
260
|
+
expect(changeEvent!.detail).toEqual({
|
|
261
|
+
name: 'Alice',
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('dispatches store-change event for signal changes', () => {
|
|
266
|
+
const user = store({ name: 'Hannah' })
|
|
267
|
+
|
|
268
|
+
let changeEvent: StoreChangeEvent<{ name: string }> | null = null
|
|
269
|
+
user.addEventListener('store-change', event => {
|
|
270
|
+
changeEvent = event
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
user.name.set('Bob')
|
|
274
|
+
|
|
275
|
+
expect(changeEvent).toBeTruthy()
|
|
276
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
277
|
+
expect(changeEvent!.detail).toEqual({
|
|
278
|
+
name: 'Bob',
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('dispatches store-remove event for removed properties', () => {
|
|
283
|
+
const user = store<{ name: string; email?: string }>({
|
|
284
|
+
name: 'Hannah',
|
|
285
|
+
email: 'hannah@example.com',
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
let removeEvent: StoreRemoveEvent<{
|
|
289
|
+
name: string
|
|
290
|
+
email?: string
|
|
291
|
+
}> | null = null
|
|
292
|
+
user.addEventListener('store-remove', event => {
|
|
293
|
+
removeEvent = event
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
user.remove('email')
|
|
297
|
+
|
|
298
|
+
expect(removeEvent).toBeTruthy()
|
|
299
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
300
|
+
expect(removeEvent!.detail.email).toBe(UNSET)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('dispatches store-add event when using add method', () => {
|
|
304
|
+
const user = store<{ name: string; email?: string }>({
|
|
305
|
+
name: 'Hannah',
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
let addEvent: StoreAddEvent<{
|
|
309
|
+
name: string
|
|
310
|
+
email?: string
|
|
311
|
+
}> | null = null
|
|
312
|
+
user.addEventListener('store-add', event => {
|
|
313
|
+
addEvent = event
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
user.add('email', 'hannah@example.com')
|
|
317
|
+
|
|
318
|
+
expect(addEvent).toBeTruthy()
|
|
319
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
320
|
+
expect(addEvent!.detail).toEqual({
|
|
321
|
+
email: 'hannah@example.com',
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('can remove event listeners', () => {
|
|
326
|
+
const user = store({ name: 'Hannah' })
|
|
327
|
+
|
|
328
|
+
let eventCount = 0
|
|
329
|
+
const listener = () => {
|
|
330
|
+
eventCount++
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
user.addEventListener('store-change', listener)
|
|
334
|
+
user.name.set('Alice')
|
|
335
|
+
expect(eventCount).toBe(1)
|
|
336
|
+
|
|
337
|
+
user.removeEventListener('store-change', listener)
|
|
338
|
+
user.name.set('Bob')
|
|
339
|
+
expect(eventCount).toBe(1) // Should not increment
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('supports multiple event listeners for the same event', () => {
|
|
343
|
+
const user = store({ name: 'Hannah' })
|
|
344
|
+
|
|
345
|
+
let listener1Called = false
|
|
346
|
+
let listener2Called = false
|
|
347
|
+
|
|
348
|
+
user.addEventListener('store-change', () => {
|
|
349
|
+
listener1Called = true
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
user.addEventListener('store-change', () => {
|
|
353
|
+
listener2Called = true
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
user.name.set('Alice')
|
|
357
|
+
|
|
358
|
+
expect(listener1Called).toBe(true)
|
|
359
|
+
expect(listener2Called).toBe(true)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('reactivity', () => {
|
|
364
|
+
test('store-level get() is reactive', () => {
|
|
365
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
366
|
+
let lastValue = { name: '', email: '' }
|
|
367
|
+
|
|
368
|
+
effect(() => {
|
|
369
|
+
lastValue = user.get()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
user.name.set('Alice')
|
|
373
|
+
|
|
374
|
+
expect(lastValue).toEqual({
|
|
375
|
+
name: 'Alice',
|
|
376
|
+
email: 'hannah@example.com',
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('individual signal reactivity works', () => {
|
|
381
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
382
|
+
let lastName = ''
|
|
383
|
+
let nameEffectRuns = 0
|
|
384
|
+
|
|
385
|
+
// Get signal for name property directly
|
|
386
|
+
const nameSignal = user.name
|
|
387
|
+
|
|
388
|
+
effect(() => {
|
|
389
|
+
lastName = nameSignal.get()
|
|
390
|
+
nameEffectRuns++
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Change name should trigger effect
|
|
394
|
+
user.name.set('Alice')
|
|
395
|
+
expect(lastName).toBe('Alice')
|
|
396
|
+
expect(nameEffectRuns).toBe(2) // Initial + update
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('nested store changes propagate to parent', () => {
|
|
400
|
+
const user = store({
|
|
401
|
+
preferences: {
|
|
402
|
+
theme: 'dark',
|
|
403
|
+
},
|
|
404
|
+
})
|
|
405
|
+
let effectRuns = 0
|
|
406
|
+
|
|
407
|
+
effect(() => {
|
|
408
|
+
user.get() // Watch entire store
|
|
409
|
+
effectRuns++
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
user.preferences.theme.set('light')
|
|
413
|
+
expect(effectRuns).toBe(2) // Initial + nested change
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('updates are reactive', () => {
|
|
417
|
+
const user = store<{ name: string; email?: string }>({
|
|
418
|
+
name: 'Hannah',
|
|
419
|
+
})
|
|
420
|
+
let lastValue = {}
|
|
421
|
+
let effectRuns = 0
|
|
422
|
+
|
|
423
|
+
effect(() => {
|
|
424
|
+
lastValue = user.get()
|
|
425
|
+
effectRuns++
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
user.add('email', 'hannah@example.com')
|
|
429
|
+
expect(lastValue).toEqual({
|
|
430
|
+
name: 'Hannah',
|
|
431
|
+
email: 'hannah@example.com',
|
|
432
|
+
})
|
|
433
|
+
expect(effectRuns).toBe(2)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('remove method is reactive', () => {
|
|
437
|
+
const user = store<{ name: string; email?: string }>({
|
|
438
|
+
name: 'Hannah',
|
|
439
|
+
email: 'hannah@example.com',
|
|
440
|
+
})
|
|
441
|
+
let lastValue = {}
|
|
442
|
+
let effectRuns = 0
|
|
443
|
+
|
|
444
|
+
effect(() => {
|
|
445
|
+
lastValue = user.get()
|
|
446
|
+
effectRuns++
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
expect(effectRuns).toBe(1)
|
|
450
|
+
|
|
451
|
+
user.remove('email')
|
|
452
|
+
expect(lastValue).toEqual({
|
|
453
|
+
name: 'Hannah',
|
|
454
|
+
})
|
|
455
|
+
expect(effectRuns).toBe(2)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('add method does not overwrite existing properties', () => {
|
|
459
|
+
const user = store<{ name: string; email?: string }>({
|
|
460
|
+
name: 'Hannah',
|
|
461
|
+
email: 'original@example.com',
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const originalSize = user.size.get()
|
|
465
|
+
user.add('email', 'new@example.com')
|
|
466
|
+
|
|
467
|
+
expect(user.email?.get()).toBe('original@example.com')
|
|
468
|
+
expect(user.size.get()).toBe(originalSize)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test('remove method has no effect on non-existent properties', () => {
|
|
472
|
+
const user = store<{ name: string; email?: string }>({
|
|
473
|
+
name: 'Hannah',
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const originalSize = user.size.get()
|
|
477
|
+
user.remove('email')
|
|
478
|
+
|
|
479
|
+
expect(user.size.get()).toBe(originalSize)
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
describe('computed integration', () => {
|
|
484
|
+
test('works with computed signals', () => {
|
|
485
|
+
const user = store({ firstName: 'Hannah', lastName: 'Smith' })
|
|
486
|
+
|
|
487
|
+
const fullName = computed(() => {
|
|
488
|
+
return `${user.firstName.get()} ${user.lastName.get()}`
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
expect(fullName.get()).toBe('Hannah Smith')
|
|
492
|
+
|
|
493
|
+
user.firstName.set('Alice')
|
|
494
|
+
expect(fullName.get()).toBe('Alice Smith')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('computed reacts to nested store changes', () => {
|
|
498
|
+
const config = store({
|
|
499
|
+
ui: {
|
|
500
|
+
theme: 'dark',
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const themeDisplay = computed(() => {
|
|
505
|
+
return `Theme: ${config.ui.theme.get()}`
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
expect(themeDisplay.get()).toBe('Theme: dark')
|
|
509
|
+
|
|
510
|
+
config.ui.theme.set('light')
|
|
511
|
+
expect(themeDisplay.get()).toBe('Theme: light')
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
describe('arrays and edge cases', () => {
|
|
516
|
+
test('handles arrays as store values', () => {
|
|
517
|
+
const data = store({ items: [1, 2, 3] })
|
|
518
|
+
|
|
519
|
+
// Arrays become stores with string indices
|
|
520
|
+
expect(isStore(data.items)).toBe(true)
|
|
521
|
+
expect(data.items['0'].get()).toBe(1)
|
|
522
|
+
expect(data.items['1'].get()).toBe(2)
|
|
523
|
+
expect(data.items['2'].get()).toBe(3)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
test('array-derived nested stores have correct type inference', () => {
|
|
527
|
+
const todoApp = store({
|
|
528
|
+
todos: ['Buy milk', 'Walk the dog', 'Write code'],
|
|
529
|
+
users: [
|
|
530
|
+
{ name: 'Alice', active: true },
|
|
531
|
+
{ name: 'Bob', active: false },
|
|
532
|
+
],
|
|
533
|
+
numbers: [1, 2, 3, 4, 5],
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
// Arrays should become stores
|
|
537
|
+
expect(isStore(todoApp.todos)).toBe(true)
|
|
538
|
+
expect(isStore(todoApp.users)).toBe(true)
|
|
539
|
+
expect(isStore(todoApp.numbers)).toBe(true)
|
|
540
|
+
|
|
541
|
+
// String array elements should be State<string>
|
|
542
|
+
expect(todoApp.todos['0'].get()).toBe('Buy milk')
|
|
543
|
+
expect(todoApp.todos['1'].get()).toBe('Walk the dog')
|
|
544
|
+
expect(todoApp.todos['2'].get()).toBe('Write code')
|
|
545
|
+
|
|
546
|
+
// Should be able to modify string elements
|
|
547
|
+
todoApp.todos['0'].set('Buy groceries')
|
|
548
|
+
expect(todoApp.todos['0'].get()).toBe('Buy groceries')
|
|
549
|
+
|
|
550
|
+
// Object array elements should be Store<T>
|
|
551
|
+
expect(isStore(todoApp.users[0])).toBe(true)
|
|
552
|
+
expect(isStore(todoApp.users[1])).toBe(true)
|
|
553
|
+
|
|
554
|
+
// Should be able to access nested properties in object array elements
|
|
555
|
+
expect(todoApp.users[0].name.get()).toBe('Alice')
|
|
556
|
+
expect(todoApp.users[0].active.get()).toBe(true)
|
|
557
|
+
expect(todoApp.users[1].name.get()).toBe('Bob')
|
|
558
|
+
expect(todoApp.users[1].active.get()).toBe(false)
|
|
559
|
+
|
|
560
|
+
// Should be able to modify nested properties
|
|
561
|
+
todoApp.users[0].name.set('Alice Smith')
|
|
562
|
+
todoApp.users[0].active.set(false)
|
|
563
|
+
expect(todoApp.users[0].name.get()).toBe('Alice Smith')
|
|
564
|
+
expect(todoApp.users[0].active.get()).toBe(false)
|
|
565
|
+
|
|
566
|
+
// Number array elements should be State<number>
|
|
567
|
+
expect(todoApp.numbers[0].get()).toBe(1)
|
|
568
|
+
expect(todoApp.numbers[4].get()).toBe(5)
|
|
569
|
+
|
|
570
|
+
// Should be able to modify number elements
|
|
571
|
+
todoApp.numbers[0].set(10)
|
|
572
|
+
todoApp.numbers[4].set(50)
|
|
573
|
+
expect(todoApp.numbers[0].get()).toBe(10)
|
|
574
|
+
expect(todoApp.numbers[4].get()).toBe(50)
|
|
575
|
+
|
|
576
|
+
// Store-level access should reflect all changes
|
|
577
|
+
const currentState = todoApp.get()
|
|
578
|
+
expect(currentState.todos[0]).toBe('Buy groceries')
|
|
579
|
+
expect(currentState.users[0].name).toBe('Alice Smith')
|
|
580
|
+
expect(currentState.users[0].active).toBe(false)
|
|
581
|
+
expect(currentState.numbers[0]).toBe(10)
|
|
582
|
+
expect(currentState.numbers[4]).toBe(50)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
test('handles UNSET values', () => {
|
|
586
|
+
const data = store({ value: UNSET as string })
|
|
587
|
+
|
|
588
|
+
expect(data.value.get()).toBe(UNSET)
|
|
589
|
+
data.value.set('some string')
|
|
590
|
+
expect(data.value.get()).toBe('some string')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test('handles primitive values', () => {
|
|
594
|
+
const data = store({
|
|
595
|
+
str: 'hello',
|
|
596
|
+
num: 42,
|
|
597
|
+
bool: true,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
expect(data.str.get()).toBe('hello')
|
|
601
|
+
expect(data.num.get()).toBe(42)
|
|
602
|
+
expect(data.bool.get()).toBe(true)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
describe('proxy behavior', () => {
|
|
607
|
+
test('Object.keys returns property keys', () => {
|
|
608
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
609
|
+
|
|
610
|
+
expect(Object.keys(user)).toEqual(['name', 'email'])
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('property enumeration works', () => {
|
|
614
|
+
const user = store({ name: 'Hannah', email: 'hannah@example.com' })
|
|
615
|
+
const keys: string[] = []
|
|
616
|
+
|
|
617
|
+
for (const key in user) {
|
|
618
|
+
keys.push(key)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
expect(keys).toEqual(['name', 'email'])
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('in operator works', () => {
|
|
625
|
+
const user = store({ name: 'Hannah' })
|
|
626
|
+
|
|
627
|
+
expect('name' in user).toBe(true)
|
|
628
|
+
expect('email' in user).toBe(false)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('Object.getOwnPropertyDescriptor works', () => {
|
|
632
|
+
const user = store({ name: 'Hannah' })
|
|
633
|
+
|
|
634
|
+
const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
|
|
635
|
+
expect(descriptor).toEqual({
|
|
636
|
+
enumerable: true,
|
|
637
|
+
configurable: true,
|
|
638
|
+
writable: true,
|
|
639
|
+
value: user.name,
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
describe('type conversion via toSignal', () => {
|
|
645
|
+
test('arrays are converted to stores', () => {
|
|
646
|
+
const fruits = store({ items: ['apple', 'banana', 'cherry'] })
|
|
647
|
+
|
|
648
|
+
expect(isStore(fruits.items)).toBe(true)
|
|
649
|
+
expect(fruits.items['0'].get()).toBe('apple')
|
|
650
|
+
expect(fruits.items['1'].get()).toBe('banana')
|
|
651
|
+
expect(fruits.items['2'].get()).toBe('cherry')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test('nested objects become nested stores', () => {
|
|
655
|
+
const config = store({
|
|
656
|
+
database: {
|
|
657
|
+
host: 'localhost',
|
|
658
|
+
port: 5432,
|
|
659
|
+
},
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
expect(isStore(config.database)).toBe(true)
|
|
663
|
+
expect(config.database.host.get()).toBe('localhost')
|
|
664
|
+
expect(config.database.port.get()).toBe(5432)
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
describe('spread operator behavior', () => {
|
|
669
|
+
test('spreading store spreads individual signals', () => {
|
|
670
|
+
const user = store({ name: 'Hannah', age: 25, active: true })
|
|
671
|
+
|
|
672
|
+
// Spread the store - should get individual signals
|
|
673
|
+
const spread = { ...user }
|
|
674
|
+
|
|
675
|
+
// Check that we get the signals themselves
|
|
676
|
+
expect('name' in spread).toBe(true)
|
|
677
|
+
expect('age' in spread).toBe(true)
|
|
678
|
+
expect('active' in spread).toBe(true)
|
|
679
|
+
|
|
680
|
+
// The spread should contain signals that can be called with .get()
|
|
681
|
+
expect(typeof spread.name?.get).toBe('function')
|
|
682
|
+
expect(typeof spread.age?.get).toBe('function')
|
|
683
|
+
expect(typeof spread.active?.get).toBe('function')
|
|
684
|
+
|
|
685
|
+
// The signals should return the correct values
|
|
686
|
+
expect(spread.name?.get()).toBe('Hannah')
|
|
687
|
+
expect(spread.age?.get()).toBe(25)
|
|
688
|
+
expect(spread.active?.get()).toBe(true)
|
|
689
|
+
|
|
690
|
+
// Modifying the original store should be reflected in the spread signals
|
|
691
|
+
user.name.set('Alice')
|
|
692
|
+
user.age.set(30)
|
|
693
|
+
|
|
694
|
+
expect(spread.name?.get()).toBe('Alice')
|
|
695
|
+
expect(spread.age?.get()).toBe(30)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
test('spreading nested store works correctly', () => {
|
|
699
|
+
const config = store({
|
|
700
|
+
app: { name: 'MyApp', version: '1.0' },
|
|
701
|
+
settings: { theme: 'dark', debug: false },
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const spread = { ...config }
|
|
705
|
+
|
|
706
|
+
// Should get nested store signals
|
|
707
|
+
expect(isStore(spread.app)).toBe(true)
|
|
708
|
+
expect(isStore(spread.settings)).toBe(true)
|
|
709
|
+
|
|
710
|
+
// Should be able to access nested properties
|
|
711
|
+
expect(spread.app.name.get()).toBe('MyApp')
|
|
712
|
+
expect(spread.settings.theme.get()).toBe('dark')
|
|
713
|
+
|
|
714
|
+
// Modifications should be reflected
|
|
715
|
+
config.app.name.set('UpdatedApp')
|
|
716
|
+
expect(spread.app.name.get()).toBe('UpdatedApp')
|
|
717
|
+
})
|
|
718
|
+
})
|
|
719
|
+
})
|