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