@zeix/cause-effect 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai-context.md +163 -232
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +199 -143
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +232 -197
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1325 -997
- package/index.js +1 -1
- package/index.ts +58 -74
- package/package.json +4 -1
- package/src/errors.ts +118 -74
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +466 -706
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +380 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +73 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
package/test/effect.test.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
createEffect,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
createMemo,
|
|
5
|
+
createScope,
|
|
6
|
+
createState,
|
|
7
|
+
createTask,
|
|
6
8
|
match,
|
|
7
|
-
|
|
8
|
-
State,
|
|
9
|
-
Task,
|
|
10
|
-
UNSET,
|
|
9
|
+
RequiredOwnerError,
|
|
11
10
|
} from '../index.ts'
|
|
12
11
|
|
|
13
12
|
/* === Utility Functions === */
|
|
@@ -16,797 +15,395 @@ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
16
15
|
|
|
17
16
|
/* === Tests === */
|
|
18
17
|
|
|
19
|
-
describe('
|
|
20
|
-
test('should
|
|
21
|
-
|
|
22
|
-
let count = 0
|
|
18
|
+
describe('createEffect', () => {
|
|
19
|
+
test('should run immediately on creation', () => {
|
|
20
|
+
let ran = false
|
|
23
21
|
createEffect(() => {
|
|
24
|
-
|
|
25
|
-
count++
|
|
22
|
+
ran = true
|
|
26
23
|
})
|
|
27
|
-
expect(
|
|
28
|
-
cause.set('bar')
|
|
29
|
-
expect(count).toBe(2)
|
|
24
|
+
expect(ran).toBe(true)
|
|
30
25
|
})
|
|
31
26
|
|
|
32
|
-
test('should
|
|
33
|
-
const
|
|
34
|
-
await wait(100)
|
|
35
|
-
return 10
|
|
36
|
-
})
|
|
37
|
-
const b = new Task(async () => {
|
|
38
|
-
await wait(100)
|
|
39
|
-
return 20
|
|
40
|
-
})
|
|
41
|
-
let result = 0
|
|
27
|
+
test('should re-run when a tracked dependency changes', () => {
|
|
28
|
+
const source = createState('foo')
|
|
42
29
|
let count = 0
|
|
43
30
|
createEffect(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ok: ({ a: aValue, b: bValue }) => {
|
|
47
|
-
result = aValue + bValue
|
|
48
|
-
count++
|
|
49
|
-
},
|
|
50
|
-
})
|
|
31
|
+
source.get()
|
|
32
|
+
count++
|
|
51
33
|
})
|
|
52
|
-
expect(result).toBe(0)
|
|
53
|
-
expect(count).toBe(0)
|
|
54
|
-
await wait(110)
|
|
55
|
-
expect(result).toBe(30)
|
|
56
34
|
expect(count).toBe(1)
|
|
35
|
+
source.set('bar')
|
|
36
|
+
expect(count).toBe(2)
|
|
57
37
|
})
|
|
58
38
|
|
|
59
|
-
test('should
|
|
60
|
-
const
|
|
39
|
+
test('should re-run on each state change', () => {
|
|
40
|
+
const source = createState(0)
|
|
61
41
|
let result = 0
|
|
62
|
-
let count = 0
|
|
63
42
|
createEffect(() => {
|
|
64
|
-
result =
|
|
65
|
-
count++
|
|
43
|
+
result = source.get()
|
|
66
44
|
})
|
|
67
|
-
for (let i =
|
|
68
|
-
|
|
45
|
+
for (let i = 1; i <= 5; i++) {
|
|
46
|
+
source.set(i)
|
|
69
47
|
expect(result).toBe(i)
|
|
70
|
-
expect(count).toBe(i + 1) // + 1 for effect initialization
|
|
71
48
|
}
|
|
72
49
|
})
|
|
73
50
|
|
|
74
|
-
test('should handle
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const v = a.get()
|
|
78
|
-
if (v > 5) throw new Error('Value too high')
|
|
79
|
-
return v * 2
|
|
80
|
-
})
|
|
81
|
-
let normalCallCount = 0
|
|
82
|
-
let errorCallCount = 0
|
|
51
|
+
test('should handle state updates inside effects', () => {
|
|
52
|
+
const count = createState(0)
|
|
53
|
+
let effectCount = 0
|
|
83
54
|
createEffect(() => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
ok: () => {
|
|
87
|
-
normalCallCount++
|
|
88
|
-
},
|
|
89
|
-
err: errors => {
|
|
90
|
-
errorCallCount++
|
|
91
|
-
expect(errors[0].message).toBe('Value too high')
|
|
92
|
-
},
|
|
93
|
-
})
|
|
55
|
+
effectCount++
|
|
56
|
+
if (count.get() === 0) count.set(1)
|
|
94
57
|
})
|
|
58
|
+
expect(count.get()).toBe(1)
|
|
59
|
+
expect(effectCount).toBe(2)
|
|
60
|
+
})
|
|
95
61
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
62
|
+
describe('Cleanup', () => {
|
|
63
|
+
test('should call cleanup before next run', () => {
|
|
64
|
+
const source = createState(0)
|
|
65
|
+
let cleanupCount = 0
|
|
66
|
+
let effectCount = 0
|
|
100
67
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
68
|
+
createEffect(() => {
|
|
69
|
+
source.get()
|
|
70
|
+
effectCount++
|
|
71
|
+
return () => {
|
|
72
|
+
cleanupCount++
|
|
73
|
+
}
|
|
74
|
+
})
|
|
105
75
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(normalCallCount).toBe(3)
|
|
109
|
-
expect(errorCallCount).toBe(1)
|
|
110
|
-
})
|
|
76
|
+
expect(effectCount).toBe(1)
|
|
77
|
+
expect(cleanupCount).toBe(0)
|
|
111
78
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const v = a.get()
|
|
116
|
-
if (v > 5) throw new Error('Value too high')
|
|
117
|
-
return v * 2
|
|
118
|
-
})
|
|
119
|
-
let normalCallCount = 0
|
|
120
|
-
let errorCallCount = 0
|
|
121
|
-
createEffect(() => {
|
|
122
|
-
const result = resolve({ b })
|
|
123
|
-
if (result.ok) {
|
|
124
|
-
normalCallCount++
|
|
125
|
-
} else if (result.errors) {
|
|
126
|
-
errorCallCount++
|
|
127
|
-
expect(result.errors[0].message).toBe('Value too high')
|
|
128
|
-
}
|
|
129
|
-
})
|
|
79
|
+
source.set(1)
|
|
80
|
+
expect(effectCount).toBe(2)
|
|
81
|
+
expect(cleanupCount).toBe(1)
|
|
130
82
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
83
|
+
source.set(2)
|
|
84
|
+
expect(effectCount).toBe(3)
|
|
85
|
+
expect(cleanupCount).toBe(2)
|
|
86
|
+
})
|
|
135
87
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
expect(errorCallCount).toBe(1)
|
|
88
|
+
test('should call cleanup on disposal', () => {
|
|
89
|
+
const source = createState(0)
|
|
90
|
+
let cleanupCalled = false
|
|
140
91
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
92
|
+
const dispose = createEffect(() => {
|
|
93
|
+
source.get()
|
|
94
|
+
return () => {
|
|
95
|
+
cleanupCalled = true
|
|
96
|
+
}
|
|
97
|
+
})
|
|
146
98
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return 42
|
|
99
|
+
expect(cleanupCalled).toBe(false)
|
|
100
|
+
dispose()
|
|
101
|
+
expect(cleanupCalled).toBe(true)
|
|
151
102
|
})
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
expect(values.a).toBe(42)
|
|
160
|
-
},
|
|
161
|
-
nil: () => {
|
|
162
|
-
nilCount++
|
|
163
|
-
},
|
|
103
|
+
|
|
104
|
+
test('should stop reacting after disposal', () => {
|
|
105
|
+
const source = createState(42)
|
|
106
|
+
let received = 0
|
|
107
|
+
|
|
108
|
+
const dispose = createEffect(() => {
|
|
109
|
+
received = source.get()
|
|
164
110
|
})
|
|
165
|
-
})
|
|
166
111
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
expect(a.get()).toBe(UNSET)
|
|
170
|
-
await wait(110)
|
|
171
|
-
expect(normalCallCount).toBeGreaterThan(0)
|
|
172
|
-
expect(nilCount).toBe(1)
|
|
173
|
-
expect(a.get()).toBe(42)
|
|
174
|
-
})
|
|
112
|
+
source.set(43)
|
|
113
|
+
expect(received).toBe(43)
|
|
175
114
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return 42
|
|
180
|
-
})
|
|
181
|
-
let normalCallCount = 0
|
|
182
|
-
let nilCount = 0
|
|
183
|
-
createEffect(() => {
|
|
184
|
-
const result = resolve({ a })
|
|
185
|
-
if (result.ok) {
|
|
186
|
-
normalCallCount++
|
|
187
|
-
expect(result.values.a).toBe(42)
|
|
188
|
-
} else if (result.pending) {
|
|
189
|
-
nilCount++
|
|
190
|
-
}
|
|
115
|
+
dispose()
|
|
116
|
+
source.set(44)
|
|
117
|
+
expect(received).toBe(43)
|
|
191
118
|
})
|
|
192
|
-
|
|
193
|
-
expect(normalCallCount).toBe(0)
|
|
194
|
-
expect(nilCount).toBe(1)
|
|
195
|
-
expect(a.get()).toBe(UNSET)
|
|
196
|
-
await wait(110)
|
|
197
|
-
expect(normalCallCount).toBeGreaterThan(0)
|
|
198
|
-
expect(nilCount).toBe(1)
|
|
199
|
-
expect(a.get()).toBe(42)
|
|
200
119
|
})
|
|
201
120
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
console.error = mockConsoleError
|
|
121
|
+
describe('Owner Registration', () => {
|
|
122
|
+
test('should dispose nested effects when parent scope is disposed', () => {
|
|
123
|
+
const source = createState(0)
|
|
124
|
+
let innerRuns = 0
|
|
207
125
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return v * 2
|
|
126
|
+
const dispose = createScope(() => {
|
|
127
|
+
createEffect(() => {
|
|
128
|
+
source.get()
|
|
129
|
+
innerRuns++
|
|
130
|
+
})
|
|
214
131
|
})
|
|
215
132
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
})
|
|
133
|
+
expect(innerRuns).toBe(1)
|
|
134
|
+
source.set(1)
|
|
135
|
+
expect(innerRuns).toBe(2)
|
|
220
136
|
|
|
221
|
-
|
|
222
|
-
|
|
137
|
+
dispose()
|
|
138
|
+
source.set(2)
|
|
139
|
+
expect(innerRuns).toBe(2) // no longer reacting
|
|
140
|
+
})
|
|
141
|
+
})
|
|
223
142
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
143
|
+
describe('Input Validation', () => {
|
|
144
|
+
test('should throw InvalidCallbackError for non-function', () => {
|
|
145
|
+
// @ts-expect-error - Testing invalid input
|
|
146
|
+
expect(() => createEffect(null)).toThrow(
|
|
147
|
+
'[Effect] Callback null is invalid',
|
|
228
148
|
)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
149
|
+
// @ts-expect-error - Testing invalid input
|
|
150
|
+
expect(() => createEffect(42)).toThrow(
|
|
151
|
+
'[Effect] Callback 42 is invalid',
|
|
152
|
+
)
|
|
153
|
+
})
|
|
233
154
|
})
|
|
155
|
+
})
|
|
234
156
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
157
|
+
describe('match', () => {
|
|
158
|
+
test('should call ok handler when all signals have values', () => {
|
|
159
|
+
const a = createState(1)
|
|
160
|
+
const b = createState(2)
|
|
161
|
+
let result = 0
|
|
162
|
+
createEffect(() =>
|
|
163
|
+
match([a, b], {
|
|
164
|
+
ok: ([aVal, bVal]) => {
|
|
165
|
+
result = aVal + bVal
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
expect(result).toBe(3)
|
|
170
|
+
})
|
|
238
171
|
|
|
239
|
-
|
|
240
|
-
|
|
172
|
+
test('should call nil handler when signals are unset', async () => {
|
|
173
|
+
const task = createTask(async () => {
|
|
174
|
+
await wait(50)
|
|
175
|
+
return 42
|
|
241
176
|
})
|
|
177
|
+
let okCount = 0
|
|
178
|
+
let nilCount = 0
|
|
179
|
+
createEffect(() =>
|
|
180
|
+
match([task], {
|
|
181
|
+
ok: ([value]) => {
|
|
182
|
+
okCount++
|
|
183
|
+
expect(value).toBe(42)
|
|
184
|
+
},
|
|
185
|
+
nil: () => {
|
|
186
|
+
nilCount++
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
242
190
|
|
|
243
|
-
|
|
244
|
-
expect(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
expect(received).toBe(43) // Should not update after dispose
|
|
191
|
+
expect(okCount).toBe(0)
|
|
192
|
+
expect(nilCount).toBe(1)
|
|
193
|
+
await wait(60)
|
|
194
|
+
expect(okCount).toBeGreaterThan(0)
|
|
195
|
+
expect(nilCount).toBe(1)
|
|
249
196
|
})
|
|
250
197
|
|
|
251
|
-
test('should
|
|
198
|
+
test('should call err handler when signals throw', () => {
|
|
199
|
+
const a = createState(1)
|
|
200
|
+
const b = createMemo(() => {
|
|
201
|
+
if (a.get() > 5) throw new Error('Too high')
|
|
202
|
+
return a.get() * 2
|
|
203
|
+
})
|
|
252
204
|
let okCount = 0
|
|
253
205
|
let errCount = 0
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
createEffect(() => {
|
|
257
|
-
const resolved = resolve({ count })
|
|
258
|
-
match(resolved, {
|
|
206
|
+
createEffect(() =>
|
|
207
|
+
match([b], {
|
|
259
208
|
ok: () => {
|
|
260
209
|
okCount++
|
|
261
|
-
// This effect updates the signal it depends on, creating a circular dependency
|
|
262
|
-
count.update(v => ++v)
|
|
263
210
|
},
|
|
264
211
|
err: errors => {
|
|
265
212
|
errCount++
|
|
266
|
-
expect(errors[0]).
|
|
267
|
-
expect(errors[0].message).toBe(
|
|
268
|
-
'Circular dependency detected in effect',
|
|
269
|
-
)
|
|
213
|
+
expect(errors[0].message).toBe('Too high')
|
|
270
214
|
},
|
|
271
|
-
})
|
|
272
|
-
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
273
217
|
|
|
274
|
-
// Verify that the count was changed only once due to the circular dependency error
|
|
275
|
-
expect(count.get()).toBe(1)
|
|
276
218
|
expect(okCount).toBe(1)
|
|
219
|
+
a.set(6)
|
|
277
220
|
expect(errCount).toBe(1)
|
|
278
|
-
})
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
describe('Effect - Async with AbortSignal', () => {
|
|
282
|
-
test('should pass AbortSignal to async effect callback', async () => {
|
|
283
|
-
let abortSignalReceived = false
|
|
284
|
-
let effectCompleted = false
|
|
285
|
-
|
|
286
|
-
createEffect(async (abort: AbortSignal) => {
|
|
287
|
-
expect(abort).toBeInstanceOf(AbortSignal)
|
|
288
|
-
expect(abort.aborted).toBe(false)
|
|
289
|
-
abortSignalReceived = true
|
|
290
|
-
|
|
291
|
-
await wait(50)
|
|
292
|
-
effectCompleted = true
|
|
293
|
-
return () => {}
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
expect(abortSignalReceived).toBe(true)
|
|
297
|
-
await wait(60)
|
|
298
|
-
expect(effectCompleted).toBe(true)
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
test('should abort async operations when signal changes', async () => {
|
|
302
|
-
const testSignal = new State(1)
|
|
303
|
-
let operationAborted = false
|
|
304
|
-
let operationCompleted = false
|
|
305
|
-
let abortReason: DOMException | undefined
|
|
306
|
-
|
|
307
|
-
createEffect(async abort => {
|
|
308
|
-
const result = resolve({ testSignal })
|
|
309
|
-
if (!result.ok) return
|
|
310
|
-
|
|
311
|
-
abort.addEventListener('abort', () => {
|
|
312
|
-
operationAborted = true
|
|
313
|
-
abortReason = abort.reason
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
await wait(100)
|
|
318
|
-
operationCompleted = true
|
|
319
|
-
} catch (error) {
|
|
320
|
-
if (
|
|
321
|
-
error instanceof DOMException &&
|
|
322
|
-
error.name === 'AbortError'
|
|
323
|
-
) {
|
|
324
|
-
operationAborted = true
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
// Change signal quickly to trigger abort
|
|
330
|
-
await wait(20)
|
|
331
|
-
testSignal.set(2)
|
|
332
|
-
|
|
333
|
-
await wait(50)
|
|
334
|
-
expect(operationAborted).toBe(true)
|
|
335
|
-
expect(operationCompleted).toBe(false)
|
|
336
|
-
expect(abortReason instanceof DOMException).toBe(true)
|
|
337
|
-
expect((abortReason as DOMException).name).toBe('AbortError')
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
test('should abort async operations on effect cleanup', async () => {
|
|
341
|
-
let operationAborted = false
|
|
342
|
-
let abortReason: DOMException | undefined
|
|
343
|
-
|
|
344
|
-
const cleanup = createEffect(async abort => {
|
|
345
|
-
abort.addEventListener('abort', () => {
|
|
346
|
-
operationAborted = true
|
|
347
|
-
abortReason = abort.reason
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
await wait(100)
|
|
351
|
-
})
|
|
352
221
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
await wait(30)
|
|
357
|
-
expect(operationAborted).toBe(true)
|
|
358
|
-
expect(abortReason instanceof DOMException).toBe(true)
|
|
359
|
-
expect((abortReason as DOMException).name).toBe('AbortError')
|
|
222
|
+
a.set(3)
|
|
223
|
+
expect(okCount).toBe(2)
|
|
224
|
+
expect(errCount).toBe(1)
|
|
360
225
|
})
|
|
361
226
|
|
|
362
|
-
test('should
|
|
227
|
+
test('should fall back to console.error when err handler is not provided', () => {
|
|
363
228
|
const originalConsoleError = console.error
|
|
364
229
|
const mockConsoleError = mock(() => {})
|
|
365
230
|
console.error = mockConsoleError
|
|
366
231
|
|
|
367
232
|
try {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (!result.ok) return
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
await new Promise((resolve, reject) => {
|
|
376
|
-
const timeout = setTimeout(resolve, 100)
|
|
377
|
-
abort.addEventListener('abort', () => {
|
|
378
|
-
clearTimeout(timeout)
|
|
379
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
380
|
-
})
|
|
381
|
-
})
|
|
382
|
-
} catch (error) {
|
|
383
|
-
if (
|
|
384
|
-
error instanceof DOMException &&
|
|
385
|
-
error.name === 'AbortError'
|
|
386
|
-
) {
|
|
387
|
-
// This is expected, should not be logged
|
|
388
|
-
return
|
|
389
|
-
} else {
|
|
390
|
-
throw error
|
|
391
|
-
}
|
|
392
|
-
}
|
|
233
|
+
const a = createState(1)
|
|
234
|
+
const b = createMemo(() => {
|
|
235
|
+
if (a.get() > 5) throw new Error('Too high')
|
|
236
|
+
return a.get() * 2
|
|
393
237
|
})
|
|
394
238
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// Should not have logged the AbortError
|
|
400
|
-
expect(mockConsoleError).not.toHaveBeenCalledWith(
|
|
401
|
-
'Effect callback error:',
|
|
402
|
-
expect.any(DOMException),
|
|
239
|
+
createEffect(() =>
|
|
240
|
+
match([b], {
|
|
241
|
+
ok: () => {},
|
|
242
|
+
}),
|
|
403
243
|
)
|
|
244
|
+
|
|
245
|
+
a.set(6)
|
|
246
|
+
expect(mockConsoleError).toHaveBeenCalled()
|
|
404
247
|
} finally {
|
|
405
248
|
console.error = originalConsoleError
|
|
406
249
|
}
|
|
407
250
|
})
|
|
408
251
|
|
|
409
|
-
test('should
|
|
410
|
-
|
|
411
|
-
let cleanupRegistered = false
|
|
412
|
-
const testSignal = new State('initial')
|
|
413
|
-
|
|
414
|
-
const cleanup = createEffect(async () => {
|
|
415
|
-
const result = resolve({ testSignal })
|
|
416
|
-
if (!result.ok) return
|
|
417
|
-
|
|
418
|
-
await wait(30)
|
|
419
|
-
asyncEffectCompleted = true
|
|
420
|
-
return () => {
|
|
421
|
-
cleanupRegistered = true
|
|
422
|
-
}
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
// Wait for async effect to complete
|
|
426
|
-
await wait(50)
|
|
427
|
-
expect(asyncEffectCompleted).toBe(true)
|
|
428
|
-
|
|
429
|
-
cleanup()
|
|
430
|
-
expect(cleanupRegistered).toBe(true)
|
|
431
|
-
expect(cleanup).toBeInstanceOf(Function)
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
test('should handle rapid signal changes with concurrent async operations', async () => {
|
|
435
|
-
const testSignal = new State(0)
|
|
436
|
-
let completedOperations = 0
|
|
437
|
-
let abortedOperations = 0
|
|
438
|
-
|
|
439
|
-
createEffect(async abort => {
|
|
440
|
-
const result = resolve({ testSignal })
|
|
441
|
-
if (!result.ok) return
|
|
442
|
-
|
|
443
|
-
try {
|
|
444
|
-
await wait(30)
|
|
445
|
-
if (!abort.aborted) {
|
|
446
|
-
completedOperations++
|
|
447
|
-
}
|
|
448
|
-
} catch (error) {
|
|
449
|
-
if (
|
|
450
|
-
error instanceof DOMException &&
|
|
451
|
-
error.name === 'AbortError'
|
|
452
|
-
) {
|
|
453
|
-
abortedOperations++
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
// Rapidly change signal multiple times
|
|
459
|
-
testSignal.set(1)
|
|
460
|
-
await wait(5)
|
|
461
|
-
testSignal.set(2)
|
|
462
|
-
await wait(5)
|
|
463
|
-
testSignal.set(3)
|
|
464
|
-
await wait(5)
|
|
465
|
-
testSignal.set(4)
|
|
466
|
-
|
|
467
|
-
// Wait for all operations to complete or abort
|
|
468
|
-
await wait(60)
|
|
469
|
-
|
|
470
|
-
// Only the last operation should complete
|
|
471
|
-
expect(completedOperations).toBe(1)
|
|
472
|
-
expect(abortedOperations).toBe(0) // AbortError is handled gracefully, not thrown
|
|
252
|
+
test('should throw RequiredOwnerError when called outside an owner', () => {
|
|
253
|
+
expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError)
|
|
473
254
|
})
|
|
474
255
|
|
|
475
|
-
test('should
|
|
476
|
-
const
|
|
477
|
-
const mockConsoleError = mock(() => {})
|
|
478
|
-
console.error = mockConsoleError
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
const testSignal = new State(1)
|
|
482
|
-
|
|
483
|
-
const errorThrower = new Memo(() => {
|
|
484
|
-
const value = testSignal.get()
|
|
485
|
-
if (value > 5) throw new Error('Value too high')
|
|
486
|
-
return value
|
|
487
|
-
})
|
|
488
|
-
|
|
489
|
-
createEffect(async () => {
|
|
490
|
-
const result = resolve({ errorThrower })
|
|
491
|
-
if (result.ok) {
|
|
492
|
-
// Normal operation
|
|
493
|
-
} else if (result.errors) {
|
|
494
|
-
// Handle errors from resolve
|
|
495
|
-
expect(result.errors[0].message).toBe('Value too high')
|
|
496
|
-
return
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Simulate an async error that's not an AbortError
|
|
500
|
-
if (result.ok && result.values.errorThrower > 3) {
|
|
501
|
-
throw new Error('Async processing error')
|
|
502
|
-
}
|
|
503
|
-
})
|
|
504
|
-
|
|
505
|
-
testSignal.set(4) // This will cause an async error
|
|
256
|
+
test('should resolve multiple async tasks without waterfalls', async () => {
|
|
257
|
+
const a = createTask(async () => {
|
|
506
258
|
await wait(20)
|
|
507
|
-
|
|
508
|
-
// Should have logged the async error
|
|
509
|
-
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
510
|
-
'Error in async effect callback:',
|
|
511
|
-
expect.any(Error),
|
|
512
|
-
)
|
|
513
|
-
} finally {
|
|
514
|
-
console.error = originalConsoleError
|
|
515
|
-
}
|
|
516
|
-
})
|
|
517
|
-
|
|
518
|
-
test('should handle promise-based async effects', async () => {
|
|
519
|
-
let promiseResolved = false
|
|
520
|
-
let effectValue = ''
|
|
521
|
-
const testSignal = new State('test-value')
|
|
522
|
-
|
|
523
|
-
createEffect(async abort => {
|
|
524
|
-
const result = resolve({ testSignal })
|
|
525
|
-
if (!result.ok) return
|
|
526
|
-
|
|
527
|
-
// Simulate async work that respects abort signal
|
|
528
|
-
await new Promise<void>((resolve, reject) => {
|
|
529
|
-
const timeout = setTimeout(() => {
|
|
530
|
-
effectValue = result.values.testSignal
|
|
531
|
-
promiseResolved = true
|
|
532
|
-
resolve()
|
|
533
|
-
}, 40)
|
|
534
|
-
|
|
535
|
-
abort.addEventListener('abort', () => {
|
|
536
|
-
clearTimeout(timeout)
|
|
537
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
538
|
-
})
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
return () => {
|
|
542
|
-
// Cleanup function
|
|
543
|
-
}
|
|
259
|
+
return 10
|
|
544
260
|
})
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
expect(effectValue).toBe('test-value')
|
|
549
|
-
})
|
|
550
|
-
|
|
551
|
-
test('should not create AbortController for sync functions', () => {
|
|
552
|
-
const testSignal = new State('test')
|
|
553
|
-
let syncCallCount = 0
|
|
554
|
-
|
|
555
|
-
// Mock AbortController constructor to detect if it's called
|
|
556
|
-
const originalAbortController = globalThis.AbortController
|
|
557
|
-
let abortControllerCreated = false
|
|
558
|
-
|
|
559
|
-
globalThis.AbortController = class extends originalAbortController {
|
|
560
|
-
constructor() {
|
|
561
|
-
super()
|
|
562
|
-
abortControllerCreated = true
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
try {
|
|
567
|
-
createEffect(() => {
|
|
568
|
-
const result = resolve({ testSignal })
|
|
569
|
-
if (result.ok) {
|
|
570
|
-
syncCallCount++
|
|
571
|
-
}
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
testSignal.set('changed')
|
|
575
|
-
expect(syncCallCount).toBe(2)
|
|
576
|
-
expect(abortControllerCreated).toBe(false)
|
|
577
|
-
} finally {
|
|
578
|
-
globalThis.AbortController = originalAbortController
|
|
579
|
-
}
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
test('should handle concurrent async operations with abort', async () => {
|
|
583
|
-
const testSignal = new State(1)
|
|
584
|
-
let operation1Completed = false
|
|
585
|
-
let operation1Aborted = false
|
|
586
|
-
|
|
587
|
-
createEffect(async abort => {
|
|
588
|
-
const result = resolve({ testSignal })
|
|
589
|
-
if (!result.ok) return
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
// Create a promise that can be aborted
|
|
593
|
-
await new Promise<void>((resolve, reject) => {
|
|
594
|
-
const timeout = setTimeout(() => {
|
|
595
|
-
operation1Completed = true
|
|
596
|
-
resolve()
|
|
597
|
-
}, 80)
|
|
598
|
-
|
|
599
|
-
abort.addEventListener('abort', () => {
|
|
600
|
-
operation1Aborted = true
|
|
601
|
-
clearTimeout(timeout)
|
|
602
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
603
|
-
})
|
|
604
|
-
})
|
|
605
|
-
} catch (error) {
|
|
606
|
-
if (
|
|
607
|
-
error instanceof DOMException &&
|
|
608
|
-
error.name === 'AbortError'
|
|
609
|
-
) {
|
|
610
|
-
// Expected when aborted
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
throw error
|
|
614
|
-
}
|
|
261
|
+
const b = createTask(async () => {
|
|
262
|
+
await wait(20)
|
|
263
|
+
return 20
|
|
615
264
|
})
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
265
|
+
let result = 0
|
|
266
|
+
let nilCount = 0
|
|
267
|
+
createEffect(() =>
|
|
268
|
+
match([a, b], {
|
|
269
|
+
ok: ([aVal, bVal]) => {
|
|
270
|
+
result = aVal + bVal
|
|
271
|
+
},
|
|
272
|
+
nil: () => {
|
|
273
|
+
nilCount++
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
expect(result).toBe(0)
|
|
278
|
+
expect(nilCount).toBe(1)
|
|
624
279
|
await wait(30)
|
|
625
|
-
|
|
626
|
-
expect(operation1Aborted).toBe(true)
|
|
627
|
-
expect(operation1Completed).toBe(false)
|
|
280
|
+
expect(result).toBe(30)
|
|
628
281
|
})
|
|
629
|
-
})
|
|
630
282
|
|
|
631
|
-
describe('
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
283
|
+
describe('Async Handlers', () => {
|
|
284
|
+
test('should not register cleanup from stale async handler after disposal', async () => {
|
|
285
|
+
let cleanupRegistered = false
|
|
286
|
+
|
|
287
|
+
const dispose = createEffect(() =>
|
|
288
|
+
match([], {
|
|
289
|
+
ok: async () => {
|
|
290
|
+
await wait(50)
|
|
291
|
+
return () => {
|
|
292
|
+
cleanupRegistered = true
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
636
297
|
|
|
637
|
-
|
|
638
|
-
|
|
298
|
+
await wait(10)
|
|
299
|
+
dispose()
|
|
300
|
+
await wait(60)
|
|
639
301
|
|
|
640
|
-
|
|
641
|
-
effectRan = true
|
|
642
|
-
expect(result.values.a).toBe(10)
|
|
643
|
-
expect(result.values.b).toBe('hello')
|
|
644
|
-
}
|
|
302
|
+
expect(cleanupRegistered).toBe(false)
|
|
645
303
|
})
|
|
646
304
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
305
|
+
test('should register and run cleanup from completed async handler', async () => {
|
|
306
|
+
let cleanupCalled = false
|
|
307
|
+
|
|
308
|
+
const dispose = createEffect(() =>
|
|
309
|
+
match([], {
|
|
310
|
+
ok: async () => {
|
|
311
|
+
await wait(10)
|
|
312
|
+
return () => {
|
|
313
|
+
cleanupCalled = true
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
653
318
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
ok: values => {
|
|
658
|
-
matchedValue = values.a
|
|
659
|
-
},
|
|
660
|
-
})
|
|
319
|
+
await wait(20)
|
|
320
|
+
dispose()
|
|
321
|
+
expect(cleanupCalled).toBe(true)
|
|
661
322
|
})
|
|
662
323
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
})
|
|
666
|
-
|
|
667
|
-
describe('Effect - Race Conditions and Consistency', () => {
|
|
668
|
-
test('should handle race conditions between abort and cleanup properly', async () => {
|
|
669
|
-
// This test explores potential race conditions in effect cleanup
|
|
670
|
-
const testSignal = new State(0)
|
|
671
|
-
let cleanupCallCount = 0
|
|
672
|
-
let abortCallCount = 0
|
|
673
|
-
let operationCount = 0
|
|
674
|
-
|
|
675
|
-
createEffect(async abort => {
|
|
676
|
-
testSignal.get()
|
|
677
|
-
++operationCount
|
|
678
|
-
|
|
679
|
-
abort.addEventListener('abort', () => {
|
|
680
|
-
abortCallCount++
|
|
681
|
-
})
|
|
324
|
+
test('should route async errors to err handler', async () => {
|
|
325
|
+
const originalConsoleError = console.error
|
|
326
|
+
const mockConsoleError = mock(() => {})
|
|
327
|
+
console.error = mockConsoleError
|
|
682
328
|
|
|
683
329
|
try {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
330
|
+
const source = createState(1)
|
|
331
|
+
|
|
332
|
+
createEffect(() =>
|
|
333
|
+
match([source], {
|
|
334
|
+
ok: async ([value]) => {
|
|
335
|
+
await wait(10)
|
|
336
|
+
if (value > 3) throw new Error('Async error')
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
source.set(4)
|
|
342
|
+
await wait(20)
|
|
343
|
+
|
|
344
|
+
expect(mockConsoleError).toHaveBeenCalled()
|
|
345
|
+
} finally {
|
|
346
|
+
console.error = originalConsoleError
|
|
691
347
|
}
|
|
692
348
|
})
|
|
693
349
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const comp = new Task(async () => {
|
|
716
|
-
computedRetries++
|
|
717
|
-
await wait(30)
|
|
718
|
-
return source.get() * 2
|
|
719
|
-
})
|
|
720
|
-
|
|
721
|
-
// Effect without abort listener (current implementation)
|
|
722
|
-
createEffect(async () => {
|
|
723
|
-
effectRuns++
|
|
724
|
-
// Must access the source to make effect reactive
|
|
725
|
-
source.get()
|
|
726
|
-
await wait(30)
|
|
727
|
-
resolve({ comp })
|
|
728
|
-
// Effect doesn't need to return a value immediately
|
|
729
|
-
})
|
|
730
|
-
|
|
731
|
-
// Change source rapidly
|
|
732
|
-
source.set(2)
|
|
733
|
-
await wait(10)
|
|
734
|
-
source.set(3)
|
|
735
|
-
await wait(50)
|
|
736
|
-
|
|
737
|
-
// Computed should retry efficiently due to abort listener
|
|
738
|
-
// Effect should handle the changes naturally through dependency tracking
|
|
739
|
-
expect(computedRetries).toBeGreaterThan(0)
|
|
740
|
-
expect(effectRuns).toBeGreaterThan(0)
|
|
741
|
-
})
|
|
350
|
+
test('should discard stale async cleanup when effect re-runs', async () => {
|
|
351
|
+
const source = createState(1)
|
|
352
|
+
let staleCleanupCalled = false
|
|
353
|
+
let freshCleanupCalled = false
|
|
354
|
+
|
|
355
|
+
const dispose = createEffect(() =>
|
|
356
|
+
match([source], {
|
|
357
|
+
ok: async ([value]) => {
|
|
358
|
+
if (value === 1) {
|
|
359
|
+
await wait(80)
|
|
360
|
+
return () => {
|
|
361
|
+
staleCleanupCalled = true
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
await wait(10)
|
|
365
|
+
return () => {
|
|
366
|
+
freshCleanupCalled = true
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
742
371
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
let cleanupCallCount = 0
|
|
747
|
-
let effectRunCount = 0
|
|
748
|
-
let staleCleanupAttempts = 0
|
|
372
|
+
await wait(20)
|
|
373
|
+
source.set(2)
|
|
374
|
+
await wait(100)
|
|
749
375
|
|
|
750
|
-
|
|
751
|
-
effectRunCount++
|
|
752
|
-
const currentRun = effectRunCount
|
|
753
|
-
testSignal.get() // Make reactive
|
|
376
|
+
expect(staleCleanupCalled).toBe(false)
|
|
754
377
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// This cleanup should only be registered for the latest run
|
|
758
|
-
return () => {
|
|
759
|
-
cleanupCallCount++
|
|
760
|
-
if (currentRun !== effectRunCount) {
|
|
761
|
-
staleCleanupAttempts++
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
} catch (error) {
|
|
765
|
-
if (!isAbortError(error)) throw error
|
|
766
|
-
return undefined
|
|
767
|
-
}
|
|
378
|
+
dispose()
|
|
379
|
+
expect(freshCleanupCalled).toBe(true)
|
|
768
380
|
})
|
|
769
381
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
382
|
+
test('should call async cleanup before re-running', async () => {
|
|
383
|
+
const source = createState(0)
|
|
384
|
+
let cleanupCount = 0
|
|
385
|
+
let okCount = 0
|
|
386
|
+
|
|
387
|
+
createEffect(() =>
|
|
388
|
+
match([source], {
|
|
389
|
+
ok: async () => {
|
|
390
|
+
okCount++
|
|
391
|
+
await wait(10)
|
|
392
|
+
return () => {
|
|
393
|
+
cleanupCount++
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
)
|
|
783
398
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
let computeAttempts = 0
|
|
788
|
-
let finalValue: number = 0
|
|
399
|
+
await wait(20)
|
|
400
|
+
expect(okCount).toBe(1)
|
|
401
|
+
expect(cleanupCount).toBe(0)
|
|
789
402
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
await wait(
|
|
793
|
-
|
|
403
|
+
source.set(1)
|
|
404
|
+
expect(cleanupCount).toBe(1)
|
|
405
|
+
await wait(20)
|
|
406
|
+
expect(okCount).toBe(2)
|
|
794
407
|
})
|
|
795
|
-
|
|
796
|
-
// Start computation
|
|
797
|
-
expect(comp.get()).toBe(UNSET)
|
|
798
|
-
|
|
799
|
-
// Change source during computation - this should trigger immediate retry
|
|
800
|
-
await wait(10)
|
|
801
|
-
source.set(5)
|
|
802
|
-
|
|
803
|
-
// Wait for computation to complete
|
|
804
|
-
await wait(50)
|
|
805
|
-
finalValue = comp.get()
|
|
806
|
-
|
|
807
|
-
// The abort listener allows immediate retry, so we should get the latest value
|
|
808
|
-
expect(finalValue).toBe(10) // 5 * 2
|
|
809
|
-
// Note: The number of attempts can vary due to timing, but should get correct result
|
|
810
|
-
expect(computeAttempts).toBeGreaterThanOrEqual(1)
|
|
811
408
|
})
|
|
812
409
|
})
|